@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/index.js CHANGED
@@ -164,7 +164,40 @@ const JEV_QUESTIONS = {
164
164
  };
165
165
 
166
166
  /**
167
- * The structured verdict the judge subagent MUST produce. Constrained to the
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
- /** Shadowing persona for the judge child (spawn provider capability). */
194
- const APPROVER_PERSONA = [
195
- "You are an independent security approval agent inside a coding harness.",
196
- "Your only job is to judge ONE request for wider sandbox access and report the verdict through the structured_output tool.",
197
- "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.",
198
- "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.",
199
- "You never ask questions, never attempt the operation yourself, and never finish with a plain-text answer.",
200
- ].join(" ");
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
- if (this._lastKnob(agent.session, "permission/preset", "preset") !== PRESET_NAME) return;
371
- this._enableCore(agent.session, agent);
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 || !this._enabled.has(agent.session.id)) return "";
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 "Agent 审批".
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 sandbox
529
- * to workspace-write and approval policy to `ask` (the waterfall — and
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 !== BASE_MODE) session.append("sandbox/mode", { mode: BASE_MODE });
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 "Agent 审批". Without it (the
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 "agent-approval is not ON for this session";
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 "agent-approval OFF: previous permission knobs restored";
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 "agent-approval OFF: previous permission knobs restored";
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, via `sessionPersistence.locate(header)` — a pure path
696
- * resolution that also works for live sessions). Falls back to a
697
- * plugin-owned per-session file under DSH_HOME when the seam or the
698
- * location is unavailable; the fallback keeps restart-safety at the cost
699
- * of not being cleaned up when the session is deleted.
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 && typeof persistence.locate === "function") {
962
+ if (persistence !== undefined) {
704
963
  try {
705
- const loc = persistence.locate(session.header);
706
- if (loc && typeof loc.path === "string" && loc.path !== "") {
707
- return join(dirname(loc.path), RECORDS_SIDECAR);
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
- _judgePrompt(session, req, argsRaw) {
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. Report the verdict via the structured_output tool only.",
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); anything else spawns the judge
1051
- // child through the `spawn` provider as before.
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 → Agent 审批, or the TYPESAFE_API_KEY environment variable)",
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
- async _jevRequest(cfg, state, abortSignal) {
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
- * Map a Jev response to the same outcomes the subagent path produces.
1343
- * Returns the waterfall outcome string; records the audit line itself.
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
- _jevVerdict(session, body, cfg, base, trustKey, durationMs) {
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
- this._record(session, {
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
- this._record(session, {
1384
- ...base,
1385
- durationMs: durationMs,
1386
- outcome: "unavailable",
1964
+ return {
1965
+ kind: "low-confidence",
1966
+ served: served,
1967
+ choice: choice,
1387
1968
  riskLevel: riskChoice,
1388
- model: label,
1389
- rationale:
1390
- "Jev confidence " + confidence.toFixed(2) + " is below the gate " + cfg.confidence.toFixed(2) + " (decision draft: " + choice + ") — fail closed",
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
- const approved = choice === "approve";
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: riskChoice,
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