@duke-dsh-plugins/dsh-agent-approval 1.3.5 → 1.4.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/client.js +190 -11
- package/index.js +251 -17
- package/package.json +1 -1
- package/typert.host.js +123 -3
package/client.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* 1. A "Agent 审批" page in the Settings panel (`settings.section`):
|
|
7
7
|
* - approval model picker (provider + model, or the harness default),
|
|
8
8
|
* - judge timeout setting (fail-closed),
|
|
9
|
-
* - the list of sessions with the mode enabled
|
|
9
|
+
* - the list of sessions with the mode enabled (session-list title +
|
|
10
|
+
* workspace, so each chip is recognizable),
|
|
10
11
|
* - the latest approval audit records (verdict, risk, model, duration,
|
|
11
12
|
* rationale; hover for the full rationale + tool arguments).
|
|
12
13
|
*
|
|
@@ -38,7 +39,9 @@ window.__ModuleLoader__.load({
|
|
|
38
39
|
.aapr-muted{color:var(--dsw-alias-label-secondary);line-height:1.5}
|
|
39
40
|
.aapr-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
40
41
|
.aapr-select,.aapr-input{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px;font-size:12px;max-width:340px}
|
|
41
|
-
.aapr-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);
|
|
42
|
+
.aapr-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:2px 10px;background:var(--dsw-alias-bg-layer-2);max-width:100%}
|
|
43
|
+
.aapr-chip-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:320px}
|
|
44
|
+
.aapr-chip-id{color:var(--dsw-alias-label-secondary);font-family:monospace;font-size:11px}
|
|
42
45
|
.aapr-chip button{background:none;border:none;color:var(--dsw-alias-state-error-primary);cursor:pointer;font-size:12px;padding:0 2px}
|
|
43
46
|
.aapr-wrap{overflow-x:auto}
|
|
44
47
|
.aapr-table{width:100%;border-collapse:collapse;font-size:12px}
|
|
@@ -47,6 +50,12 @@ window.__ModuleLoader__.load({
|
|
|
47
50
|
.aapr-cell{max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:normal}
|
|
48
51
|
.aapr-ok{color:var(--dsw-alias-state-success-primary);white-space:nowrap}
|
|
49
52
|
.aapr-no{color:var(--dsw-alias-state-error-primary);white-space:nowrap}
|
|
53
|
+
.aapr-input-wide{flex:1;min-width:220px;max-width:none}
|
|
54
|
+
.aapr-rule-match{font-family:monospace;font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
55
|
+
.aapr-rule-del,.aapr-whitelist{background:none;border:none;cursor:pointer;font-size:12px;padding:0 2px}
|
|
56
|
+
.aapr-rule-del{color:var(--dsw-alias-state-error-primary)}
|
|
57
|
+
.aapr-whitelist{color:var(--dsw-alias-label-secondary)}
|
|
58
|
+
.aapr-whitelist:hover{color:var(--dsw-alias-label-primary)}
|
|
50
59
|
|
|
51
60
|
/* Settings nav icon: DSH 0.1.x settings.section only projects id/order/
|
|
52
61
|
label, and the settings shell paints a generic gear for every external
|
|
@@ -249,6 +258,24 @@ window.__ModuleLoader__.load({
|
|
|
249
258
|
parameters: param("dsh-agent-approval#AgentApprovalToggleRequest"),
|
|
250
259
|
result: result("dsh-agent-approval#AgentApprovalToggleResult"),
|
|
251
260
|
},
|
|
261
|
+
{
|
|
262
|
+
id: "dsh-agent-approval#agentApproval/addRule",
|
|
263
|
+
service: "agentApproval",
|
|
264
|
+
namespace: "agentApproval",
|
|
265
|
+
method: "addRule",
|
|
266
|
+
invocation: { kind: "direct" },
|
|
267
|
+
parameters: param("dsh-agent-approval#AgentApprovalAddRuleRequest"),
|
|
268
|
+
result: result("dsh-agent-approval#AgentApprovalRulesResult"),
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: "dsh-agent-approval#agentApproval/removeRule",
|
|
272
|
+
service: "agentApproval",
|
|
273
|
+
namespace: "agentApproval",
|
|
274
|
+
method: "removeRule",
|
|
275
|
+
invocation: { kind: "direct" },
|
|
276
|
+
parameters: param("dsh-agent-approval#AgentApprovalRemoveRuleRequest"),
|
|
277
|
+
result: result("dsh-agent-approval#AgentApprovalRulesResult"),
|
|
278
|
+
},
|
|
252
279
|
{
|
|
253
280
|
id: "dsh-agent-approval#agentApproval/clearRecords",
|
|
254
281
|
service: "agentApproval",
|
|
@@ -326,6 +353,19 @@ window.__ModuleLoader__.load({
|
|
|
326
353
|
return typeof s === "string" && s.length > n ? s.slice(0, n) + "…" : s || "";
|
|
327
354
|
}
|
|
328
355
|
|
|
356
|
+
/**
|
|
357
|
+
Mirror of Host `shortId()`: DSH prefixes session ids with the literal
|
|
358
|
+
`"session-"` before the UUID, so a naive `slice(0, 8)` shows nothing
|
|
359
|
+
but `"session-"`. Strip the known prefix so the chip displays 8 chars
|
|
360
|
+
of the UUID proper. Kept here too because the bundle has no shared
|
|
361
|
+
module with the Host half.
|
|
362
|
+
*/
|
|
363
|
+
function shortSessionId(id) {
|
|
364
|
+
const s = String(id);
|
|
365
|
+
const tail = s.indexOf("session-") === 0 ? s.slice("session-".length) : s;
|
|
366
|
+
return tail.slice(0, 8);
|
|
367
|
+
}
|
|
368
|
+
|
|
329
369
|
function fmtTime(iso) {
|
|
330
370
|
try {
|
|
331
371
|
const d = new Date(iso);
|
|
@@ -360,6 +400,11 @@ window.__ModuleLoader__.load({
|
|
|
360
400
|
const noteSlot = React.useState("");
|
|
361
401
|
const note = noteSlot[0];
|
|
362
402
|
const setNote = noteSlot[1];
|
|
403
|
+
// Rule-add form drafts (the table itself comes from getState).
|
|
404
|
+
const ruleEffectSlot = React.useState("allow");
|
|
405
|
+
const ruleToolSlot = React.useState("");
|
|
406
|
+
const ruleMatchSlot = React.useState("");
|
|
407
|
+
const ruleNoteSlot = React.useState("");
|
|
363
408
|
|
|
364
409
|
const refresh = () => {
|
|
365
410
|
remote
|
|
@@ -425,6 +470,58 @@ window.__ModuleLoader__.load({
|
|
|
425
470
|
.catch(() => {});
|
|
426
471
|
};
|
|
427
472
|
|
|
473
|
+
const ruleEffect = ruleEffectSlot[0];
|
|
474
|
+
const ruleTool = ruleToolSlot[0];
|
|
475
|
+
const ruleMatch = ruleMatchSlot[0];
|
|
476
|
+
const ruleNote = ruleNoteSlot[0];
|
|
477
|
+
const setRuleEffect = ruleEffectSlot[1];
|
|
478
|
+
const setRuleTool = ruleToolSlot[1];
|
|
479
|
+
const setRuleMatch = ruleMatchSlot[1];
|
|
480
|
+
const setRuleNote = ruleNoteSlot[1];
|
|
481
|
+
const rules = state !== null && Array.isArray(state.rules) ? state.rules : [];
|
|
482
|
+
|
|
483
|
+
const addRule = (draft) => {
|
|
484
|
+
remote
|
|
485
|
+
.addRule(draft)
|
|
486
|
+
.then((res) => {
|
|
487
|
+
const v = pick(res);
|
|
488
|
+
setState((prev) => (prev ? Object.assign({}, prev, { rules: v.rules }) : prev));
|
|
489
|
+
setRuleTool("");
|
|
490
|
+
setRuleMatch("");
|
|
491
|
+
setRuleNote("");
|
|
492
|
+
setNote("规则已保存(deny 优先于 allow;命中即不再经过审批模型)");
|
|
493
|
+
})
|
|
494
|
+
.catch((e) => setNote("保存失败:" + (e && e.message ? e.message : String(e))));
|
|
495
|
+
};
|
|
496
|
+
const submitRule = () => {
|
|
497
|
+
if (ruleTool.trim() === "") {
|
|
498
|
+
setNote("工具名必填(* 匹配所有工具)");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
addRule({ effect: ruleEffect, tool: ruleTool.trim(), match: ruleMatch, note: ruleNote.trim() });
|
|
502
|
+
};
|
|
503
|
+
const removeRule = (id) => {
|
|
504
|
+
remote
|
|
505
|
+
.removeRule({ id: id })
|
|
506
|
+
.then((res) => {
|
|
507
|
+
const v = pick(res);
|
|
508
|
+
setState((prev) => (prev ? Object.assign({}, prev, { rules: v.rules }) : prev));
|
|
509
|
+
})
|
|
510
|
+
.catch(() => {});
|
|
511
|
+
};
|
|
512
|
+
// One-click whitelist from an audit row: the recorded args are a
|
|
513
|
+
// PREFIX of the real arguments JSON (the Host truncates at 2000
|
|
514
|
+
// chars), so stripping the truncation marker keeps a valid substring.
|
|
515
|
+
const whitelistRecord = (r) => {
|
|
516
|
+
const args = String(r.args || "").replace(/…\[truncated\]$/, "");
|
|
517
|
+
addRule({
|
|
518
|
+
effect: "allow",
|
|
519
|
+
tool: String(r.toolName),
|
|
520
|
+
match: args,
|
|
521
|
+
note: "来自审计 " + fmtTime(r.at),
|
|
522
|
+
});
|
|
523
|
+
};
|
|
524
|
+
|
|
428
525
|
const providerOptions = [{ id: "", name: "默认(Harness 默认模型)" }].concat(
|
|
429
526
|
dir ? dir.providers : [],
|
|
430
527
|
);
|
|
@@ -529,15 +626,82 @@ window.__ModuleLoader__.load({
|
|
|
529
626
|
: h(
|
|
530
627
|
"div",
|
|
531
628
|
{ className: "aapr-row" },
|
|
532
|
-
state.enabledSessions.map((
|
|
533
|
-
|
|
629
|
+
state.enabledSessions.map((raw) => {
|
|
630
|
+
// New hosts send { id, title, cwd }; a not-yet-restarted
|
|
631
|
+
// old host still sends bare id strings — render both.
|
|
632
|
+
const info =
|
|
633
|
+
typeof raw === "string" ? { id: raw, title: "", cwd: "" } : raw;
|
|
634
|
+
const sid = String(info.id);
|
|
635
|
+
const title =
|
|
636
|
+
typeof info.title === "string" && info.title !== "" ? info.title : "";
|
|
637
|
+
const cwd = typeof info.cwd === "string" && info.cwd !== "" ? info.cwd : "";
|
|
638
|
+
const tip =
|
|
639
|
+
"会话 ID:" + sid + (cwd !== "" ? "\n工作区:" + cwd : "");
|
|
640
|
+
const sidShort = shortSessionId(sid);
|
|
641
|
+
return h(
|
|
534
642
|
"span",
|
|
535
|
-
{ key: sid, className: "aapr-chip" },
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
643
|
+
{ key: sid, className: "aapr-chip", title: tip },
|
|
644
|
+
h(
|
|
645
|
+
"span",
|
|
646
|
+
{ className: "aapr-chip-title" },
|
|
647
|
+
title !== "" ? title : sidShort,
|
|
648
|
+
),
|
|
649
|
+
title !== ""
|
|
650
|
+
? h("span", { className: "aapr-chip-id" }, sidShort)
|
|
651
|
+
: null,
|
|
652
|
+
h(
|
|
653
|
+
"button",
|
|
654
|
+
{ onClick: () => disableSession(sid), title: "关闭该会话的 Agent 审批" },
|
|
655
|
+
"✕",
|
|
656
|
+
),
|
|
657
|
+
);
|
|
658
|
+
}),
|
|
659
|
+
),
|
|
660
|
+
),
|
|
661
|
+
h(
|
|
662
|
+
"div",
|
|
663
|
+
{ className: "aapr-card" },
|
|
664
|
+
h("h3", null, "放行 / 拒绝规则"),
|
|
665
|
+
h(
|
|
666
|
+
"div",
|
|
667
|
+
{ className: "aapr-muted" },
|
|
668
|
+
"命中规则的提权不再经过审批模型:拒绝规则直接拒、放行规则直接过(拒绝优先于放行)。match 留空 = 该工具全部调用;否则是参数 JSON 的子串,或 /正则/flags 形式。另:模型批准后,同一会话内参数完全相同的再次提权直接放行(会话内信任,不跨会话、不泛化)。",
|
|
669
|
+
),
|
|
670
|
+
rules.length === 0
|
|
671
|
+
? h("div", { className: "aapr-muted" }, "暂无规则。")
|
|
672
|
+
: rules.map((rule) =>
|
|
673
|
+
h(
|
|
674
|
+
"div",
|
|
675
|
+
{ key: rule.id, className: "aapr-row" },
|
|
676
|
+
h(
|
|
677
|
+
"span",
|
|
678
|
+
{ className: rule.effect === "allow" ? "aapr-ok" : "aapr-no" },
|
|
679
|
+
rule.effect === "allow" ? "放行" : "拒绝",
|
|
539
680
|
),
|
|
681
|
+
h("span", { className: "aapr-chip-id" }, String(rule.tool)),
|
|
682
|
+
rule.match !== ""
|
|
683
|
+
? h("span", { className: "aapr-rule-match", title: String(rule.match) }, truncText(rule.match, 60))
|
|
684
|
+
: h("span", { className: "aapr-muted" }, "(全部调用)"),
|
|
685
|
+
rule.note !== ""
|
|
686
|
+
? h("span", { className: "aapr-muted" }, truncText(rule.note, 40))
|
|
687
|
+
: null,
|
|
688
|
+
h("button", { className: "aapr-rule-del", onClick: () => removeRule(rule.id), title: "删除该规则" }, "✕"),
|
|
540
689
|
),
|
|
690
|
+
),
|
|
691
|
+
h(
|
|
692
|
+
"div",
|
|
693
|
+
{ className: "aapr-row" },
|
|
694
|
+
h(
|
|
695
|
+
"select",
|
|
696
|
+
{ className: "aapr-select", value: ruleEffect, onChange: (e) => setRuleEffect(e.target.value) },
|
|
697
|
+
h("option", { value: "allow" }, "放行"),
|
|
698
|
+
h("option", { value: "deny" }, "拒绝"),
|
|
699
|
+
),
|
|
700
|
+
h("input", { className: "aapr-input", placeholder: "工具名(* = 所有工具)", value: ruleTool, onChange: (e) => setRuleTool(e.target.value) }),
|
|
701
|
+
h("input", { className: "aapr-input aapr-input-wide", placeholder: "match:留空 = 全部;子串或 /正则/flags", value: ruleMatch, onChange: (e) => setRuleMatch(e.target.value) }),
|
|
702
|
+
h("input", { className: "aapr-input", placeholder: "备注(可选)", value: ruleNote, onChange: (e) => setRuleNote(e.target.value) }),
|
|
703
|
+
h(ui.Button, { variant: "primary", size: "sm", onClick: submitRule }, "添加"),
|
|
704
|
+
),
|
|
541
705
|
),
|
|
542
706
|
h(
|
|
543
707
|
"div",
|
|
@@ -562,7 +726,7 @@ window.__ModuleLoader__.load({
|
|
|
562
726
|
h(
|
|
563
727
|
"tr",
|
|
564
728
|
null,
|
|
565
|
-
["时间", "会话", "工具", "结果", "风险", "模型", "耗时", "审批理由"].map((t) => h("th", { key: t }, t)),
|
|
729
|
+
["时间", "会话", "工具", "结果", "风险", "模型", "耗时", "审批理由", "规则"].map((t) => h("th", { key: t }, t)),
|
|
566
730
|
),
|
|
567
731
|
),
|
|
568
732
|
h(
|
|
@@ -591,16 +755,31 @@ window.__ModuleLoader__.load({
|
|
|
591
755
|
},
|
|
592
756
|
truncText(r.rationale, 110),
|
|
593
757
|
),
|
|
758
|
+
h(
|
|
759
|
+
"td",
|
|
760
|
+
null,
|
|
761
|
+
r.outcome === "allowed-once" && r.args && r.model !== "rule"
|
|
762
|
+
? h(
|
|
763
|
+
"button",
|
|
764
|
+
{
|
|
765
|
+
className: "aapr-whitelist",
|
|
766
|
+
onClick: () => whitelistRecord(r),
|
|
767
|
+
title: "把该操作存为放行规则(工具 + 参数子串):今后直接放行,不再经过审批模型",
|
|
768
|
+
},
|
|
769
|
+
"加白",
|
|
770
|
+
)
|
|
771
|
+
: null,
|
|
772
|
+
),
|
|
594
773
|
),
|
|
595
774
|
)
|
|
596
|
-
: h("tr", null, h("td", { colSpan:
|
|
775
|
+
: h("tr", null, h("td", { colSpan: 9 }, h("span", { className: "aapr-muted" }, "暂无记录"))),
|
|
597
776
|
),
|
|
598
777
|
),
|
|
599
778
|
),
|
|
600
779
|
h(
|
|
601
780
|
"div",
|
|
602
781
|
{ className: "aapr-muted" },
|
|
603
|
-
"悬停“审批理由”可查看完整理由与工具参数;“审批会话”前缀可在会话列表中找到审批 Agent
|
|
782
|
+
"悬停“审批理由”可查看完整理由与工具参数;“审批会话”前缀可在会话列表中找到审批 Agent 的完整会话记录。「加白」把一条已批准的操作存为放行规则(模型列显示 rule/trust 的行分别来自规则命中与会话内信任缓存)。",
|
|
604
783
|
),
|
|
605
784
|
),
|
|
606
785
|
);
|
package/index.js
CHANGED
|
@@ -18,15 +18,19 @@
|
|
|
18
18
|
* 2. JUDGE — this service claims the `approval/request` waterfall with
|
|
19
19
|
* `{ prepend: true }`, so it runs BEFORE the interactive UI answerer:
|
|
20
20
|
* an enabled session never pops a human prompt. Every escalation ask is
|
|
21
|
-
*
|
|
21
|
+
* first checked against the deterministic rule table (persisted
|
|
22
|
+
* allow/deny rules; deny wins) and then the per-session trust cache (a
|
|
23
|
+
* judge-approved, byte-identical call is not re-judged) — both
|
|
24
|
+
* short-circuit with zero model cost. Only a miss on both is routed to
|
|
25
|
+
* a ONE-SHOT `spawn` subagent (own session, zero parent
|
|
22
26
|
* context, approval policy pinned to `never` by the delegation itself,
|
|
23
27
|
* every global tool blanked via `toolFilter: { allow: [] }`) that must
|
|
24
28
|
* answer through a structured-output schema:
|
|
25
29
|
* { decision: approve|reject, riskLevel, rationale }
|
|
26
30
|
* The judge sees the exact tool arguments (read from the session log by
|
|
27
|
-
* `callId`) plus the asker's stated reason
|
|
28
|
-
*
|
|
29
|
-
*
|
|
31
|
+
* `callId`) plus the asker's stated reason. A rejection must name the
|
|
32
|
+
* concrete, credible risk the operation creates (destructive /
|
|
33
|
+
* irreversible / out-of-scope / dishonest); vague unease is approved.
|
|
30
34
|
*
|
|
31
35
|
* 3. FAIL CLOSED — any infrastructure fault, timeout, malformed verdict, or
|
|
32
36
|
* cancellation maps to the fail-closed approval outcomes
|
|
@@ -105,7 +109,7 @@ const VERDICT_SCHEMA = {
|
|
|
105
109
|
const APPROVER_PERSONA = [
|
|
106
110
|
"You are an independent security approval agent inside a coding harness.",
|
|
107
111
|
"Your only job is to judge ONE request for wider sandbox access and report the verdict through the structured_output tool.",
|
|
108
|
-
"You
|
|
112
|
+
"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.",
|
|
109
113
|
"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.",
|
|
110
114
|
"You never ask questions, never attempt the operation yourself, and never finish with a plain-text answer.",
|
|
111
115
|
].join(" ");
|
|
@@ -141,9 +145,22 @@ function trunc(value, n) {
|
|
|
141
145
|
return value.length > n ? value.slice(0, n) + "…[truncated]" : value;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* First 8 chars of a session / run id (display form in records and chips).
|
|
150
|
+
* DSH prefixes session ids with the literal `"session-"` before the UUID
|
|
151
|
+
* (see `@deepseek-ai/dsh-host-apiproxy` `session create` and
|
|
152
|
+
* `@deepseek-ai/dsh-headless` SessionId(`session-${randomUUID()}`)); a naive
|
|
153
|
+
* `slice(0, 8)` lands on that meaningless 8-char prefix and every audit row
|
|
154
|
+
* shows nothing but `"session-"`. Strip the known prefix before truncating
|
|
155
|
+
* so the displayed fragment comes from the UUID proper (where the real
|
|
156
|
+
* discriminator lives); id shapes without the prefix (subagent `run.id`
|
|
157
|
+
* = raw `randomUUID()`) are unaffected.
|
|
158
|
+
*/
|
|
159
|
+
const SESSION_ID_PREFIX = "session-";
|
|
145
160
|
function shortId(id) {
|
|
146
|
-
|
|
161
|
+
const s = String(id);
|
|
162
|
+
const tail = s.startsWith(SESSION_ID_PREFIX) ? s.slice(SESSION_ID_PREFIX.length) : s;
|
|
163
|
+
return tail.slice(0, 8);
|
|
147
164
|
}
|
|
148
165
|
|
|
149
166
|
/** Best-effort error text. */
|
|
@@ -184,6 +201,8 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
184
201
|
markRemoteMethod(this, "setModel", "setModel");
|
|
185
202
|
markRemoteMethod(this, "setApprovalTimeout", "setApprovalTimeout");
|
|
186
203
|
markRemoteMethod(this, "toggle", "toggle");
|
|
204
|
+
markRemoteMethod(this, "addRule", "addRule");
|
|
205
|
+
markRemoteMethod(this, "removeRule", "removeRule");
|
|
187
206
|
markRemoteMethod(this, "clearRecords", "clearRecords");
|
|
188
207
|
markRemoteMethod(this, "directory", "directory");
|
|
189
208
|
|
|
@@ -195,6 +214,19 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
195
214
|
this._enabled = new Map();
|
|
196
215
|
/** Audit records, oldest first, capped at MAX_RECORDS. */
|
|
197
216
|
this._records = [];
|
|
217
|
+
/**
|
|
218
|
+
* Deterministic rules judged BEFORE the model (persisted in config.json):
|
|
219
|
+
* [{ id, effect: "allow"|"deny", tool, match, note, createdAt }]. A hit
|
|
220
|
+
* short-circuits the judge entirely — no subagent, no latency.
|
|
221
|
+
*/
|
|
222
|
+
this._rules = [];
|
|
223
|
+
/**
|
|
224
|
+
* sessionId -> Set of "toolName\nargsJson" fingerprints the judge already
|
|
225
|
+
* approved in that session. Re-judging a byte-identical call is pure
|
|
226
|
+
* latency; the cache never crosses sessions and never generalizes to
|
|
227
|
+
* merely-similar arguments. Dropped with the session's enable entry.
|
|
228
|
+
*/
|
|
229
|
+
this._trusted = new Map();
|
|
198
230
|
|
|
199
231
|
// Claim escalations BEFORE the interactive answerer. The host apiproxy
|
|
200
232
|
// answerer registered earlier (composition load order); `{ prepend: true }`
|
|
@@ -222,6 +254,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
222
254
|
this._enableCore(session, agent);
|
|
223
255
|
} else if (this._enabled.has(session.id)) {
|
|
224
256
|
this._enabled.delete(session.id);
|
|
257
|
+
this._trusted.delete(session.id);
|
|
225
258
|
}
|
|
226
259
|
} catch (e) {
|
|
227
260
|
/* an emit listener must never throw */
|
|
@@ -248,7 +281,10 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
248
281
|
// A disposed session's bookkeeping entry is dead weight — drop it.
|
|
249
282
|
this.ctx.on("session/disposed", (session) => {
|
|
250
283
|
try {
|
|
251
|
-
if (session)
|
|
284
|
+
if (session) {
|
|
285
|
+
this._enabled.delete(session.id);
|
|
286
|
+
this._trusted.delete(session.id);
|
|
287
|
+
}
|
|
252
288
|
} catch (e) {
|
|
253
289
|
/* cleanup only */
|
|
254
290
|
}
|
|
@@ -410,6 +446,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
410
446
|
const prev = this._enabled.get(session.id);
|
|
411
447
|
if (prev === undefined) return "agent-approval is not ON for this session";
|
|
412
448
|
this._enabled.delete(session.id);
|
|
449
|
+
this._trusted.delete(session.id);
|
|
413
450
|
if (!restoreKnobs) return "agent-approval OFF: previous permission knobs restored";
|
|
414
451
|
if (
|
|
415
452
|
typeof prev.prevSandbox === "string" &&
|
|
@@ -457,6 +494,64 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
457
494
|
}
|
|
458
495
|
}
|
|
459
496
|
|
|
497
|
+
// ---- deterministic rules + session trust ----------------------------------
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Compile a rule's `match`: "" matches every call of the tool;
|
|
501
|
+
* "/pattern/flags" is a regex; anything else is a plain substring tested
|
|
502
|
+
* against the raw arguments JSON. Returns undefined for the substring form,
|
|
503
|
+
* null for an invalid regex (rejected at add time; a corrupt persisted rule
|
|
504
|
+
* simply never matches).
|
|
505
|
+
*/
|
|
506
|
+
_ruleRegex(match) {
|
|
507
|
+
if (match.length < 2 || match[0] !== "/") return undefined;
|
|
508
|
+
const last = match.lastIndexOf("/");
|
|
509
|
+
if (last <= 0) return undefined;
|
|
510
|
+
try {
|
|
511
|
+
return new RegExp(match.slice(1, last), match.slice(last + 1));
|
|
512
|
+
} catch (e) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Whether one rule hits this exact call. */
|
|
518
|
+
_ruleMatches(rule, toolName, argsRaw) {
|
|
519
|
+
if (rule.tool !== "*" && rule.tool !== toolName) return false;
|
|
520
|
+
if (rule.match === "") return true;
|
|
521
|
+
const args = typeof argsRaw === "string" ? argsRaw : "";
|
|
522
|
+
const re = this._ruleRegex(rule.match);
|
|
523
|
+
if (re === null) return false;
|
|
524
|
+
if (re !== undefined) return re.test(args);
|
|
525
|
+
return args.indexOf(rule.match) !== -1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* First hitting rule — every deny rule is evaluated before any allow rule
|
|
530
|
+
* may win, so a later-added deny always overrides an earlier allow.
|
|
531
|
+
* Undefined when nothing hits.
|
|
532
|
+
*/
|
|
533
|
+
_matchRules(toolName, argsRaw) {
|
|
534
|
+
let allowHit;
|
|
535
|
+
for (const rule of this._rules) {
|
|
536
|
+
if (!this._ruleMatches(rule, toolName, argsRaw)) continue;
|
|
537
|
+
if (rule.effect === "deny") return rule;
|
|
538
|
+
if (allowHit === undefined) allowHit = rule;
|
|
539
|
+
}
|
|
540
|
+
return allowHit;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Owned plain copies for the wire (strict result schema). */
|
|
544
|
+
_rulesSnapshot() {
|
|
545
|
+
return this._rules.map((r) => ({
|
|
546
|
+
id: String(r.id),
|
|
547
|
+
effect: r.effect === "deny" ? "deny" : "allow",
|
|
548
|
+
tool: String(r.tool),
|
|
549
|
+
match: String(r.match),
|
|
550
|
+
note: String(r.note),
|
|
551
|
+
createdAt: String(r.createdAt),
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
|
|
460
555
|
// ---- audit ----------------------------------------------------------------
|
|
461
556
|
|
|
462
557
|
/** Coerce one entry to the strict wire shape (typert result schema). */
|
|
@@ -501,11 +596,12 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
501
596
|
}
|
|
502
597
|
}
|
|
503
598
|
|
|
504
|
-
/** Persist the judge settings (model override + timeout) to config.json. */
|
|
599
|
+
/** Persist the judge settings (model override + timeout + rules) to config.json. */
|
|
505
600
|
_persistConfig() {
|
|
506
601
|
const body = JSON.stringify({
|
|
507
602
|
model: { provider: this._model.provider, model: this._model.model },
|
|
508
603
|
timeoutMs: this._timeoutMs,
|
|
604
|
+
rules: this._rules,
|
|
509
605
|
});
|
|
510
606
|
mkdir(DATA_DIR, { recursive: true })
|
|
511
607
|
.then(() => writeFile(CONFIG_FILE, body, "utf8"))
|
|
@@ -533,6 +629,27 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
533
629
|
if (typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs)) {
|
|
534
630
|
this._timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.floor(cfg.timeoutMs)));
|
|
535
631
|
}
|
|
632
|
+
if (Array.isArray(cfg.rules)) {
|
|
633
|
+
const rules = [];
|
|
634
|
+
for (const raw of cfg.rules) {
|
|
635
|
+
if (!raw || typeof raw !== "object") continue;
|
|
636
|
+
if (raw.effect !== "allow" && raw.effect !== "deny") continue;
|
|
637
|
+
if (typeof raw.tool !== "string" || raw.tool === "") continue;
|
|
638
|
+
if (typeof raw.match !== "string") continue;
|
|
639
|
+
rules.push({
|
|
640
|
+
id:
|
|
641
|
+
typeof raw.id === "string" && raw.id !== ""
|
|
642
|
+
? raw.id
|
|
643
|
+
: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
644
|
+
effect: raw.effect,
|
|
645
|
+
tool: raw.tool,
|
|
646
|
+
match: raw.match,
|
|
647
|
+
note: typeof raw.note === "string" ? raw.note : "",
|
|
648
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : new Date().toISOString(),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
this._rules = rules;
|
|
652
|
+
}
|
|
536
653
|
}
|
|
537
654
|
} catch (e) {
|
|
538
655
|
/* first run or unreadable config — keep the defaults */
|
|
@@ -651,7 +768,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
651
768
|
"- reading tool-owned config or logs needed to debug the task at hand.",
|
|
652
769
|
"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.",
|
|
653
770
|
"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.",
|
|
654
|
-
"
|
|
771
|
+
"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.",
|
|
655
772
|
);
|
|
656
773
|
return lines.join("\n");
|
|
657
774
|
}
|
|
@@ -733,18 +850,47 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
733
850
|
const startedAt = new Date().toISOString();
|
|
734
851
|
const t0 = Date.now();
|
|
735
852
|
const argsRaw = this._callArgsOf(session, req.callId);
|
|
736
|
-
const
|
|
853
|
+
const toolName = String(req.toolName);
|
|
737
854
|
const base = {
|
|
738
855
|
at: startedAt,
|
|
739
856
|
sessionId: shortId(session.id),
|
|
740
|
-
toolName:
|
|
857
|
+
toolName: toolName,
|
|
741
858
|
reason: trunc(req.reason, 300),
|
|
742
859
|
args: trunc(argsRaw, 2000),
|
|
743
|
-
model: route.label,
|
|
744
860
|
durationMs: 0,
|
|
745
861
|
childSessionId: "",
|
|
746
862
|
};
|
|
747
863
|
|
|
864
|
+
// 1. Deterministic rules run BEFORE the model — zero latency, zero cost.
|
|
865
|
+
// Deny beats allow (see _matchRules); both are recorded for audit.
|
|
866
|
+
const rule = this._matchRules(toolName, argsRaw);
|
|
867
|
+
if (rule !== undefined) {
|
|
868
|
+
const text =
|
|
869
|
+
(rule.effect === "deny" ? "matched deny rule" : "matched allow rule") +
|
|
870
|
+
" [tool=" + rule.tool + (rule.match !== "" ? " match=" + rule.match : "") + "]" +
|
|
871
|
+
(rule.note !== "" ? " — " + rule.note : "");
|
|
872
|
+
base.durationMs = Date.now() - t0;
|
|
873
|
+
if (rule.effect === "deny") {
|
|
874
|
+
this._record({ ...base, outcome: "rejected", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
875
|
+
return "rejected";
|
|
876
|
+
}
|
|
877
|
+
this._record({ ...base, outcome: "allowed-once", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
878
|
+
return "allowed-once";
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// 2. Session trust: a byte-identical call (same tool, same arguments JSON)
|
|
882
|
+
// the judge already approved in this session is not re-judged.
|
|
883
|
+
const trustKey = typeof argsRaw === "string" ? toolName + "\n" + argsRaw : undefined;
|
|
884
|
+
const trusted = this._trusted.get(session.id);
|
|
885
|
+
if (trustKey !== undefined && trusted !== undefined && trusted.has(trustKey)) {
|
|
886
|
+
base.durationMs = Date.now() - t0;
|
|
887
|
+
this._record({ ...base, outcome: "allowed-once", riskLevel: "-", model: "trust", rationale: "trusted: an identical operation was already approved in this session" });
|
|
888
|
+
return "allowed-once";
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// 3. The model judge.
|
|
892
|
+
const route = this._judgeRoute();
|
|
893
|
+
|
|
748
894
|
let run;
|
|
749
895
|
try {
|
|
750
896
|
run = await this.ctx.subagents.start("spawn", {
|
|
@@ -760,7 +906,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
760
906
|
persona: APPROVER_PERSONA,
|
|
761
907
|
});
|
|
762
908
|
} catch (error) {
|
|
763
|
-
this._record({ ...base, outcome: "unavailable", riskLevel: "-", rationale: "approval agent failed to start: " + errText(error) });
|
|
909
|
+
this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent failed to start: " + errText(error) });
|
|
764
910
|
return "unavailable";
|
|
765
911
|
}
|
|
766
912
|
base.childSessionId = shortId(run.id);
|
|
@@ -801,21 +947,33 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
801
947
|
...base,
|
|
802
948
|
outcome: approved ? "allowed-once" : "rejected",
|
|
803
949
|
riskLevel: String(verdict.riskLevel || "-"),
|
|
950
|
+
model: route.label,
|
|
804
951
|
rationale: trunc(verdict.rationale, 600),
|
|
805
952
|
});
|
|
953
|
+
// Trust one approved fingerprint for the rest of the session: the
|
|
954
|
+
// next byte-identical call short-circuits before the model.
|
|
955
|
+
if (approved && trustKey !== undefined) {
|
|
956
|
+
let set = this._trusted.get(session.id);
|
|
957
|
+
if (set === undefined) {
|
|
958
|
+
set = new Set();
|
|
959
|
+
this._trusted.set(session.id, set);
|
|
960
|
+
}
|
|
961
|
+
set.add(trustKey);
|
|
962
|
+
}
|
|
806
963
|
return approved ? "allowed-once" : "rejected";
|
|
807
964
|
}
|
|
808
965
|
this._record({
|
|
809
966
|
...base,
|
|
810
967
|
outcome: "unavailable",
|
|
811
968
|
riskLevel: "-",
|
|
969
|
+
model: route.label,
|
|
812
970
|
rationale:
|
|
813
971
|
"approval agent returned no valid verdict (stopReason: " + String(result.stopReason) + ")",
|
|
814
972
|
});
|
|
815
973
|
return "unavailable";
|
|
816
974
|
}
|
|
817
975
|
if (winner.kind === "aborted") {
|
|
818
|
-
this._record({ ...base, outcome: "cancelled", riskLevel: "-", rationale: "request cancelled while the approval agent was judging" });
|
|
976
|
+
this._record({ ...base, outcome: "cancelled", riskLevel: "-", model: route.label, rationale: "request cancelled while the approval agent was judging" });
|
|
819
977
|
return "cancelled";
|
|
820
978
|
}
|
|
821
979
|
if (winner.kind === "timeout") {
|
|
@@ -823,16 +981,50 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
823
981
|
...base,
|
|
824
982
|
outcome: "unavailable",
|
|
825
983
|
riskLevel: "-",
|
|
984
|
+
model: route.label,
|
|
826
985
|
rationale: "approval agent timed out after " + String(this._timeoutMs) + "ms (fail closed)",
|
|
827
986
|
});
|
|
828
987
|
return "unavailable";
|
|
829
988
|
}
|
|
830
|
-
this._record({ ...base, outcome: "unavailable", riskLevel: "-", rationale: "approval agent infrastructure fault: " + errText(winner.error) });
|
|
989
|
+
this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent infrastructure fault: " + errText(winner.error) });
|
|
831
990
|
return "unavailable";
|
|
832
991
|
}
|
|
833
992
|
|
|
834
993
|
// ---- Remote API ------------------------------------------------------------
|
|
835
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Display info for every enabled session: the same log-backed title the
|
|
997
|
+
* session list shows (via the optional `sessionTitle` service) plus the
|
|
998
|
+
* workspace cwd, so the Settings chips are recognizable. Every read is a
|
|
999
|
+
* best-effort leaf read on owned plain objects; a disposed agent, an absent
|
|
1000
|
+
* title service, or a missing header field degrades to "".
|
|
1001
|
+
*/
|
|
1002
|
+
_sessionInfos() {
|
|
1003
|
+
const titles = this.ctx.get("sessionTitle");
|
|
1004
|
+
const out = [];
|
|
1005
|
+
for (const sid of this._enabled.keys()) {
|
|
1006
|
+
let title = "";
|
|
1007
|
+
let cwd = "";
|
|
1008
|
+
const agent = this.ctx.agents.get(sid);
|
|
1009
|
+
const session = agent === undefined ? undefined : agent.session;
|
|
1010
|
+
if (session !== undefined) {
|
|
1011
|
+
try {
|
|
1012
|
+
const snap = titles === undefined ? undefined : titles.get(session);
|
|
1013
|
+
if (snap && typeof snap.title === "string") title = snap.title;
|
|
1014
|
+
} catch (e) {
|
|
1015
|
+
/* title read is best-effort */
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
if (session.header && typeof session.header.cwd === "string") cwd = session.header.cwd;
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
/* header access is best-effort */
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
out.push({ id: String(sid), title: title, cwd: cwd });
|
|
1024
|
+
}
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
836
1028
|
/** Snapshot for the Settings page. */
|
|
837
1029
|
async getState() {
|
|
838
1030
|
return {
|
|
@@ -840,7 +1032,8 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
840
1032
|
value: {
|
|
841
1033
|
model: { provider: this._model.provider, model: this._model.model },
|
|
842
1034
|
timeoutMs: this._timeoutMs,
|
|
843
|
-
enabledSessions:
|
|
1035
|
+
enabledSessions: this._sessionInfos(),
|
|
1036
|
+
rules: this._rulesSnapshot(),
|
|
844
1037
|
records: this._records.slice(-50).reverse(),
|
|
845
1038
|
},
|
|
846
1039
|
};
|
|
@@ -885,6 +1078,47 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
885
1078
|
return { ok: true, value: { message: this._setEnabled(agent, want) } };
|
|
886
1079
|
}
|
|
887
1080
|
|
|
1081
|
+
/**
|
|
1082
|
+
* Add one deterministic rule and persist the table. `tool` is an exact tool
|
|
1083
|
+
* name or "*" for every tool; `match` is "" (every call of that tool), a
|
|
1084
|
+
* plain substring, or "/pattern/flags" tested against the raw arguments
|
|
1085
|
+
* JSON. Returns the full table.
|
|
1086
|
+
*/
|
|
1087
|
+
async addRule(request) {
|
|
1088
|
+
const effect = request && request.effect === "deny" ? "deny" : "allow";
|
|
1089
|
+
const tool = request && typeof request.tool === "string" ? request.tool.trim() : "";
|
|
1090
|
+
const match = request && typeof request.match === "string" ? request.match : "";
|
|
1091
|
+
const note = request && typeof request.note === "string" ? trunc(request.note, 200) : "";
|
|
1092
|
+
if (tool === "") {
|
|
1093
|
+
return { ok: false, error: { code: "invalid-rule", message: 'tool is required ("*" matches every tool)' } };
|
|
1094
|
+
}
|
|
1095
|
+
if (this._ruleRegex(match) === null) {
|
|
1096
|
+
return { ok: false, error: { code: "invalid-rule", message: "invalid /regex/flags match expression" } };
|
|
1097
|
+
}
|
|
1098
|
+
this._rules.push({
|
|
1099
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
|
1100
|
+
effect: effect,
|
|
1101
|
+
tool: tool,
|
|
1102
|
+
match: match,
|
|
1103
|
+
note: note,
|
|
1104
|
+
createdAt: new Date().toISOString(),
|
|
1105
|
+
});
|
|
1106
|
+
this._persistConfig();
|
|
1107
|
+
return { ok: true, value: { rules: this._rulesSnapshot() } };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Remove one rule by id and persist the table. Returns the full table. */
|
|
1111
|
+
async removeRule(request) {
|
|
1112
|
+
const id = request && typeof request.id === "string" ? request.id : "";
|
|
1113
|
+
const before = this._rules.length;
|
|
1114
|
+
this._rules = this._rules.filter((r) => r.id !== id);
|
|
1115
|
+
if (this._rules.length === before) {
|
|
1116
|
+
return { ok: false, error: { code: "rule-not-found", message: "no rule with that id" } };
|
|
1117
|
+
}
|
|
1118
|
+
this._persistConfig();
|
|
1119
|
+
return { ok: true, value: { rules: this._rulesSnapshot() } };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
888
1122
|
/** Clear the audit records (memory + persisted file). */
|
|
889
1123
|
async clearRecords() {
|
|
890
1124
|
this._records = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duke-dsh-plugins/dsh-agent-approval",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Agent-decided approvals for DeepSeek Harness: a workspace-write base permission mode where an independent approval subagent judges every sandbox escalation (risky operations are rejected), with a configurable approval model and an audit log in Settings.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
package/typert.host.js
CHANGED
|
@@ -41,11 +41,31 @@ const modelRouteSchema = z
|
|
|
41
41
|
})
|
|
42
42
|
.readonly();
|
|
43
43
|
|
|
44
|
+
const enabledSessionSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
title: z.string(),
|
|
48
|
+
cwd: z.string(),
|
|
49
|
+
})
|
|
50
|
+
.readonly();
|
|
51
|
+
|
|
52
|
+
const ruleSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
id: z.string(),
|
|
55
|
+
effect: z.enum(["allow", "deny"]),
|
|
56
|
+
tool: z.string(),
|
|
57
|
+
match: z.string(),
|
|
58
|
+
note: z.string(),
|
|
59
|
+
createdAt: z.string(),
|
|
60
|
+
})
|
|
61
|
+
.readonly();
|
|
62
|
+
|
|
44
63
|
const stateValueSchema = z
|
|
45
64
|
.object({
|
|
46
65
|
model: modelRouteSchema,
|
|
47
66
|
timeoutMs: z.number(),
|
|
48
|
-
enabledSessions: z.array(
|
|
67
|
+
enabledSessions: z.array(enabledSessionSchema).readonly(),
|
|
68
|
+
rules: z.array(ruleSchema).readonly(),
|
|
49
69
|
records: z.array(recordSchema).readonly(),
|
|
50
70
|
})
|
|
51
71
|
.readonly();
|
|
@@ -68,6 +88,12 @@ const toggleValueSchema = z
|
|
|
68
88
|
})
|
|
69
89
|
.readonly();
|
|
70
90
|
|
|
91
|
+
const rulesValueSchema = z
|
|
92
|
+
.object({
|
|
93
|
+
rules: z.array(ruleSchema).readonly(),
|
|
94
|
+
})
|
|
95
|
+
.readonly();
|
|
96
|
+
|
|
71
97
|
const clearRecordsValueSchema = z
|
|
72
98
|
.object({
|
|
73
99
|
cleared: z.boolean(),
|
|
@@ -128,6 +154,8 @@ const stateResultSchema = okResult(stateValueSchema);
|
|
|
128
154
|
const setModelResultSchema = okResult(setModelValueSchema);
|
|
129
155
|
const setTimeoutResultSchema = okResult(setTimeoutValueSchema);
|
|
130
156
|
const toggleResultSchema = okResult(toggleValueSchema);
|
|
157
|
+
const addRuleResultSchema = okResult(rulesValueSchema);
|
|
158
|
+
const removeRuleResultSchema = okResult(rulesValueSchema);
|
|
131
159
|
const clearRecordsResultSchema = okResult(clearRecordsValueSchema);
|
|
132
160
|
const directoryResultSchema = okResult(directoryValueSchema);
|
|
133
161
|
|
|
@@ -147,6 +175,17 @@ const _agentApproval_toggle_parameter_0$schema = z.object({
|
|
|
147
175
|
on: z.boolean(),
|
|
148
176
|
});
|
|
149
177
|
|
|
178
|
+
const _agentApproval_addRule_parameter_0$schema = z.object({
|
|
179
|
+
effect: z.enum(["allow", "deny"]),
|
|
180
|
+
tool: z.string(),
|
|
181
|
+
match: z.string(),
|
|
182
|
+
note: z.string(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const _agentApproval_removeRule_parameter_0$schema = z.object({
|
|
186
|
+
id: z.string(),
|
|
187
|
+
});
|
|
188
|
+
|
|
150
189
|
export const TYPERT = {
|
|
151
190
|
package: "@duke-dsh-plugins/dsh-agent-approval",
|
|
152
191
|
face: "host",
|
|
@@ -241,6 +280,56 @@ export const TYPERT = {
|
|
|
241
280
|
},
|
|
242
281
|
sourceLocation: { file: "index.js", line: 1, column: 1 },
|
|
243
282
|
},
|
|
283
|
+
{
|
|
284
|
+
id: "dsh-agent-approval#agentApproval/addRule",
|
|
285
|
+
service: "agentApproval",
|
|
286
|
+
namespace: "agentApproval",
|
|
287
|
+
method: "addRule",
|
|
288
|
+
invocation: { kind: "direct" },
|
|
289
|
+
parameters: [
|
|
290
|
+
{
|
|
291
|
+
name: "request",
|
|
292
|
+
wire: "request",
|
|
293
|
+
source: "json",
|
|
294
|
+
codec: {
|
|
295
|
+
mode: "strict",
|
|
296
|
+
typeSymbol: "dsh-agent-approval#AgentApprovalAddRuleRequest",
|
|
297
|
+
schema: _agentApproval_addRule_parameter_0$schema,
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
result: {
|
|
302
|
+
mode: "strict",
|
|
303
|
+
typeSymbol: "dsh-agent-approval#AgentApprovalRulesResult",
|
|
304
|
+
schema: addRuleResultSchema,
|
|
305
|
+
},
|
|
306
|
+
sourceLocation: { file: "index.js", line: 1, column: 1 },
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
id: "dsh-agent-approval#agentApproval/removeRule",
|
|
310
|
+
service: "agentApproval",
|
|
311
|
+
namespace: "agentApproval",
|
|
312
|
+
method: "removeRule",
|
|
313
|
+
invocation: { kind: "direct" },
|
|
314
|
+
parameters: [
|
|
315
|
+
{
|
|
316
|
+
name: "request",
|
|
317
|
+
wire: "request",
|
|
318
|
+
source: "json",
|
|
319
|
+
codec: {
|
|
320
|
+
mode: "strict",
|
|
321
|
+
typeSymbol: "dsh-agent-approval#AgentApprovalRemoveRuleRequest",
|
|
322
|
+
schema: _agentApproval_removeRule_parameter_0$schema,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
result: {
|
|
327
|
+
mode: "strict",
|
|
328
|
+
typeSymbol: "dsh-agent-approval#AgentApprovalRulesResult",
|
|
329
|
+
schema: removeRuleResultSchema,
|
|
330
|
+
},
|
|
331
|
+
sourceLocation: { file: "index.js", line: 1, column: 1 },
|
|
332
|
+
},
|
|
244
333
|
{
|
|
245
334
|
id: "dsh-agent-approval#agentApproval/clearRecords",
|
|
246
335
|
service: "agentApproval",
|
|
@@ -288,7 +377,7 @@ export const TYPERT = {
|
|
|
288
377
|
signature: "@Remote('getState') async getState(): Promise<AgentApprovalStateResult>",
|
|
289
378
|
summary: "Snapshot for the Settings page and the composer toggle.",
|
|
290
379
|
jsDoc:
|
|
291
|
-
"/**\n * Return the judge model route, timeout, enabled session
|
|
380
|
+
"/**\n * Return the judge model route, timeout, enabled sessions (id + session-list title + workspace cwd), and the latest audit records.\n * @returns success or a business failure.\n */",
|
|
292
381
|
},
|
|
293
382
|
{
|
|
294
383
|
kind: "method",
|
|
@@ -314,6 +403,22 @@ export const TYPERT = {
|
|
|
314
403
|
jsDoc:
|
|
315
404
|
"/**\n * Enable (workspace-write base + judged escalations) or disable (restore prior knobs) for one live session.\n * @param request - { sessionId, on }.\n * @returns a human-readable message, or session-not-live.\n */",
|
|
316
405
|
},
|
|
406
|
+
{
|
|
407
|
+
kind: "method",
|
|
408
|
+
name: "addRule",
|
|
409
|
+
signature: "@Remote('addRule') async addRule(request: AgentApprovalAddRuleRequest): Promise<AgentApprovalRulesResult>",
|
|
410
|
+
summary: "Add a deterministic allow/deny rule judged before the model (persisted).",
|
|
411
|
+
jsDoc:
|
|
412
|
+
"/**\n * Add one rule: tool = exact name or \"*\"; match = \"\" (every call), substring, or /regex/flags over the raw arguments JSON. Deny is evaluated before allow.\n * @param request - { effect, tool, match, note }.\n * @returns the full rule table.\n */",
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
kind: "method",
|
|
416
|
+
name: "removeRule",
|
|
417
|
+
signature: "@Remote('removeRule') async removeRule(request: AgentApprovalRemoveRuleRequest): Promise<AgentApprovalRulesResult>",
|
|
418
|
+
summary: "Remove one rule by id (persisted).",
|
|
419
|
+
jsDoc:
|
|
420
|
+
"/**\n * Remove one rule by id.\n * @param request - { id }.\n * @returns the full rule table.\n */",
|
|
421
|
+
},
|
|
317
422
|
{
|
|
318
423
|
kind: "method",
|
|
319
424
|
name: "clearRecords",
|
|
@@ -342,10 +447,25 @@ export const TYPERT = {
|
|
|
342
447
|
declaration:
|
|
343
448
|
"export interface AgentApprovalModelRoute {\n readonly provider: string;\n readonly model: string;\n}",
|
|
344
449
|
},
|
|
450
|
+
{
|
|
451
|
+
name: "AgentApprovalEnabledSession",
|
|
452
|
+
declaration:
|
|
453
|
+
"export interface AgentApprovalEnabledSession {\n readonly id: string;\n readonly title: string;\n readonly cwd: string;\n}",
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: "AgentApprovalRule",
|
|
457
|
+
declaration:
|
|
458
|
+
"export interface AgentApprovalRule {\n readonly id: string;\n readonly effect: 'allow' | 'deny';\n readonly tool: string;\n readonly match: string;\n readonly note: string;\n readonly createdAt: string;\n}",
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: "AgentApprovalRulesResult",
|
|
462
|
+
declaration:
|
|
463
|
+
"export type AgentApprovalRulesResult = { ok: true; value: { readonly rules: readonly AgentApprovalRule[] } } | { ok: false; error: { code: string; message?: string } };",
|
|
464
|
+
},
|
|
345
465
|
{
|
|
346
466
|
name: "AgentApprovalStateValue",
|
|
347
467
|
declaration:
|
|
348
|
-
"export interface AgentApprovalStateValue {\n readonly model: AgentApprovalModelRoute;\n readonly timeoutMs: number;\n readonly enabledSessions: readonly
|
|
468
|
+
"export interface AgentApprovalStateValue {\n readonly model: AgentApprovalModelRoute;\n readonly timeoutMs: number;\n readonly enabledSessions: readonly AgentApprovalEnabledSession[];\n readonly rules: readonly AgentApprovalRule[];\n readonly records: readonly AgentApprovalRecord[];\n}",
|
|
349
469
|
},
|
|
350
470
|
{
|
|
351
471
|
name: "AgentApprovalStateResult",
|