@duke-dsh-plugins/dsh-agent-approval 1.7.2 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/client.js +240 -11
- package/cordis.patch.yml +12 -6
- package/index.js +1021 -68
- package/package.json +5 -5
- package/typert.host.js +105 -2
package/index.js
CHANGED
|
@@ -164,7 +164,40 @@ const JEV_QUESTIONS = {
|
|
|
164
164
|
};
|
|
165
165
|
|
|
166
166
|
/**
|
|
167
|
-
*
|
|
167
|
+
* v1.8.0: the per-call review mode (`agent-review` preset). The SAME policy
|
|
168
|
+
* criteria as `JEV_QUESTIONS` — only the decision instruction wording adapts
|
|
169
|
+
* from "escalation request" to the pending tool call.
|
|
170
|
+
*/
|
|
171
|
+
const JEV_REVIEW_QUESTIONS = {
|
|
172
|
+
...JEV_QUESTIONS,
|
|
173
|
+
decision: {
|
|
174
|
+
...JEV_QUESTIONS.decision,
|
|
175
|
+
instructions: "Should this pending tool call be allowed to execute?",
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The per-call review preset key (registered by the package's
|
|
181
|
+
* `cordis.patch.yml` `permission` row override; key avoids the reserved
|
|
182
|
+
* `auto`/`custom` names). Its bundle is Full access base + `ask` policy —
|
|
183
|
+
* `ask` is the required preset knob value, NOT a human fallback: every
|
|
184
|
+
* review denial is FINAL (fail-closed, no human review — user ruling
|
|
185
|
+
* 2026-10).
|
|
186
|
+
*/
|
|
187
|
+
const REVIEW_PRESET_NAME = "agent-review";
|
|
188
|
+
/** The review preset's sandbox base (the mode pins the session here). */
|
|
189
|
+
const REVIEW_BASE_MODE = "danger-full-access";
|
|
190
|
+
/**
|
|
191
|
+
* The outer PTC transport tool name — deliberately EXCLUDED from per-call
|
|
192
|
+
* review (aligned with the official auto-review scope); every native call
|
|
193
|
+
* and every started PTC inner call IS reviewed.
|
|
194
|
+
*/
|
|
195
|
+
const RUN_CODE_TOOL = "run_code";
|
|
196
|
+
/** Structured error identity shown on a final review denial tool card. */
|
|
197
|
+
const REVIEW_DENIED_NAME = "AgentReviewDeniedError";
|
|
198
|
+
const REVIEW_DENIED_CODE = "AGENT_REVIEW_DENIED";
|
|
199
|
+
|
|
200
|
+
/** Constrained to the
|
|
168
201
|
* JSON-Schema subset `assertObjectJsonSchema` enforces for subagent outputs
|
|
169
202
|
* (type/properties/required/additionalProperties/enum only).
|
|
170
203
|
*/
|
|
@@ -190,14 +223,48 @@ const VERDICT_SCHEMA = {
|
|
|
190
223
|
additionalProperties: false,
|
|
191
224
|
};
|
|
192
225
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
226
|
+
/**
|
|
227
|
+
* v1.8.0 judge invocation modes. `"llm"` (the default) judges through ONE
|
|
228
|
+
* direct `ctx.llm.stream()` call — no subagent session is created, so the
|
|
229
|
+
* requesting session keeps zero judge-side context pollution (no child in
|
|
230
|
+
* the session list, no `subagent/descriptor` events). `"subagent"` spawns
|
|
231
|
+
* the isolated judge child as before. Both modes share the same ground
|
|
232
|
+
* truth, persona and VERDICT_SCHEMA contract — only the invocation (and the
|
|
233
|
+
* output-channel wording) differs.
|
|
234
|
+
*/
|
|
235
|
+
const JUDGE_MODE_LLM = "llm";
|
|
236
|
+
const JUDGE_MODE_SUBAGENT = "subagent";
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Output-channel wording — the ONLY text difference between the two judge
|
|
240
|
+
* modes. The structured_output phrase targets the spawn path's schema tool;
|
|
241
|
+
* the JSON phrase is its one-shot stream equivalent (same VERDICT_SCHEMA
|
|
242
|
+
* contract, validated after parsing).
|
|
243
|
+
*/
|
|
244
|
+
const OUTPUT_VIA_STRUCTURED_TOOL = "through the structured_output tool.";
|
|
245
|
+
const OUTPUT_VIA_JSON =
|
|
246
|
+
'as one JSON object of exactly the shape {"decision":"approve"|"reject","riskLevel":"low"|"medium"|"high","rationale":"two or three sentences justifying the verdict"}.';
|
|
247
|
+
const PROMPT_TAIL_STRUCTURED = "Report the verdict via the structured_output tool only.";
|
|
248
|
+
const PROMPT_TAIL_JSON =
|
|
249
|
+
'Respond with exactly one JSON object of exactly the shape {"decision":"approve"|"reject","riskLevel":"low"|"medium"|"high","rationale":"two or three sentences justifying the verdict"} and nothing else.';
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Shadowing persona for the judge. Identical in both modes except the output
|
|
253
|
+
* clause — assembled so the spawn-mode text stays byte-identical to the
|
|
254
|
+
* pre-1.8.0 `APPROVER_PERSONA` constant.
|
|
255
|
+
*/
|
|
256
|
+
function approverPersona(outputClause) {
|
|
257
|
+
return [
|
|
258
|
+
"You are an independent security approval agent inside a coding harness.",
|
|
259
|
+
"Your only job is to judge ONE request for wider sandbox access and report the verdict " + outputClause,
|
|
260
|
+
"You reject what is concretely dangerous — destructive or irreversible operations, ones that reach outside their stated purpose, or requests whose stated justification does not match the actual arguments. Mere uncertainty, an unfamiliar command, or a terse justification is never enough: every rejection must name the concrete risk the operation creates.",
|
|
261
|
+
"Your own judging session is deliberately sandboxed: approvals are disabled for YOU and your permission scope is fixed. That describes only your own environment — never cite your own constraints (or anything your runtime context says about YOUR permissions) as a property of the requesting session or as grounds for rejection.",
|
|
262
|
+
"You never ask questions, never attempt the operation yourself, and never finish with a plain-text answer.",
|
|
263
|
+
].join(" ");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The spawn-mode persona (byte-identical to the pre-1.8.0 constant). */
|
|
267
|
+
const APPROVER_PERSONA = approverPersona(OUTPUT_VIA_STRUCTURED_TOOL);
|
|
201
268
|
|
|
202
269
|
// ---- helpers ----------------------------------------------------------------
|
|
203
270
|
|
|
@@ -284,6 +351,8 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
284
351
|
async [Service.init]() {
|
|
285
352
|
markRemoteMethod(this, "getState", "getState");
|
|
286
353
|
markRemoteMethod(this, "setModel", "setModel");
|
|
354
|
+
markRemoteMethod(this, "setJudgeMode", "setJudgeMode");
|
|
355
|
+
markRemoteMethod(this, "setReviewDefault", "setReviewDefault");
|
|
287
356
|
markRemoteMethod(this, "setJevConfig", "setJevConfig");
|
|
288
357
|
markRemoteMethod(this, "setApprovalTimeout", "setApprovalTimeout");
|
|
289
358
|
markRemoteMethod(this, "toggle", "toggle");
|
|
@@ -294,6 +363,20 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
294
363
|
|
|
295
364
|
/** Judge model override; empty strings = use the harness default route. */
|
|
296
365
|
this._model = { provider: "", model: "" };
|
|
366
|
+
/**
|
|
367
|
+
* Judge invocation mode: "llm" (default — one direct ctx.llm.stream()
|
|
368
|
+
* call, no subagent session) or "subagent" (the isolated judge child).
|
|
369
|
+
* Persisted; the wire field is `judgeMode`.
|
|
370
|
+
*/
|
|
371
|
+
this._judgeMode = JUDGE_MODE_LLM;
|
|
372
|
+
/**
|
|
373
|
+
* v1.8.0: global default for the per-call review mode — when on, FRESH
|
|
374
|
+
* sessions (no genuine user message yet) auto-enter 自动审查 at creation,
|
|
375
|
+
* subject to the Jev gate. Resumed sessions keep their folded selection;
|
|
376
|
+
* per-session switching stays in the /permission menu and /agent-review
|
|
377
|
+
* command. Persisted (`reviewDefault` in config.json).
|
|
378
|
+
*/
|
|
379
|
+
this._reviewDefault = false;
|
|
297
380
|
/**
|
|
298
381
|
* TypeSafe Jev direct backend settings (used when `_model.provider` is
|
|
299
382
|
* the synthetic `typesafe` id). The API key lives in plaintext on this
|
|
@@ -332,6 +415,14 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
332
415
|
// untouched.
|
|
333
416
|
this.ctx.on("approval/request", (req, next) => this._onApprovalRequest(req, next), { prepend: true });
|
|
334
417
|
|
|
418
|
+
// v1.8.0: the per-call review mode claims `tools/pre-execute` before any
|
|
419
|
+
// tool body runs (the same seam the official experimental-auto-review
|
|
420
|
+
// uses), outermost via prepend — but only for sessions enabled in REVIEW
|
|
421
|
+
// mode; everyone else delegates untouched. Coverage: every native call
|
|
422
|
+
// and every started PTC inner call, excluding the outer run_code
|
|
423
|
+
// transport. Denials are final (fail-closed, no human fallback).
|
|
424
|
+
this.ctx.on("tools/pre-execute", (exec, next) => this._onPreExecute(exec, next), { prepend: true });
|
|
425
|
+
|
|
335
426
|
// Permission-menu integration: react to preset selections recorded in the
|
|
336
427
|
// durable log (the composer /permission control and the /permission
|
|
337
428
|
// command both write `permission/preset` through permissionPresets.set).
|
|
@@ -347,7 +438,18 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
347
438
|
if (this._enabled.has(session.id)) return;
|
|
348
439
|
const agent = this.ctx.agents.get(session.id);
|
|
349
440
|
if (agent === undefined) return; // not live (yet) — agent/created covers it
|
|
350
|
-
this._enableCore(session, agent);
|
|
441
|
+
this._enableCore(session, agent, "escalation");
|
|
442
|
+
} else if (name === REVIEW_PRESET_NAME) {
|
|
443
|
+
if (this._enabled.has(session.id)) return;
|
|
444
|
+
const agent = this.ctx.agents.get(session.id);
|
|
445
|
+
if (agent === undefined) return; // not live (yet) — agent/created covers it
|
|
446
|
+
// Gate layer 3: selecting 自动审查 without a usable Jev judge must
|
|
447
|
+
// not leave Full access with nobody judging — bounce (fail closed).
|
|
448
|
+
if (!this._jevGateOk()) {
|
|
449
|
+
this._reviewGateFallback(session, agent);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this._enableCore(session, agent, "review");
|
|
351
453
|
} else if (this._enabled.has(session.id)) {
|
|
352
454
|
this._enabled.delete(session.id);
|
|
353
455
|
this._trusted.delete(session.id);
|
|
@@ -367,8 +469,37 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
367
469
|
const agent = payload && payload.agent;
|
|
368
470
|
if (!agent || !agent.session) return;
|
|
369
471
|
if (this._enabled.has(agent.session.id)) return;
|
|
370
|
-
|
|
371
|
-
|
|
472
|
+
const preset = this._lastKnob(agent.session, "permission/preset", "preset");
|
|
473
|
+
if (preset === REVIEW_PRESET_NAME) {
|
|
474
|
+
// Restart survival for 自动审查 — re-checked against the gate: a
|
|
475
|
+
// Jev key removed while the app was down fails closed to the
|
|
476
|
+
// agent-approval preset instead of restoring bare Full access.
|
|
477
|
+
if (!this._jevGateOk()) {
|
|
478
|
+
this._reviewGateFallback(agent.session, agent);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this._enableCore(agent.session, agent, "review");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// v1.8.0 global default (setReviewDefault): a FRESH session (no
|
|
485
|
+
// genuine user message yet) auto-enters per-call review when
|
|
486
|
+
// configured and the Jev gate is open. Resumed sessions keep their
|
|
487
|
+
// folded selection — flipping them would override past choices.
|
|
488
|
+
if (
|
|
489
|
+
this._reviewDefault &&
|
|
490
|
+
(preset === undefined || preset === PRESET_NAME) &&
|
|
491
|
+
this._isFreshSession(agent.session) &&
|
|
492
|
+
this._jevGateOk()
|
|
493
|
+
) {
|
|
494
|
+
this._enableCore(agent.session, agent, "review");
|
|
495
|
+
if (this._presetRegistered(REVIEW_PRESET_NAME)) {
|
|
496
|
+
agent.session.append("permission/preset", { preset: REVIEW_PRESET_NAME });
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (preset === PRESET_NAME) {
|
|
501
|
+
this._enableCore(agent.session, agent, "escalation");
|
|
502
|
+
}
|
|
372
503
|
} catch (e) {
|
|
373
504
|
/* best-effort re-arm */
|
|
374
505
|
}
|
|
@@ -394,7 +525,14 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
394
525
|
order: 116,
|
|
395
526
|
text: (context) => {
|
|
396
527
|
const agent = context.agent;
|
|
397
|
-
if (agent === undefined
|
|
528
|
+
if (agent === undefined) return "";
|
|
529
|
+
const entry = this._enabled.get(agent.session.id);
|
|
530
|
+
if (entry === undefined) return "";
|
|
531
|
+
if (entry.mode === "review") {
|
|
532
|
+
return (
|
|
533
|
+
"Per-call review mode (自动审查) is ON for this session: the sandbox base is danger-full-access, and EVERY tool call is reviewed by the Jev judge before execution. The judge sees the exact tool call and the user's actual request; risky, destructive, out-of-scope, or dishonest calls are rejected outright and their body never runs — a rejection is FINAL (no human fallback). State the exact target of each operation and its link to the task."
|
|
534
|
+
);
|
|
535
|
+
}
|
|
398
536
|
const route = " routed to " + this._judgeRoute().label;
|
|
399
537
|
return (
|
|
400
538
|
"Agent-approval mode is ON for this session: the sandbox base is workspace-write, and every sandbox-escalation request is decided by an independent approval agent" +
|
|
@@ -426,6 +564,27 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
426
564
|
return { kind: "success", text: this._setEnabled(invocation.agent, arg === "on") };
|
|
427
565
|
},
|
|
428
566
|
});
|
|
567
|
+
scope.commands.register({
|
|
568
|
+
name: "agent-review",
|
|
569
|
+
description:
|
|
570
|
+
"Toggle per-call review (自动审查): Full access base; every tool call is reviewed by the Jev judge before execution; risky calls are rejected with no human fallback",
|
|
571
|
+
input: { hint: "<on|off>" },
|
|
572
|
+
handler: (invocation) => {
|
|
573
|
+
const arg = invocation.rawInput.trim().toLowerCase();
|
|
574
|
+
if (arg === "") {
|
|
575
|
+
const entry = this._enabled.get(invocation.agent.session.id);
|
|
576
|
+
const on = entry !== undefined && entry.mode === "review";
|
|
577
|
+
return {
|
|
578
|
+
kind: "success",
|
|
579
|
+
text: "agent-review is " + (on ? "ON" : "OFF") + " for this session (usage: /agent-review on|off)",
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
if (arg !== "on" && arg !== "off") {
|
|
583
|
+
return { kind: "error", text: "usage: /agent-review on|off" };
|
|
584
|
+
}
|
|
585
|
+
return { kind: "success", text: this._setReviewEnabled(invocation.agent, arg === "on") };
|
|
586
|
+
},
|
|
587
|
+
});
|
|
429
588
|
});
|
|
430
589
|
|
|
431
590
|
// Hydrate persisted settings + audit records (never throws).
|
|
@@ -470,17 +629,103 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
470
629
|
return on ? this._enable(agent, true) : this._disable(agent, true);
|
|
471
630
|
}
|
|
472
631
|
|
|
632
|
+
/**
|
|
633
|
+
* v1.8.0: toggle the per-call review mode (自动审查) for one live session —
|
|
634
|
+
* the /agent-review command lands here. Enabling runs the Jev gate (layer
|
|
635
|
+
* 2 of the three-layer gate) and pins the agent-review bundle
|
|
636
|
+
* (danger-full-access + ask); disabling restores the remembered knobs
|
|
637
|
+
* through the same `_disable` core as the escalation mode.
|
|
638
|
+
*/
|
|
639
|
+
_setReviewEnabled(agent, on) {
|
|
640
|
+
const session = agent.session;
|
|
641
|
+
const entry = this._enabled.get(session.id);
|
|
642
|
+
if (on) {
|
|
643
|
+
if (entry !== undefined && entry.mode === "review") return "自动审查 is already ON for this session";
|
|
644
|
+
if (entry !== undefined) {
|
|
645
|
+
return "自动审批 is already ON for this session — switch modes through the /permission menu";
|
|
646
|
+
}
|
|
647
|
+
if (!this._jevGateOk()) {
|
|
648
|
+
return "自动审查 requires the Jev judge: set the 审批模型 Provider to TypeSafe Jev with an API key in Settings → 自动审批 first";
|
|
649
|
+
}
|
|
650
|
+
this._enableCore(session, agent, "review");
|
|
651
|
+
if (this._presetRegistered(REVIEW_PRESET_NAME)) {
|
|
652
|
+
// Same shared-bundle rule as the escalation mode: the appended
|
|
653
|
+
// selection is what makes the menu display 自动审查.
|
|
654
|
+
session.append("permission/preset", { preset: REVIEW_PRESET_NAME });
|
|
655
|
+
}
|
|
656
|
+
return "自动审查 ON: sandbox base is danger-full-access; every tool call is reviewed by the Jev judge before execution (denials are final, no human fallback)";
|
|
657
|
+
}
|
|
658
|
+
if (entry === undefined || entry.mode !== "review") return "自动审查 is not ON for this session";
|
|
659
|
+
return this._disable(agent, true);
|
|
660
|
+
}
|
|
661
|
+
|
|
473
662
|
/**
|
|
474
663
|
* Whether the preset table currently knows our entry. The package's
|
|
475
664
|
* `cordis.patch.yml` `permission` row override registers it; without it we
|
|
476
665
|
* must NOT append `permission/preset` events — the session invariant rejects
|
|
477
666
|
* unknown preset names, and the menu simply will not show the mode.
|
|
478
667
|
*/
|
|
479
|
-
_presetRegistered() {
|
|
668
|
+
_presetRegistered(name) {
|
|
480
669
|
const presets = this.ctx.get("permissionPresets");
|
|
481
670
|
if (presets === undefined) return false;
|
|
482
671
|
try {
|
|
483
|
-
return presets.names.includes(PRESET_NAME);
|
|
672
|
+
return presets.names.includes(name === undefined ? PRESET_NAME : name);
|
|
673
|
+
} catch (e) {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* v1.8.0 gate for the per-call review mode: the user must have switched the
|
|
680
|
+
* judge to TypeSafe Jev with a resolvable API key ("设置了使用 Jev").
|
|
681
|
+
* Everything about the mode is built around the Jev judge — enabling it
|
|
682
|
+
* without one would leave Full access with nobody judging.
|
|
683
|
+
*/
|
|
684
|
+
_jevGateOk() {
|
|
685
|
+
return this._model.provider === JEV_PROVIDER && this._jevEffective().key !== "";
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Gate layer 3 (fail-closed): a session whose durable log selects the
|
|
690
|
+
* agent-review preset while the Jev gate is closed must NOT sit on Full
|
|
691
|
+
* access with nobody judging. Record why, then bounce the session to the
|
|
692
|
+
* agent-approval preset (workspace-write + ask) through the canonical
|
|
693
|
+
* preset writer — which re-enters our own preset listener and arms the
|
|
694
|
+
* escalation mode. Best-effort: even if the bounce fails, no review mode
|
|
695
|
+
* is armed and the gate still holds.
|
|
696
|
+
*/
|
|
697
|
+
_reviewGateFallback(session, agent) {
|
|
698
|
+
try {
|
|
699
|
+
this._record(session, {
|
|
700
|
+
at: new Date().toISOString(),
|
|
701
|
+
toolName: "(mode)",
|
|
702
|
+
reason: "(agent-review enable)",
|
|
703
|
+
args: "",
|
|
704
|
+
outcome: "unavailable",
|
|
705
|
+
riskLevel: "-",
|
|
706
|
+
model: "gate",
|
|
707
|
+
durationMs: 0,
|
|
708
|
+
childSessionId: "",
|
|
709
|
+
rationale:
|
|
710
|
+
"自动审查 requires the Jev judge (Settings → 自动审批: Provider = TypeSafe Jev with an API key); falling back to the 自动审批 preset (fail closed)",
|
|
711
|
+
mode: "review",
|
|
712
|
+
});
|
|
713
|
+
const presets = this.ctx.get("permissionPresets");
|
|
714
|
+
if (presets !== undefined) presets.set(session, PRESET_NAME);
|
|
715
|
+
} catch (e) {
|
|
716
|
+
/* best-effort bounce; the gate holds either way */
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Whether a session has not yet seen a genuine user message — i.e. it is
|
|
722
|
+
* being created rather than resumed. Guards the review default: a resumed
|
|
723
|
+
* session carries its past work, so its folded preset selection wins.
|
|
724
|
+
* Unknown shapes read as resumed (never hijack a session we cannot read).
|
|
725
|
+
*/
|
|
726
|
+
_isFreshSession(session) {
|
|
727
|
+
try {
|
|
728
|
+
return this._recentUserContext(session).first === "";
|
|
484
729
|
} catch (e) {
|
|
485
730
|
return false;
|
|
486
731
|
}
|
|
@@ -493,7 +738,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
493
738
|
if (presets === undefined) return undefined;
|
|
494
739
|
try {
|
|
495
740
|
for (const name of presets.names) {
|
|
496
|
-
if (name === PRESET_NAME) continue;
|
|
741
|
+
if (name === PRESET_NAME || name === REVIEW_PRESET_NAME) continue;
|
|
497
742
|
const spec = presets.resolve(name);
|
|
498
743
|
if (spec.sandbox === sandbox && spec.approval === approval) return name;
|
|
499
744
|
}
|
|
@@ -507,7 +752,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
507
752
|
* Enable the judging mode and (optionally) record the preset selection so
|
|
508
753
|
* the permission menu reflects the mode. Shared-bundle rule: the LAST
|
|
509
754
|
* `permission/preset` event wins the derive tie against workspace-write, so
|
|
510
|
-
* the append is what makes the menu display "
|
|
755
|
+
* the append is what makes the menu display "自动审批".
|
|
511
756
|
*/
|
|
512
757
|
_enable(agent, appendPreset) {
|
|
513
758
|
const session = agent.session;
|
|
@@ -525,12 +770,16 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
525
770
|
* The pure bookkeeping half of enabling: capture the session's EFFECTIVE
|
|
526
771
|
* knob values (override ?? defaults — a session living under a `never`
|
|
527
772
|
* composition default must return to `never`, not to the fold's "no
|
|
528
|
-
* override" state) and the last recorded preset selection, then pin
|
|
529
|
-
*
|
|
773
|
+
* override" state) and the last recorded preset selection, then pin the
|
|
774
|
+
* mode's sandbox base and approval policy to `ask` (the waterfall — and
|
|
530
775
|
* therefore our claimer — only runs under `ask`; under `never` the approval
|
|
531
|
-
* service short-circuits to `rejected` before any listener).
|
|
776
|
+
* service short-circuits to `rejected` before any listener). v1.8.0:
|
|
777
|
+
* `mode` selects the pinned sandbox — "review" pins Full access (the
|
|
778
|
+
* agent-review preset bundle), anything else pins workspace-write.
|
|
532
779
|
*/
|
|
533
|
-
_enableCore(session, agent) {
|
|
780
|
+
_enableCore(session, agent, mode) {
|
|
781
|
+
const isReview = mode === "review";
|
|
782
|
+
const baseMode = isReview ? REVIEW_BASE_MODE : BASE_MODE;
|
|
534
783
|
const approval = this.ctx.approval;
|
|
535
784
|
const effectiveSandbox =
|
|
536
785
|
this._lastKnob(session, "sandbox/mode", "mode") ??
|
|
@@ -541,8 +790,9 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
541
790
|
prevSandbox: effectiveSandbox,
|
|
542
791
|
prevApproval: effectiveApproval,
|
|
543
792
|
prevPreset: this._lastKnob(session, "permission/preset", "preset"),
|
|
793
|
+
mode: isReview ? "review" : "escalation",
|
|
544
794
|
});
|
|
545
|
-
if (effectiveSandbox !==
|
|
795
|
+
if (effectiveSandbox !== baseMode) session.append("sandbox/mode", { mode: baseMode });
|
|
546
796
|
approval.setPolicy(agent, "ask");
|
|
547
797
|
}
|
|
548
798
|
|
|
@@ -550,17 +800,18 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
550
800
|
* Disable the judging mode. With `restoreKnobs` (the chip/command path) the
|
|
551
801
|
* remembered values go back through the canonical setters and the menu's
|
|
552
802
|
* preset selection is corrected for the restored bundle — the shared-bundle
|
|
553
|
-
* tie rule would otherwise keep displaying "
|
|
803
|
+
* tie rule would otherwise keep displaying "自动审批". Without it (the
|
|
554
804
|
* user switched to another preset in the menu) we touch nothing: the preset
|
|
555
805
|
* service writes its own knob events right after the selection event.
|
|
556
806
|
*/
|
|
557
807
|
_disable(agent, restoreKnobs) {
|
|
558
808
|
const session = agent.session;
|
|
559
809
|
const prev = this._enabled.get(session.id);
|
|
560
|
-
if (prev === undefined) return "
|
|
810
|
+
if (prev === undefined) return "the mode is not ON for this session";
|
|
811
|
+
const label = prev.mode === "review" ? "agent-review" : "agent-approval";
|
|
561
812
|
this._enabled.delete(session.id);
|
|
562
813
|
this._trusted.delete(session.id);
|
|
563
|
-
if (!restoreKnobs) return "
|
|
814
|
+
if (!restoreKnobs) return label + " OFF: previous permission knobs restored";
|
|
564
815
|
if (
|
|
565
816
|
typeof prev.prevSandbox === "string" &&
|
|
566
817
|
prev.prevSandbox !== this._lastKnob(session, "sandbox/mode", "mode")
|
|
@@ -579,6 +830,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
579
830
|
if (
|
|
580
831
|
typeof prev.prevPreset === "string" &&
|
|
581
832
|
prev.prevPreset !== PRESET_NAME &&
|
|
833
|
+
prev.prevPreset !== REVIEW_PRESET_NAME &&
|
|
582
834
|
this._presetMatches(prev.prevPreset, prev.prevSandbox, prev.prevApproval)
|
|
583
835
|
) {
|
|
584
836
|
name = prev.prevPreset;
|
|
@@ -590,7 +842,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
590
842
|
}
|
|
591
843
|
if (name !== undefined) session.append("permission/preset", { preset: name });
|
|
592
844
|
}
|
|
593
|
-
return "
|
|
845
|
+
return label + " OFF: previous permission knobs restored";
|
|
594
846
|
}
|
|
595
847
|
|
|
596
848
|
/** Whether one named table entry's bundle equals the given knob values. */
|
|
@@ -686,25 +938,38 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
686
938
|
durationMs: Number(entry.durationMs) || 0,
|
|
687
939
|
childSessionId: String(entry.childSessionId),
|
|
688
940
|
rationale: String(entry.rationale),
|
|
941
|
+
// v1.8.0: escalation = sandbox-escalation review (approval/request),
|
|
942
|
+
// review = per-call review (tools/pre-execute). Old sidecar lines have
|
|
943
|
+
// no such field and fold to "escalation".
|
|
944
|
+
mode: entry.mode === "review" ? "review" : "escalation",
|
|
689
945
|
};
|
|
690
946
|
}
|
|
691
947
|
|
|
692
948
|
/**
|
|
693
949
|
* Resolve the audit sidecar for one session: `agent-approval.jsonl` inside
|
|
694
950
|
* the session's persistence directory (same directory as the session's own
|
|
695
|
-
* durable log
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
951
|
+
* durable log — a pure path resolution that also works for live sessions).
|
|
952
|
+
* DSH ≤0.1.7 exposes it as `sessionPersistence.locate(header)`; DSH 0.2.0
|
|
953
|
+
* dropped `locate` and moved resolution to the JSONL backend's async
|
|
954
|
+
* `resolveCurrentLog(id)` (returns the log path, or undefined while only a
|
|
955
|
+
* historical generation exists). Falls back to a plugin-owned per-session
|
|
956
|
+
* file under DSH_HOME when the seam or the location is unavailable; the
|
|
957
|
+
* fallback keeps restart-safety at the cost of not being cleaned up when the
|
|
958
|
+
* session is deleted.
|
|
700
959
|
*/
|
|
701
960
|
async _recordsFileOf(session) {
|
|
702
961
|
const persistence = this.ctx.get("sessionPersistence");
|
|
703
|
-
if (persistence !== undefined
|
|
962
|
+
if (persistence !== undefined) {
|
|
704
963
|
try {
|
|
705
|
-
|
|
706
|
-
if (
|
|
707
|
-
|
|
964
|
+
let path;
|
|
965
|
+
if (typeof persistence.locate === "function") {
|
|
966
|
+
const loc = persistence.locate(session.header);
|
|
967
|
+
if (loc && typeof loc.path === "string") path = loc.path;
|
|
968
|
+
} else if (typeof persistence.resolveCurrentLog === "function") {
|
|
969
|
+
path = await persistence.resolveCurrentLog(session.id);
|
|
970
|
+
}
|
|
971
|
+
if (typeof path === "string" && path !== "") {
|
|
972
|
+
return join(dirname(path), RECORDS_SIDECAR);
|
|
708
973
|
}
|
|
709
974
|
} catch (e) {
|
|
710
975
|
/* fall through to the plugin-owned fallback */
|
|
@@ -765,6 +1030,8 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
765
1030
|
_persistConfig() {
|
|
766
1031
|
const body = JSON.stringify({
|
|
767
1032
|
model: { provider: this._model.provider, model: this._model.model },
|
|
1033
|
+
judgeMode: this._judgeMode,
|
|
1034
|
+
reviewDefault: this._reviewDefault,
|
|
768
1035
|
jev: this._jevShape(),
|
|
769
1036
|
timeoutMs: this._timeoutMs,
|
|
770
1037
|
rules: this._rules,
|
|
@@ -792,6 +1059,12 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
792
1059
|
) {
|
|
793
1060
|
this._model = { provider: cfg.model.provider, model: cfg.model.model };
|
|
794
1061
|
}
|
|
1062
|
+
if (cfg.judgeMode === JUDGE_MODE_LLM || cfg.judgeMode === JUDGE_MODE_SUBAGENT) {
|
|
1063
|
+
this._judgeMode = cfg.judgeMode;
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof cfg.reviewDefault === "boolean") {
|
|
1066
|
+
this._reviewDefault = cfg.reviewDefault;
|
|
1067
|
+
}
|
|
795
1068
|
if (cfg.jev && typeof cfg.jev === "object") {
|
|
796
1069
|
if (typeof cfg.jev.apiKey === "string") this._jev.apiKey = cfg.jev.apiKey;
|
|
797
1070
|
if (typeof cfg.jev.endpoint === "string" && cfg.jev.endpoint !== "") {
|
|
@@ -885,7 +1158,15 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
885
1158
|
};
|
|
886
1159
|
}
|
|
887
1160
|
|
|
888
|
-
|
|
1161
|
+
/**
|
|
1162
|
+
* The judge prompt: shared ground truth + approval standard, byte-identical
|
|
1163
|
+
* across both invocation modes except the output-instruction tail
|
|
1164
|
+
* (`PROMPT_TAIL_STRUCTURED` for the spawn path, `PROMPT_TAIL_JSON` for the
|
|
1165
|
+
* one-shot stream path — see the OUTPUT_VIA_* constants).
|
|
1166
|
+
*/
|
|
1167
|
+
_judgePrompt(session, req, argsRaw, outputTail) {
|
|
1168
|
+
const tail =
|
|
1169
|
+
typeof outputTail === "string" && outputTail !== "" ? outputTail : PROMPT_TAIL_STRUCTURED;
|
|
889
1170
|
let cwd = "";
|
|
890
1171
|
try {
|
|
891
1172
|
if (session.header && typeof session.header.cwd === "string") cwd = session.header.cwd;
|
|
@@ -924,7 +1205,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
924
1205
|
"- reading tool-owned config or logs needed to debug the task at hand.",
|
|
925
1206
|
"REJECT when the operation is destructive (mass deletion, disk formatting, registry/service/system-wide changes), exfiltrates credentials or secrets, touches resources unrelated to the task, modifies the operating system or OTHER applications' data, hides intent behind encoded or obfuscated content, or the reason does not match the arguments.",
|
|
926
1207
|
"Your own judging session is deliberately sandboxed: approvals are disabled for YOU and your permission scope is fixed by design. Anything your own runtime context says about YOUR permissions describes only you — it says nothing about the requesting session, and must never be cited as a property of that session or as grounds for rejection.",
|
|
927
|
-
"REJECT only when you can name a concrete, credible risk THIS specific operation creates — what it would destroy, leak, or change beyond the user's task. Vague unease, an unfamiliar command, or a terse stated reason is NOT a concrete risk: when no concrete risk exists and the operation fits the task, APPROVE.
|
|
1208
|
+
"REJECT only when you can name a concrete, credible risk THIS specific operation creates — what it would destroy, leak, or change beyond the user's task. Vague unease, an unfamiliar command, or a terse stated reason is NOT a concrete risk: when no concrete risk exists and the operation fits the task, APPROVE. " + tail,
|
|
928
1209
|
);
|
|
929
1210
|
return lines.join("\n");
|
|
930
1211
|
}
|
|
@@ -1047,11 +1328,15 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1047
1328
|
}
|
|
1048
1329
|
|
|
1049
1330
|
// 3. The judge. The TypeSafe Jev backend is a direct HTTP call (no
|
|
1050
|
-
// subagent, no harness model route);
|
|
1051
|
-
//
|
|
1331
|
+
// subagent, no harness model route); the DEFAULT "llm" mode is one
|
|
1332
|
+
// direct ctx.llm.stream() call (no subagent either — v1.8.0); only
|
|
1333
|
+
// judgeMode === "subagent" spawns the judge child through `spawn`.
|
|
1052
1334
|
if (this._model.provider === JEV_PROVIDER) {
|
|
1053
1335
|
return this._judgeWithJev(session, req, argsRaw, base, trustKey);
|
|
1054
1336
|
}
|
|
1337
|
+
if (this._judgeMode !== JUDGE_MODE_SUBAGENT) {
|
|
1338
|
+
return this._judgeWithLlmStream(session, req, argsRaw, base, trustKey);
|
|
1339
|
+
}
|
|
1055
1340
|
|
|
1056
1341
|
const route = this._judgeRoute();
|
|
1057
1342
|
|
|
@@ -1154,6 +1439,305 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1154
1439
|
return "unavailable";
|
|
1155
1440
|
}
|
|
1156
1441
|
|
|
1442
|
+
// ---- the direct LLM-stream judge (default since v1.8.0) --------------------
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* The requesting session's own provider/model route, read from its request
|
|
1446
|
+
* header — the concrete route a one-shot stream call needs when the judge
|
|
1447
|
+
* route resolves "inherit(requester)" (no configured override and no
|
|
1448
|
+
* harness default selection). Undefined when no complete route is readable.
|
|
1449
|
+
*/
|
|
1450
|
+
_requesterRoute(session) {
|
|
1451
|
+
try {
|
|
1452
|
+
const header = typeof session.requestHeader === "function" ? session.requestHeader() : undefined;
|
|
1453
|
+
const cfg = header && header.config;
|
|
1454
|
+
if (
|
|
1455
|
+
cfg &&
|
|
1456
|
+
typeof cfg.provider === "string" &&
|
|
1457
|
+
cfg.provider !== "" &&
|
|
1458
|
+
typeof cfg.model === "string" &&
|
|
1459
|
+
cfg.model !== ""
|
|
1460
|
+
) {
|
|
1461
|
+
return { provider: cfg.provider, model: cfg.model };
|
|
1462
|
+
}
|
|
1463
|
+
} catch (e) {
|
|
1464
|
+
/* header access is best-effort */
|
|
1465
|
+
}
|
|
1466
|
+
return undefined;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Judge one escalation through ONE direct Harness LLM stream call (the
|
|
1471
|
+
* default judge mode since v1.8.0). Input/output mirror the subagent path
|
|
1472
|
+
* exactly — same persona, same `_judgePrompt`, same VERDICT_SCHEMA verdict
|
|
1473
|
+
* contract — only the invocation differs: no subagent session is created
|
|
1474
|
+
* (zero judge-side context pollution; `childSessionId` stays empty).
|
|
1475
|
+
* Mirrors `_judgeWithJev`'s fail-closed contract:
|
|
1476
|
+
* - no concrete route / llm fault / non-'stop' finish / malformed verdict
|
|
1477
|
+
* / timeout → `unavailable`
|
|
1478
|
+
* - request cancelled mid-flight → `cancelled`
|
|
1479
|
+
*/
|
|
1480
|
+
async _judgeWithLlmStream(session, req, argsRaw, base, trustKey) {
|
|
1481
|
+
const route = this._judgeRoute();
|
|
1482
|
+
let provider = route.provider;
|
|
1483
|
+
let model = route.model;
|
|
1484
|
+
let label = route.label;
|
|
1485
|
+
if (provider === "" || model === "") {
|
|
1486
|
+
const own = this._requesterRoute(session);
|
|
1487
|
+
if (own === undefined) {
|
|
1488
|
+
this._record(session, {
|
|
1489
|
+
...base,
|
|
1490
|
+
outcome: "unavailable",
|
|
1491
|
+
riskLevel: "-",
|
|
1492
|
+
model: label,
|
|
1493
|
+
rationale:
|
|
1494
|
+
"no concrete model route for the direct judge (no override, no harness default, no readable requester route)",
|
|
1495
|
+
});
|
|
1496
|
+
return "unavailable";
|
|
1497
|
+
}
|
|
1498
|
+
provider = own.provider;
|
|
1499
|
+
model = own.model;
|
|
1500
|
+
label = "inherit(" + provider + "/" + model + ")";
|
|
1501
|
+
}
|
|
1502
|
+
// ctx.llm is a runtime precondition (the agent loop itself cannot run
|
|
1503
|
+
// without it) — no absence fallback by design (user ruling 2026-10); a
|
|
1504
|
+
// somehow-missing service just resolves fail-closed with an honest line.
|
|
1505
|
+
const llm = this.ctx.get("llm");
|
|
1506
|
+
if (llm === undefined || typeof llm.stream !== "function") {
|
|
1507
|
+
this._record(session, {
|
|
1508
|
+
...base,
|
|
1509
|
+
outcome: "unavailable",
|
|
1510
|
+
riskLevel: "-",
|
|
1511
|
+
model: label,
|
|
1512
|
+
rationale: "the harness llm service is not composed; the direct judge cannot run (fail closed)",
|
|
1513
|
+
});
|
|
1514
|
+
return "unavailable";
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const startedAt = Date.now();
|
|
1518
|
+
const controller = new AbortController();
|
|
1519
|
+
const signal = req.signal;
|
|
1520
|
+
const onAbort = () => controller.abort();
|
|
1521
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
1522
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
let winner;
|
|
1526
|
+
try {
|
|
1527
|
+
const options = {
|
|
1528
|
+
provider: provider,
|
|
1529
|
+
model: model,
|
|
1530
|
+
system: approverPersona(OUTPUT_VIA_JSON),
|
|
1531
|
+
messages: [
|
|
1532
|
+
{
|
|
1533
|
+
role: "user",
|
|
1534
|
+
content: [{ type: "text", text: this._judgePrompt(session, req, argsRaw, PROMPT_TAIL_JSON) }],
|
|
1535
|
+
},
|
|
1536
|
+
],
|
|
1537
|
+
temperature: 0,
|
|
1538
|
+
signal: controller.signal,
|
|
1539
|
+
};
|
|
1540
|
+
winner = await Promise.race([
|
|
1541
|
+
this._readLlmVerdict(llm.stream(options))
|
|
1542
|
+
.then((verdict) => ({ kind: "result", verdict: verdict }))
|
|
1543
|
+
.catch((error) => ({
|
|
1544
|
+
kind: "fault",
|
|
1545
|
+
error: error,
|
|
1546
|
+
aborted: !!(error && error.name === "AbortError"),
|
|
1547
|
+
})),
|
|
1548
|
+
(signal
|
|
1549
|
+
? new Promise((resolve) => {
|
|
1550
|
+
if (signal.aborted) {
|
|
1551
|
+
resolve(true);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
signal.addEventListener("abort", () => resolve(true), { once: true });
|
|
1555
|
+
})
|
|
1556
|
+
: Promise.resolve(false)
|
|
1557
|
+
).then((v) => ({ kind: "aborted", aborted: v })),
|
|
1558
|
+
this.ctx.timeout(this._timeoutMs).then(() => ({ kind: "timeout" })),
|
|
1559
|
+
]);
|
|
1560
|
+
} finally {
|
|
1561
|
+
if (signal && typeof signal.removeEventListener === "function") {
|
|
1562
|
+
signal.removeEventListener("abort", onAbort);
|
|
1563
|
+
}
|
|
1564
|
+
// Whether the race was lost to timeout/cancel or the call already
|
|
1565
|
+
// settled, closing the stream is always safe.
|
|
1566
|
+
try {
|
|
1567
|
+
controller.abort();
|
|
1568
|
+
} catch (e) {
|
|
1569
|
+
/* controller abort never blocks the outcome */
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
base.durationMs = Date.now() - startedAt;
|
|
1573
|
+
|
|
1574
|
+
if (winner.kind === "result") {
|
|
1575
|
+
const verdict = winner.verdict;
|
|
1576
|
+
const approved = verdict.decision === "approve";
|
|
1577
|
+
this._record(session, {
|
|
1578
|
+
...base,
|
|
1579
|
+
outcome: approved ? "allowed-once" : "rejected",
|
|
1580
|
+
riskLevel: verdict.riskLevel,
|
|
1581
|
+
model: label,
|
|
1582
|
+
rationale: trunc(verdict.rationale, 600),
|
|
1583
|
+
});
|
|
1584
|
+
// Trust one approved fingerprint for the rest of the session: the next
|
|
1585
|
+
// byte-identical call short-circuits before any judge runs.
|
|
1586
|
+
if (approved && trustKey !== undefined) {
|
|
1587
|
+
let set = this._trusted.get(session.id);
|
|
1588
|
+
if (set === undefined) {
|
|
1589
|
+
set = new Set();
|
|
1590
|
+
this._trusted.set(session.id, set);
|
|
1591
|
+
}
|
|
1592
|
+
set.add(trustKey);
|
|
1593
|
+
}
|
|
1594
|
+
return approved ? "allowed-once" : "rejected";
|
|
1595
|
+
}
|
|
1596
|
+
if (winner.kind === "aborted" || (winner.kind === "fault" && winner.aborted)) {
|
|
1597
|
+
this._record(session, {
|
|
1598
|
+
...base,
|
|
1599
|
+
outcome: "cancelled",
|
|
1600
|
+
riskLevel: "-",
|
|
1601
|
+
model: label,
|
|
1602
|
+
rationale: "request cancelled while the direct judge was judging",
|
|
1603
|
+
});
|
|
1604
|
+
return "cancelled";
|
|
1605
|
+
}
|
|
1606
|
+
if (winner.kind === "timeout") {
|
|
1607
|
+
this._record(session, {
|
|
1608
|
+
...base,
|
|
1609
|
+
outcome: "unavailable",
|
|
1610
|
+
riskLevel: "-",
|
|
1611
|
+
model: label,
|
|
1612
|
+
rationale: "direct judge call timed out after " + String(this._timeoutMs) + "ms (fail closed)",
|
|
1613
|
+
});
|
|
1614
|
+
return "unavailable";
|
|
1615
|
+
}
|
|
1616
|
+
this._record(session, {
|
|
1617
|
+
...base,
|
|
1618
|
+
outcome: "unavailable",
|
|
1619
|
+
riskLevel: "-",
|
|
1620
|
+
model: label,
|
|
1621
|
+
rationale: "direct judge call failed (fail closed): " + errText(winner.error),
|
|
1622
|
+
});
|
|
1623
|
+
return "unavailable";
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* Aggregate one `ctx.llm.stream()` response into a validated verdict.
|
|
1628
|
+
* Chunk protocol (dsh-llm `StreamChunk`): `block-start` / `text-delta` /
|
|
1629
|
+
* `reasoning-delta` / `tool-call-delta` / `block-end` / `usage` / `finish`.
|
|
1630
|
+
* Per the verdict contract the response must be zero or more reasoning
|
|
1631
|
+
* blocks followed by exactly ONE text block holding the JSON verdict, with
|
|
1632
|
+
* a terminal `stop` finish. `block-end` carries the authoritative assembled
|
|
1633
|
+
* block, so it replaces any deltas already counted for that index (no
|
|
1634
|
+
* double counting). Every abnormal shape throws — upstream maps it to
|
|
1635
|
+
* `unavailable` (fail closed); an `aborted` finish throws AbortError so the
|
|
1636
|
+
* race maps it to `cancelled`.
|
|
1637
|
+
*/
|
|
1638
|
+
async _readLlmVerdict(stream) {
|
|
1639
|
+
const blocks = new Map(); // index -> { type, text }
|
|
1640
|
+
let finish;
|
|
1641
|
+
const entryOf = (index) => {
|
|
1642
|
+
let entry = blocks.get(index);
|
|
1643
|
+
if (entry === undefined) {
|
|
1644
|
+
entry = { type: undefined, text: "" };
|
|
1645
|
+
blocks.set(index, entry);
|
|
1646
|
+
}
|
|
1647
|
+
return entry;
|
|
1648
|
+
};
|
|
1649
|
+
for await (const chunk of stream) {
|
|
1650
|
+
if (finish !== undefined) throw new Error("direct judge emitted data after its terminal finish");
|
|
1651
|
+
if (!chunk || typeof chunk !== "object") continue; // merge-extensible protocol
|
|
1652
|
+
if (chunk.type === "block-start") {
|
|
1653
|
+
entryOf(chunk.index).type = String(chunk.blockType);
|
|
1654
|
+
} else if (chunk.type === "text-delta") {
|
|
1655
|
+
const entry = entryOf(chunk.index);
|
|
1656
|
+
if (entry.type === undefined) entry.type = "text";
|
|
1657
|
+
entry.text += String(chunk.text === undefined ? "" : chunk.text);
|
|
1658
|
+
} else if (chunk.type === "reasoning-delta") {
|
|
1659
|
+
const entry = entryOf(chunk.index);
|
|
1660
|
+
if (entry.type === undefined) entry.type = "reasoning";
|
|
1661
|
+
} else if (chunk.type === "tool-call-delta") {
|
|
1662
|
+
entryOf(chunk.index).type = "tool-call";
|
|
1663
|
+
} else if (chunk.type === "block-end") {
|
|
1664
|
+
const block = chunk.block;
|
|
1665
|
+
blocks.set(chunk.index, {
|
|
1666
|
+
type: block && typeof block.type === "string" ? block.type : "text",
|
|
1667
|
+
text: block && typeof block.text === "string" ? block.text : "",
|
|
1668
|
+
});
|
|
1669
|
+
} else if (chunk.type === "finish") {
|
|
1670
|
+
finish = chunk.reason;
|
|
1671
|
+
}
|
|
1672
|
+
// `usage` and unknown chunk types carry no verdict content — ignored.
|
|
1673
|
+
}
|
|
1674
|
+
if (finish === undefined) throw new Error("direct judge stream ended without a terminal finish");
|
|
1675
|
+
if (finish.kind === "aborted") {
|
|
1676
|
+
const e = new Error("direct judge stream aborted");
|
|
1677
|
+
e.name = "AbortError";
|
|
1678
|
+
throw e;
|
|
1679
|
+
}
|
|
1680
|
+
if (finish.kind !== "stop") throw new Error("direct judge finished with " + String(finish.kind));
|
|
1681
|
+
const ordered = [];
|
|
1682
|
+
for (const entry of blocks.values()) {
|
|
1683
|
+
if (entry.type === undefined) continue;
|
|
1684
|
+
if ((entry.type === "text" || entry.type === "reasoning") && entry.text.trim() === "") continue;
|
|
1685
|
+
ordered.push(entry);
|
|
1686
|
+
}
|
|
1687
|
+
if (ordered.length === 0) throw new Error("direct judge emitted no content blocks");
|
|
1688
|
+
const final = ordered[ordered.length - 1];
|
|
1689
|
+
if (final.type !== "text") throw new Error("direct judge must end with exactly one text block");
|
|
1690
|
+
for (let i = 0; i < ordered.length - 1; i++) {
|
|
1691
|
+
if (ordered[i].type !== "reasoning") {
|
|
1692
|
+
throw new Error("direct judge must emit zero or more reasoning blocks followed by exactly one text block");
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
return this._verdictFromJsonText(final.text);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Parse one verdict JSON text against the VERDICT_SCHEMA contract — the
|
|
1700
|
+
* same check `result.structured` enforces on the subagent path (three
|
|
1701
|
+
* required members, `additionalProperties: false`, enum fields). Anything
|
|
1702
|
+
* else throws; upstream maps that to `unavailable`.
|
|
1703
|
+
*/
|
|
1704
|
+
_verdictFromJsonText(text) {
|
|
1705
|
+
let raw = String(text).trim();
|
|
1706
|
+
const fence = raw.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
1707
|
+
if (fence) raw = fence[1].trim();
|
|
1708
|
+
let value;
|
|
1709
|
+
try {
|
|
1710
|
+
value = JSON.parse(raw);
|
|
1711
|
+
} catch (e) {
|
|
1712
|
+
const open = raw.indexOf("{");
|
|
1713
|
+
const close = raw.lastIndexOf("}");
|
|
1714
|
+
if (open < 0 || close <= open) throw new Error("direct judge verdict text is not JSON");
|
|
1715
|
+
value = JSON.parse(raw.slice(open, close + 1));
|
|
1716
|
+
}
|
|
1717
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1718
|
+
throw new Error("direct judge verdict must be one JSON object");
|
|
1719
|
+
}
|
|
1720
|
+
const keys = Object.keys(value);
|
|
1721
|
+
if (
|
|
1722
|
+
keys.length !== 3 ||
|
|
1723
|
+
value.decision === undefined ||
|
|
1724
|
+
value.riskLevel === undefined ||
|
|
1725
|
+
value.rationale === undefined
|
|
1726
|
+
) {
|
|
1727
|
+
throw new Error("direct judge verdict must have exactly decision/riskLevel/rationale");
|
|
1728
|
+
}
|
|
1729
|
+
if (value.decision !== "approve" && value.decision !== "reject") {
|
|
1730
|
+
throw new Error("direct judge verdict decision must be approve|reject");
|
|
1731
|
+
}
|
|
1732
|
+
if (value.riskLevel !== "low" && value.riskLevel !== "medium" && value.riskLevel !== "high") {
|
|
1733
|
+
throw new Error("direct judge verdict riskLevel must be low|medium|high");
|
|
1734
|
+
}
|
|
1735
|
+
if (typeof value.rationale !== "string") {
|
|
1736
|
+
throw new Error("direct judge verdict rationale must be a string");
|
|
1737
|
+
}
|
|
1738
|
+
return { decision: value.decision, riskLevel: value.riskLevel, rationale: value.rationale };
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1157
1741
|
// ---- the TypeSafe Jev direct backend ---------------------------------------
|
|
1158
1742
|
|
|
1159
1743
|
/**
|
|
@@ -1230,7 +1814,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1230
1814
|
outcome: "unavailable",
|
|
1231
1815
|
riskLevel: "-",
|
|
1232
1816
|
model: "jev(" + cfg.model + ")",
|
|
1233
|
-
rationale: "Jev backend selected but no API key configured (Settings →
|
|
1817
|
+
rationale: "Jev backend selected but no API key configured (Settings → 自动审批, or the TYPESAFE_API_KEY environment variable)",
|
|
1234
1818
|
});
|
|
1235
1819
|
return "unavailable";
|
|
1236
1820
|
}
|
|
@@ -1309,8 +1893,10 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1309
1893
|
return "unavailable";
|
|
1310
1894
|
}
|
|
1311
1895
|
|
|
1312
|
-
/** The single POST to the System One endpoint; resolves the parsed body.
|
|
1313
|
-
|
|
1896
|
+
/** The single POST to the System One endpoint; resolves the parsed body.
|
|
1897
|
+
* `questions` defaults to the escalation set; the review path passes
|
|
1898
|
+
* `JEV_REVIEW_QUESTIONS`. */
|
|
1899
|
+
async _jevRequest(cfg, state, abortSignal, questions) {
|
|
1314
1900
|
const response = await fetch(cfg.endpoint, {
|
|
1315
1901
|
method: "POST",
|
|
1316
1902
|
headers: {
|
|
@@ -1320,7 +1906,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1320
1906
|
body: JSON.stringify({
|
|
1321
1907
|
state: state,
|
|
1322
1908
|
model: cfg.model,
|
|
1323
|
-
questions: JEV_QUESTIONS,
|
|
1909
|
+
questions: questions === undefined ? JEV_QUESTIONS : questions,
|
|
1324
1910
|
}),
|
|
1325
1911
|
signal: abortSignal,
|
|
1326
1912
|
});
|
|
@@ -1339,12 +1925,15 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1339
1925
|
}
|
|
1340
1926
|
|
|
1341
1927
|
/**
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1928
|
+
* Parse + gate one Jev response into a normalized verdict, shared by the
|
|
1929
|
+
* escalation path (`_jevVerdict`) and the per-call review path
|
|
1930
|
+
* (`_reviewWithJev`) so both judge to exactly the same standard:
|
|
1931
|
+
* - `{ kind: "malformed", served }` — any missing/out-of-shape answer
|
|
1932
|
+
* - `{ kind: "low-confidence", served, choice, riskLevel, confidence, gate }`
|
|
1933
|
+
* - `{ kind: "verdict", served, choice, riskLevel, rationale }`
|
|
1344
1934
|
*/
|
|
1345
|
-
|
|
1935
|
+
_jevParse(body, cfg) {
|
|
1346
1936
|
const served = typeof body.model === "string" && body.model !== "" ? body.model : cfg.model;
|
|
1347
|
-
const label = "jev(" + served + ")";
|
|
1348
1937
|
const answers = body.answers && typeof body.answers === "object" ? body.answers : {};
|
|
1349
1938
|
const decision = answers.decision && typeof answers.decision === "object" ? answers.decision : undefined;
|
|
1350
1939
|
const risk = answers.riskLevel && typeof answers.riskLevel === "object" ? answers.riskLevel : undefined;
|
|
@@ -1366,30 +1955,20 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1366
1955
|
riskChoice === undefined ||
|
|
1367
1956
|
!Number.isFinite(probeNoul)
|
|
1368
1957
|
) {
|
|
1369
|
-
|
|
1370
|
-
...base,
|
|
1371
|
-
durationMs: durationMs,
|
|
1372
|
-
outcome: "unavailable",
|
|
1373
|
-
riskLevel: "-",
|
|
1374
|
-
model: label,
|
|
1375
|
-
rationale: "Jev returned no valid verdict shape (decision/riskLevel/concreteRisk incomplete)",
|
|
1376
|
-
});
|
|
1377
|
-
return "unavailable";
|
|
1958
|
+
return { kind: "malformed", served: served };
|
|
1378
1959
|
}
|
|
1379
1960
|
|
|
1380
1961
|
// Confidence gate: below the threshold the model is not sure enough to
|
|
1381
1962
|
// decide at all — never a grant, never a recorded rejection.
|
|
1382
1963
|
if (confidence < cfg.confidence) {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1964
|
+
return {
|
|
1965
|
+
kind: "low-confidence",
|
|
1966
|
+
served: served,
|
|
1967
|
+
choice: choice,
|
|
1387
1968
|
riskLevel: riskChoice,
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
});
|
|
1392
|
-
return "unavailable";
|
|
1969
|
+
confidence: confidence,
|
|
1970
|
+
gate: cfg.confidence,
|
|
1971
|
+
};
|
|
1393
1972
|
}
|
|
1394
1973
|
|
|
1395
1974
|
const pApprove = Number(probabilities.approve);
|
|
@@ -1418,15 +1997,47 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1418
1997
|
(riskParts.length > 0 ? "(" + riskParts.join(",") + ")" : "") +
|
|
1419
1998
|
";具体风险概率=" + probeNoul.toFixed(2) +
|
|
1420
1999
|
"。Jev 为结构化决策模型,不生成文字,本理由由概率分布合成。";
|
|
2000
|
+
return { kind: "verdict", served: served, choice: choice, riskLevel: riskChoice, rationale: rationale };
|
|
2001
|
+
}
|
|
1421
2002
|
|
|
2003
|
+
/**
|
|
2004
|
+
* Map a Jev response to the same outcomes the subagent path produces.
|
|
2005
|
+
* Returns the waterfall outcome string; records the audit line itself.
|
|
2006
|
+
*/
|
|
2007
|
+
_jevVerdict(session, body, cfg, base, trustKey, durationMs) {
|
|
2008
|
+
const parsed = this._jevParse(body, cfg);
|
|
2009
|
+
const label = "jev(" + parsed.served + ")";
|
|
1422
2010
|
base.durationMs = durationMs;
|
|
1423
|
-
|
|
2011
|
+
|
|
2012
|
+
if (parsed.kind === "malformed") {
|
|
2013
|
+
this._record(session, {
|
|
2014
|
+
...base,
|
|
2015
|
+
outcome: "unavailable",
|
|
2016
|
+
riskLevel: "-",
|
|
2017
|
+
model: label,
|
|
2018
|
+
rationale: "Jev returned no valid verdict shape (decision/riskLevel/concreteRisk incomplete)",
|
|
2019
|
+
});
|
|
2020
|
+
return "unavailable";
|
|
2021
|
+
}
|
|
2022
|
+
if (parsed.kind === "low-confidence") {
|
|
2023
|
+
this._record(session, {
|
|
2024
|
+
...base,
|
|
2025
|
+
outcome: "unavailable",
|
|
2026
|
+
riskLevel: parsed.riskLevel,
|
|
2027
|
+
model: label,
|
|
2028
|
+
rationale:
|
|
2029
|
+
"Jev confidence " + parsed.confidence.toFixed(2) + " is below the gate " + parsed.gate.toFixed(2) + " (decision draft: " + parsed.choice + ") — fail closed",
|
|
2030
|
+
});
|
|
2031
|
+
return "unavailable";
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
const approved = parsed.choice === "approve";
|
|
1424
2035
|
this._record(session, {
|
|
1425
2036
|
...base,
|
|
1426
2037
|
outcome: approved ? "allowed-once" : "rejected",
|
|
1427
|
-
riskLevel:
|
|
2038
|
+
riskLevel: parsed.riskLevel,
|
|
1428
2039
|
model: label,
|
|
1429
|
-
rationale: trunc(rationale, 600),
|
|
2040
|
+
rationale: trunc(parsed.rationale, 600),
|
|
1430
2041
|
});
|
|
1431
2042
|
if (approved && trustKey !== undefined) {
|
|
1432
2043
|
let set = this._trusted.get(session.id);
|
|
@@ -1439,6 +2050,311 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1439
2050
|
return approved ? "allowed-once" : "rejected";
|
|
1440
2051
|
}
|
|
1441
2052
|
|
|
2053
|
+
// ---- v1.8.0 per-call review mode (agent-review) ----------------------------
|
|
2054
|
+
|
|
2055
|
+
/**
|
|
2056
|
+
* The `tools/pre-execute` waterfall listener (outermost via prepend).
|
|
2057
|
+
* Claims every call of a REVIEW-enabled session before its body runs;
|
|
2058
|
+
* everything else delegates via `next()` OUTSIDE any try/catch (a failure
|
|
2059
|
+
* deeper in the chain keeps its own semantics). Coverage mirrors the
|
|
2060
|
+
* official auto-review: every native call and every started PTC inner call
|
|
2061
|
+
* (`exec.parent`), with the outer `run_code` transport deliberately
|
|
2062
|
+
* excluded. Denials are FINAL (fail-closed, no human fallback — user
|
|
2063
|
+
* ruling 2026-10): reject, low confidence, timeout and infrastructure
|
|
2064
|
+
* faults all deny the call without executing its body.
|
|
2065
|
+
*/
|
|
2066
|
+
async _onPreExecute(exec, next) {
|
|
2067
|
+
let session;
|
|
2068
|
+
try {
|
|
2069
|
+
const agent = exec && exec.agent;
|
|
2070
|
+
const s = agent && agent.session;
|
|
2071
|
+
if (s === undefined || s === null) return await next();
|
|
2072
|
+
if (exec.parent === undefined && String(exec.name) === RUN_CODE_TOOL) return await next();
|
|
2073
|
+
const entry = this._enabled.get(s.id);
|
|
2074
|
+
if (entry === undefined || entry.mode !== "review") return await next();
|
|
2075
|
+
session = s;
|
|
2076
|
+
} catch (e) {
|
|
2077
|
+
// A broken claim check must not fail closed for the (vast majority)
|
|
2078
|
+
// non-review sessions — delegate exactly like an unclaimed call.
|
|
2079
|
+
return await next();
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
let verdict;
|
|
2083
|
+
try {
|
|
2084
|
+
verdict = await this._reviewCall(session, exec);
|
|
2085
|
+
} catch (e) {
|
|
2086
|
+
verdict = this._reviewDeny(String(exec && exec.name), "reviewer fault (fail closed): " + errText(e));
|
|
2087
|
+
}
|
|
2088
|
+
if (verdict === undefined) return await next();
|
|
2089
|
+
return verdict;
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* The review-mode decision chain for one pending call: deterministic rules
|
|
2094
|
+
* → session trust cache → the Jev judge. Returns `undefined` to allow (the
|
|
2095
|
+
* caller then delegates `next()`), otherwise a final pre-execute decision.
|
|
2096
|
+
*/
|
|
2097
|
+
async _reviewCall(session, exec) {
|
|
2098
|
+
const startedAt = new Date().toISOString();
|
|
2099
|
+
const t0 = Date.now();
|
|
2100
|
+
const toolName = String(exec.name);
|
|
2101
|
+
let argsRaw;
|
|
2102
|
+
try {
|
|
2103
|
+
argsRaw = exec.arguments === undefined ? "" : JSON.stringify(exec.arguments);
|
|
2104
|
+
} catch (e) {
|
|
2105
|
+
argsRaw = undefined;
|
|
2106
|
+
}
|
|
2107
|
+
const base = {
|
|
2108
|
+
at: startedAt,
|
|
2109
|
+
toolName: toolName,
|
|
2110
|
+
reason: "(per-call review — tools/pre-execute carries no stated reason)",
|
|
2111
|
+
args: trunc(typeof argsRaw === "string" ? argsRaw : "", 2000),
|
|
2112
|
+
durationMs: 0,
|
|
2113
|
+
childSessionId: "",
|
|
2114
|
+
mode: "review",
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
// 1. Deterministic rules run BEFORE the judge — zero latency, zero cost.
|
|
2118
|
+
// Deny beats allow; both are recorded for audit.
|
|
2119
|
+
const rule = this._matchRules(toolName, argsRaw);
|
|
2120
|
+
if (rule !== undefined) {
|
|
2121
|
+
const text =
|
|
2122
|
+
(rule.effect === "deny" ? "matched deny rule" : "matched allow rule") +
|
|
2123
|
+
" [tool=" + rule.tool + (rule.match !== "" ? " match=" + rule.match : "") + "]" +
|
|
2124
|
+
(rule.note !== "" ? " — " + rule.note : "");
|
|
2125
|
+
base.durationMs = Date.now() - t0;
|
|
2126
|
+
this._record(session, {
|
|
2127
|
+
...base,
|
|
2128
|
+
outcome: rule.effect === "deny" ? "rejected" : "allowed-once",
|
|
2129
|
+
riskLevel: "-",
|
|
2130
|
+
model: "rule",
|
|
2131
|
+
rationale: trunc(text, 600),
|
|
2132
|
+
});
|
|
2133
|
+
if (rule.effect === "deny") return this._reviewDeny(toolName, text);
|
|
2134
|
+
return undefined;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// 2. Session trust: a byte-identical call (same tool, same arguments JSON)
|
|
2138
|
+
// already approved in this session runs without judging.
|
|
2139
|
+
const trustKey = typeof argsRaw === "string" ? toolName + "\n" + argsRaw : undefined;
|
|
2140
|
+
const trusted = this._trusted.get(session.id);
|
|
2141
|
+
if (trustKey !== undefined && trusted !== undefined && trusted.has(trustKey)) {
|
|
2142
|
+
base.durationMs = Date.now() - t0;
|
|
2143
|
+
this._record(session, {
|
|
2144
|
+
...base,
|
|
2145
|
+
outcome: "allowed-once",
|
|
2146
|
+
riskLevel: "-",
|
|
2147
|
+
model: "trust",
|
|
2148
|
+
rationale: "trusted: an identical operation was already approved in this session",
|
|
2149
|
+
});
|
|
2150
|
+
return undefined;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// 3. The Jev judge — the ONLY review judge (the mode is gated on Jev).
|
|
2154
|
+
return this._reviewWithJev(session, exec, argsRaw, base, trustKey);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/**
|
|
2158
|
+
* The final fail-closed denial for one review-mode call. No human fallback:
|
|
2159
|
+
* the tool result carries the structured detail (official auto-review deny
|
|
2160
|
+
* card shape) and the rationale goes to the audit trail as usual.
|
|
2161
|
+
*/
|
|
2162
|
+
_reviewDeny(toolName, reason) {
|
|
2163
|
+
return {
|
|
2164
|
+
kind: "deny",
|
|
2165
|
+
reason: 'Agent review rejected tool "' + toolName + '"; its body was not executed',
|
|
2166
|
+
info: {
|
|
2167
|
+
name: REVIEW_DENIED_NAME,
|
|
2168
|
+
code: REVIEW_DENIED_CODE,
|
|
2169
|
+
reason: trunc(String(reason), 600),
|
|
2170
|
+
},
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* The `state` for one per-call review: `_jevStateOf`'s ground truth plus
|
|
2176
|
+
* the pending tool's schema (the official reviewer also receives the
|
|
2177
|
+
* schema). Schema lookup is best-effort: `exec.schema` (PTC inner) or the
|
|
2178
|
+
* request header's tool list (native).
|
|
2179
|
+
*/
|
|
2180
|
+
_reviewStateOf(session, exec, argsRaw) {
|
|
2181
|
+
const state = this._jevStateOf(session, { toolName: String(exec.name), reason: "" }, argsRaw);
|
|
2182
|
+
let schema = exec.schema;
|
|
2183
|
+
if (schema === undefined || schema === null) {
|
|
2184
|
+
try {
|
|
2185
|
+
const header = typeof session.requestHeader === "function" ? session.requestHeader() : undefined;
|
|
2186
|
+
const tools = header && Array.isArray(header.tools) ? header.tools : [];
|
|
2187
|
+
for (const t of tools) {
|
|
2188
|
+
if (t && t.name === exec.name) {
|
|
2189
|
+
schema = t;
|
|
2190
|
+
break;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
} catch (e) {
|
|
2194
|
+
/* schema lookup is best-effort */
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
let parametersText = "(not available)";
|
|
2198
|
+
try {
|
|
2199
|
+
if (schema && schema.parameters) parametersText = trunc(JSON.stringify(schema.parameters), 2000) || "(empty)";
|
|
2200
|
+
} catch (e) {
|
|
2201
|
+
/* unserializable schema degrades to (not available) */
|
|
2202
|
+
}
|
|
2203
|
+
return {
|
|
2204
|
+
...state,
|
|
2205
|
+
statedReason: "(none — per-call review has no stated reason)",
|
|
2206
|
+
toolDescription:
|
|
2207
|
+
schema && typeof schema.description === "string" && schema.description !== ""
|
|
2208
|
+
? trunc(schema.description, 600)
|
|
2209
|
+
: "(not available)",
|
|
2210
|
+
toolParameters: parametersText,
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/**
|
|
2215
|
+
* Judge one pending call through the Jev HTTP API (the review-mode judge).
|
|
2216
|
+
* Mirrors `_judgeWithJev`'s fail-closed contract exactly — same `_jevParse`
|
|
2217
|
+
* standard, same confidence gate — but every outcome is FINAL: rejections,
|
|
2218
|
+
* low confidence, timeouts and faults all deny the call (no human
|
|
2219
|
+
* fallback). Returns `undefined` to allow, otherwise a pre-execute
|
|
2220
|
+
* decision.
|
|
2221
|
+
*/
|
|
2222
|
+
async _reviewWithJev(session, exec, argsRaw, base, trustKey) {
|
|
2223
|
+
const cfg = this._jevEffective();
|
|
2224
|
+
const toolName = String(exec.name);
|
|
2225
|
+
if (cfg.key === "") {
|
|
2226
|
+
this._record(session, {
|
|
2227
|
+
...base,
|
|
2228
|
+
outcome: "unavailable",
|
|
2229
|
+
riskLevel: "-",
|
|
2230
|
+
model: "jev(" + cfg.model + ")",
|
|
2231
|
+
rationale: "review judge selected but no API key configured (Settings → 自动审批, or the TYPESAFE_API_KEY environment variable)",
|
|
2232
|
+
});
|
|
2233
|
+
return this._reviewDeny(toolName, "no Jev API key configured (fail closed)");
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
const startedAt = Date.now();
|
|
2237
|
+
const controller = new AbortController();
|
|
2238
|
+
const signal = exec.signal;
|
|
2239
|
+
const onAbort = () => controller.abort();
|
|
2240
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
2241
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
let winner;
|
|
2245
|
+
try {
|
|
2246
|
+
const state = this._reviewStateOf(session, exec, argsRaw);
|
|
2247
|
+
winner = await Promise.race([
|
|
2248
|
+
this._jevRequest(cfg, state, controller.signal, JEV_REVIEW_QUESTIONS)
|
|
2249
|
+
.then((body) => ({ kind: "result", body: body }))
|
|
2250
|
+
.catch((error) => ({
|
|
2251
|
+
kind: "fault",
|
|
2252
|
+
error: error,
|
|
2253
|
+
aborted: !!(error && error.name === "AbortError"),
|
|
2254
|
+
})),
|
|
2255
|
+
(signal
|
|
2256
|
+
? new Promise((resolve) => {
|
|
2257
|
+
if (signal.aborted) {
|
|
2258
|
+
resolve(true);
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
signal.addEventListener("abort", () => resolve(true), { once: true });
|
|
2262
|
+
})
|
|
2263
|
+
: Promise.resolve(false)
|
|
2264
|
+
).then((v) => ({ kind: "aborted", aborted: v })),
|
|
2265
|
+
this.ctx.timeout(this._timeoutMs).then(() => ({ kind: "timeout" })),
|
|
2266
|
+
]);
|
|
2267
|
+
} finally {
|
|
2268
|
+
if (signal && typeof signal.removeEventListener === "function") {
|
|
2269
|
+
signal.removeEventListener("abort", onAbort);
|
|
2270
|
+
}
|
|
2271
|
+
try {
|
|
2272
|
+
controller.abort();
|
|
2273
|
+
} catch (e) {
|
|
2274
|
+
/* controller abort never blocks the outcome */
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
const durationMs = Date.now() - startedAt;
|
|
2278
|
+
base.durationMs = durationMs;
|
|
2279
|
+
|
|
2280
|
+
if (winner.kind === "result") {
|
|
2281
|
+
const parsed = this._jevParse(winner.body, cfg);
|
|
2282
|
+
const label = "jev(" + parsed.served + ")";
|
|
2283
|
+
if (parsed.kind === "malformed") {
|
|
2284
|
+
this._record(session, {
|
|
2285
|
+
...base,
|
|
2286
|
+
outcome: "unavailable",
|
|
2287
|
+
riskLevel: "-",
|
|
2288
|
+
model: label,
|
|
2289
|
+
rationale: "Jev returned no valid verdict shape (decision/riskLevel/concreteRisk incomplete)",
|
|
2290
|
+
});
|
|
2291
|
+
return this._reviewDeny(toolName, "Jev returned no valid verdict shape (fail closed)");
|
|
2292
|
+
}
|
|
2293
|
+
if (parsed.kind === "low-confidence") {
|
|
2294
|
+
this._record(session, {
|
|
2295
|
+
...base,
|
|
2296
|
+
outcome: "unavailable",
|
|
2297
|
+
riskLevel: parsed.riskLevel,
|
|
2298
|
+
model: label,
|
|
2299
|
+
rationale:
|
|
2300
|
+
"Jev confidence " + parsed.confidence.toFixed(2) + " is below the gate " + parsed.gate.toFixed(2) + " (decision draft: " + parsed.choice + ") — fail closed",
|
|
2301
|
+
});
|
|
2302
|
+
return this._reviewDeny(
|
|
2303
|
+
toolName,
|
|
2304
|
+
"Jev confidence below the gate (fail closed, decision draft: " + parsed.choice + ")",
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
const approved = parsed.choice === "approve";
|
|
2308
|
+
this._record(session, {
|
|
2309
|
+
...base,
|
|
2310
|
+
outcome: approved ? "allowed-once" : "rejected",
|
|
2311
|
+
riskLevel: parsed.riskLevel,
|
|
2312
|
+
model: label,
|
|
2313
|
+
rationale: trunc(parsed.rationale, 600),
|
|
2314
|
+
});
|
|
2315
|
+
if (approved) {
|
|
2316
|
+
if (trustKey !== undefined) {
|
|
2317
|
+
let set = this._trusted.get(session.id);
|
|
2318
|
+
if (set === undefined) {
|
|
2319
|
+
set = new Set();
|
|
2320
|
+
this._trusted.set(session.id, set);
|
|
2321
|
+
}
|
|
2322
|
+
set.add(trustKey);
|
|
2323
|
+
}
|
|
2324
|
+
return undefined;
|
|
2325
|
+
}
|
|
2326
|
+
return this._reviewDeny(toolName, parsed.rationale);
|
|
2327
|
+
}
|
|
2328
|
+
if (winner.kind === "aborted" || (winner.kind === "fault" && winner.aborted)) {
|
|
2329
|
+
this._record(session, {
|
|
2330
|
+
...base,
|
|
2331
|
+
outcome: "cancelled",
|
|
2332
|
+
riskLevel: "-",
|
|
2333
|
+
model: "jev(" + cfg.model + ")",
|
|
2334
|
+
rationale: "request cancelled while the review judge was judging",
|
|
2335
|
+
});
|
|
2336
|
+
return { kind: "cancel" };
|
|
2337
|
+
}
|
|
2338
|
+
if (winner.kind === "timeout") {
|
|
2339
|
+
this._record(session, {
|
|
2340
|
+
...base,
|
|
2341
|
+
outcome: "unavailable",
|
|
2342
|
+
riskLevel: "-",
|
|
2343
|
+
model: "jev(" + cfg.model + ")",
|
|
2344
|
+
rationale: "review judge timed out after " + String(this._timeoutMs) + "ms (fail closed)",
|
|
2345
|
+
});
|
|
2346
|
+
return this._reviewDeny(toolName, "review judge timed out (fail closed)");
|
|
2347
|
+
}
|
|
2348
|
+
this._record(session, {
|
|
2349
|
+
...base,
|
|
2350
|
+
outcome: "unavailable",
|
|
2351
|
+
riskLevel: "-",
|
|
2352
|
+
model: "jev(" + cfg.model + ")",
|
|
2353
|
+
rationale: "review judge failed (fail closed): " + errText(winner.error),
|
|
2354
|
+
});
|
|
2355
|
+
return this._reviewDeny(toolName, "review judge failed (fail closed): " + errText(winner.error));
|
|
2356
|
+
}
|
|
2357
|
+
|
|
1442
2358
|
// ---- Remote API ------------------------------------------------------------
|
|
1443
2359
|
|
|
1444
2360
|
/**
|
|
@@ -1480,6 +2396,9 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1480
2396
|
ok: true,
|
|
1481
2397
|
value: {
|
|
1482
2398
|
model: { provider: this._model.provider, model: this._model.model },
|
|
2399
|
+
judgeMode: this._judgeMode,
|
|
2400
|
+
reviewAvailable: this._jevGateOk(),
|
|
2401
|
+
reviewDefault: this._reviewDefault,
|
|
1483
2402
|
jev: this._jevShape(),
|
|
1484
2403
|
timeoutMs: this._timeoutMs,
|
|
1485
2404
|
enabledSessions: this._sessionInfos(),
|
|
@@ -1507,6 +2426,40 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1507
2426
|
return { ok: true, value: { model: { provider: this._model.provider, model: this._model.model } } };
|
|
1508
2427
|
}
|
|
1509
2428
|
|
|
2429
|
+
/**
|
|
2430
|
+
* Set the judge invocation mode: "llm" (default — one direct
|
|
2431
|
+
* `ctx.llm.stream()` call per judgment, no subagent session) or "subagent"
|
|
2432
|
+
* (the isolated judge child as before). Input/output and verdict semantics
|
|
2433
|
+
* are identical across modes; only the invocation differs. Persisted.
|
|
2434
|
+
*/
|
|
2435
|
+
async setJudgeMode(request) {
|
|
2436
|
+
const mode = request && typeof request.mode === "string" ? request.mode : "";
|
|
2437
|
+
if (mode !== JUDGE_MODE_LLM && mode !== JUDGE_MODE_SUBAGENT) {
|
|
2438
|
+
return {
|
|
2439
|
+
ok: false,
|
|
2440
|
+
error: { code: "invalid-judge-mode", message: 'mode must be "llm" or "subagent"' },
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
this._judgeMode = mode;
|
|
2444
|
+
this._persistConfig();
|
|
2445
|
+
return { ok: true, value: { judgeMode: this._judgeMode } };
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* v1.8.0: the global default for the per-call review mode. When on, FRESH
|
|
2450
|
+
* sessions (no genuine user message yet) auto-enter 自动审查 at creation,
|
|
2451
|
+
* subject to the Jev gate (gate closed → the normal default applies and a
|
|
2452
|
+
* default `agent-review` fill-in still bounces to 自动审批). Resumed
|
|
2453
|
+
* sessions are never touched — their folded preset selection wins. The
|
|
2454
|
+
* per-session switch stays in the /permission menu and /agent-review.
|
|
2455
|
+
* Persisted.
|
|
2456
|
+
*/
|
|
2457
|
+
async setReviewDefault(request) {
|
|
2458
|
+
this._reviewDefault = !!(request && request.on);
|
|
2459
|
+
this._persistConfig();
|
|
2460
|
+
return { ok: true, value: { reviewDefault: this._reviewDefault } };
|
|
2461
|
+
}
|
|
2462
|
+
|
|
1510
2463
|
/**
|
|
1511
2464
|
* Set the TypeSafe Jev backend settings (only provided fields change).
|
|
1512
2465
|
* `confidence` is the gate below which Jev's answer is not trusted and the
|