@duke-dsh-plugins/dsh-agent-approval 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/client.js +186 -95
- package/index.js +172 -81
- package/package.json +2 -2
- package/typert.host.js +47 -17
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
| ⛔ 风险即拒绝 | 破坏性 / 不可逆 / 越界(含修改操作系统或其他应用数据)/ 理由与实际命令不符 → 直接 `reject`;仅"安全、可逆、与任务相符、理由诚实"才 `approve`——项目自身的安装/部署脚本写其文档指定路径属任务所需 |
|
|
27
27
|
| 🔒 Fail-closed | 审批 Agent 启动失败、超时(可配 30s–600s)、结果不合法 → 一律按拒绝处理,绝不静默放行 |
|
|
28
28
|
| ⚙️ 审批模型可配置 | 设置页选择 Provider + Model,不选则固定用 **Harness 默认模型**(不跟随请求会话,口径稳定);选择与超时**持久保存**,重启不丢 |
|
|
29
|
-
| 📋
|
|
29
|
+
| 📋 审计记录(随会话) | 会话窗口顶部的**「审批」标签页**(轨迹旁)查看本会话全部审批:结论 / 风险等级 / 模型 / 耗时 / 理由;悬停看完整理由与**精确工具参数**;审批 Agent 的会话 id 可回溯完整推理;已批准行可一键**「加白」**存为放行规则。记录存在**会话存储目录内的独立文件**——随会话恢复,删除会话即随之删除 |
|
|
30
30
|
| 🔁 可逆开关 | 权限菜单「Agent 审批」预设、`/agent-approval on\|off` 命令两条等价路径;关闭时**恢复开启前的权限旋钮** |
|
|
31
31
|
|
|
32
32
|
## 工作原理
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
├─ approve → allowed-once(该次放行)
|
|
43
43
|
├─ reject → rejected(风险操作,最终拒绝)
|
|
44
44
|
└─ 超时/故障/取消 → fail-closed(按拒绝处理)
|
|
45
|
-
└─
|
|
45
|
+
└─ 记入审计(写入会话日志,「审批」标签页可见)
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
- 审批 Agent 只能看到:workspace 路径、**最近的用户消息**(任务上下文)、工具名、提权理由、**精确的工具参数 JSON**(按 `callId` 从会话日志回查)。裁决看"操作 vs 用户任务"的客观对齐,不依赖理由措辞。
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
dsh plugin --profile web add /path/to/dsh-agent-approval
|
|
61
61
|
|
|
62
62
|
# 正式发布:从 GitHub Release tarball 安装
|
|
63
|
-
dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-agent-approval/releases/download/v1.
|
|
63
|
+
dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-agent-approval/releases/download/v1.5.0/dsh-agent-approval-1.5.0.tgz
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
重启 DSH 后:设置面板出现 **Agent 审批** 页;`/permission` 菜单出现第四项 **Agent 审批**。
|
|
@@ -76,8 +76,8 @@ dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-agent-app
|
|
|
76
76
|
|
|
77
77
|
1. **开启**:在 `/permission` 菜单选 **Agent 审批**,或输入 `/agent-approval on`。
|
|
78
78
|
2. **自动裁决**:之后该会话里的提权请求(例如命令被沙箱拒绝后带 `sandbox_permissions` 的重试)不再弹窗,由审批 Agent 在后台裁决并放行/拒绝。
|
|
79
|
-
3.
|
|
80
|
-
4.
|
|
79
|
+
3. **审计**:会话窗口顶部的**「审批」标签页**(轨迹旁)查看本会话的审批记录;悬停"审批理由"看完整理由与工具参数;已批准行可「加白」存为放行规则。记录存在会话存储目录内的独立文件,删除会话即随之删除;v1.4 的旧全局记录用 `node scripts/migrate-records.mjs` 一次性迁移(`--dry-run` 预览)。
|
|
80
|
+
4. **配置**:设置 → **Agent 审批** 设置审批模型(不选则用 Harness 默认模型)、审批超时与放行/拒绝规则。
|
|
81
81
|
5. **关闭**:菜单切回其他预设,或 `/agent-approval off`,恢复开启前的沙箱模式与审批策略。
|
|
82
82
|
|
|
83
83
|
## 目录结构
|
package/client.js
CHANGED
|
@@ -3,13 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Rendered by the DSH web shell via `window.__ModuleLoader__.load`. Adds:
|
|
5
5
|
*
|
|
6
|
-
* 1.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* 1. An「审批」tab in the conversation window's view ring
|
|
7
|
+
* (`conversation.view`, right next to 轨迹): the per-session approval
|
|
8
|
+
* audit trail. The Host folds the records out of a sidecar file inside
|
|
9
|
+
* the session's OWN persistence directory
|
|
10
|
+
* (`<sessionDir>/agent-approval.jsonl`), so the audit follows the
|
|
11
|
+
* session — restored with it after a restart, gone when the session is
|
|
12
|
+
* deleted. Rows offer the one-click「加白」rule shortcut.
|
|
13
|
+
*
|
|
14
|
+
* 2. A "Agent 审批" page in the Settings panel (`settings.section`):
|
|
15
|
+
* approval model picker (provider + model, or the harness default),
|
|
16
|
+
* judge timeout setting (fail-closed), the list of sessions with the
|
|
17
|
+
* mode enabled (session-list title + workspace), and the allow/deny
|
|
18
|
+
* rule table.
|
|
13
19
|
*
|
|
14
20
|
* Session-level on/off lives in the /permission menu (the "Agent 审批"
|
|
15
21
|
* preset, registered by the package's cordis.patch.yml bundle patch) and the
|
|
@@ -57,6 +63,16 @@ window.__ModuleLoader__.load({
|
|
|
57
63
|
.aapr-whitelist{color:var(--dsw-alias-label-secondary)}
|
|
58
64
|
.aapr-whitelist:hover{color:var(--dsw-alias-label-primary)}
|
|
59
65
|
|
|
66
|
+
/* Conversation-window「审批」tab: fills the view area below the tab strip
|
|
67
|
+
with the same token family as the Settings cards, so the audit ledger
|
|
68
|
+
reads native beside 轨迹. */
|
|
69
|
+
.aapr-view{height:100%;overflow:auto;padding:12px 16px 16px;color:var(--dsw-alias-label-primary);font-size:13px}
|
|
70
|
+
.aapr-view-inner{display:flex;flex-direction:column;gap:10px;max-width:980px;margin:0 auto}
|
|
71
|
+
.aapr-view-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
72
|
+
.aapr-view-title{font-size:13px;font-weight:600;margin:0}
|
|
73
|
+
.aapr-state-on{color:var(--dsw-alias-state-success-primary)}
|
|
74
|
+
.aapr-state-off{color:var(--dsw-alias-label-secondary)}
|
|
75
|
+
|
|
60
76
|
/* Settings nav icon: DSH 0.1.x settings.section only projects id/order/
|
|
61
77
|
label, and the settings shell paints a generic gear for every external
|
|
62
78
|
section (client-ui-settings-general's navIcon()). registerSettingsNavIcon
|
|
@@ -277,13 +293,13 @@ window.__ModuleLoader__.load({
|
|
|
277
293
|
result: result("dsh-agent-approval#AgentApprovalRulesResult"),
|
|
278
294
|
},
|
|
279
295
|
{
|
|
280
|
-
id: "dsh-agent-approval#agentApproval/
|
|
296
|
+
id: "dsh-agent-approval#agentApproval/sessionRecords",
|
|
281
297
|
service: "agentApproval",
|
|
282
298
|
namespace: "agentApproval",
|
|
283
|
-
method: "
|
|
299
|
+
method: "sessionRecords",
|
|
284
300
|
invocation: { kind: "direct" },
|
|
285
|
-
parameters:
|
|
286
|
-
result: result("dsh-agent-approval#
|
|
301
|
+
parameters: param("dsh-agent-approval#AgentApprovalSessionRecordsRequest"),
|
|
302
|
+
result: result("dsh-agent-approval#AgentApprovalSessionRecordsResult"),
|
|
287
303
|
},
|
|
288
304
|
{
|
|
289
305
|
id: "dsh-agent-approval#agentApproval/directory",
|
|
@@ -463,12 +479,6 @@ window.__ModuleLoader__.load({
|
|
|
463
479
|
.then(refresh)
|
|
464
480
|
.catch(() => {});
|
|
465
481
|
};
|
|
466
|
-
const clearRecords = () => {
|
|
467
|
-
remote
|
|
468
|
-
.clearRecords()
|
|
469
|
-
.then(refresh)
|
|
470
|
-
.catch(() => {});
|
|
471
|
-
};
|
|
472
482
|
|
|
473
483
|
const ruleEffect = ruleEffectSlot[0];
|
|
474
484
|
const ruleTool = ruleToolSlot[0];
|
|
@@ -509,18 +519,6 @@ window.__ModuleLoader__.load({
|
|
|
509
519
|
})
|
|
510
520
|
.catch(() => {});
|
|
511
521
|
};
|
|
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
522
|
|
|
525
523
|
const providerOptions = [{ id: "", name: "默认(Harness 默认模型)" }].concat(
|
|
526
524
|
dir ? dir.providers : [],
|
|
@@ -548,8 +546,9 @@ window.__ModuleLoader__.load({
|
|
|
548
546
|
{ className: "aapr-muted" },
|
|
549
547
|
"一种新的权限模式:以 workspace-write 为基线沙箱;当工具请求提权(更宽的沙箱)时,由一个独立的审批 Agent 评估风险——安全、可逆、与任务相符的操作自动批准,破坏性、不可逆、越界或理由不符的操作直接拒绝。",
|
|
550
548
|
h("br", null),
|
|
551
|
-
"在输入框 /permission 菜单选择「Agent 审批」预设,或执行命令 /agent-approval on|off
|
|
549
|
+
"在输入框 /permission 菜单选择「Agent 审批」预设,或执行命令 /agent-approval on|off 为会话开启;每个会话的审批审计记录在该会话窗口顶部的「审批」标签页(轨迹旁),随会话保存。",
|
|
552
550
|
),
|
|
551
|
+
note !== "" ? h("div", { className: "aapr-muted" }, note) : null,
|
|
553
552
|
),
|
|
554
553
|
h(
|
|
555
554
|
"div",
|
|
@@ -703,84 +702,164 @@ window.__ModuleLoader__.load({
|
|
|
703
702
|
h(ui.Button, { variant: "primary", size: "sm", onClick: submitRule }, "添加"),
|
|
704
703
|
),
|
|
705
704
|
),
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The conversation-window「审批」tab (`conversation.view`, next to
|
|
710
|
+
* 轨迹): this session's audit trail, folded by the Host out of the
|
|
711
|
+
* session's own sidecar storage. Rendered only while the tab is
|
|
712
|
+
* active, so the 10s poll costs nothing otherwise. Session-scoped
|
|
713
|
+
* slot: the runtime hands us the standard `useSession` hook; the
|
|
714
|
+
* snapshot's `sessionId` leaf is the only field we read.
|
|
715
|
+
*/
|
|
716
|
+
function ApprovalAuditView(props) {
|
|
717
|
+
const useSession = props.useSession;
|
|
718
|
+
const session = useSession(function (s) { return s; });
|
|
719
|
+
const sessionId = session && session.sessionId ? String(session.sessionId) : "";
|
|
720
|
+
|
|
721
|
+
const stateSlot = React.useState(null); // { records, enabled } | null
|
|
722
|
+
const state = stateSlot[0];
|
|
723
|
+
const setState = stateSlot[1];
|
|
724
|
+
const noteSlot = React.useState("");
|
|
725
|
+
const note = noteSlot[0];
|
|
726
|
+
const setNote = noteSlot[1];
|
|
727
|
+
const tickSlot = React.useState(0); // manual-refresh counter
|
|
728
|
+
const tick = tickSlot[0];
|
|
729
|
+
const setTick = tickSlot[1];
|
|
730
|
+
|
|
731
|
+
React.useEffect(() => {
|
|
732
|
+
if (sessionId === "") return undefined;
|
|
733
|
+
let alive = true;
|
|
734
|
+
const load = () => {
|
|
735
|
+
if (typeof remote.sessionRecords !== "function") {
|
|
736
|
+
if (alive) setNote("Host 半未更新(缺少 sessionRecords):请重装本插件并重启 DSH。");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
remote
|
|
740
|
+
.sessionRecords({ sessionId: sessionId })
|
|
741
|
+
.then((res) => {
|
|
742
|
+
if (alive) setState(pick(res));
|
|
743
|
+
})
|
|
744
|
+
.catch((e) => {
|
|
745
|
+
if (alive) setNote("无法读取审批记录:" + (e && e.message ? e.message : String(e)));
|
|
746
|
+
});
|
|
747
|
+
};
|
|
748
|
+
load();
|
|
749
|
+
const timer = setInterval(load, 10000);
|
|
750
|
+
return () => {
|
|
751
|
+
alive = false;
|
|
752
|
+
clearInterval(timer);
|
|
753
|
+
};
|
|
754
|
+
}, [sessionId, tick]);
|
|
755
|
+
|
|
756
|
+
// One-click whitelist from an audit row: the recorded args are a
|
|
757
|
+
// PREFIX of the real arguments JSON (the Host truncates at 2000
|
|
758
|
+
// chars), so stripping the truncation marker keeps a valid substring.
|
|
759
|
+
const whitelistRecord = (r) => {
|
|
760
|
+
const args = String(r.args || "").replace(/…\[truncated\]$/, "");
|
|
761
|
+
remote
|
|
762
|
+
.addRule({
|
|
763
|
+
effect: "allow",
|
|
764
|
+
tool: String(r.toolName),
|
|
765
|
+
match: args,
|
|
766
|
+
note: "来自审批审计 " + fmtTime(r.at),
|
|
767
|
+
})
|
|
768
|
+
.then(() => setNote("已加白:今后该操作直接放行,不再经过审批模型。"))
|
|
769
|
+
.catch((e) => setNote("加白失败:" + (e && e.message ? e.message : String(e))));
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const records = state !== null && Array.isArray(state.records) ? state.records : [];
|
|
773
|
+
// 倒序展示(最新在最上);Host 仍按时间正序返回,顺序属于视图层。
|
|
774
|
+
const ordered = records.slice().reverse();
|
|
775
|
+
const enabled = state !== null ? !!state.enabled : null;
|
|
776
|
+
|
|
777
|
+
return h(
|
|
778
|
+
"div",
|
|
779
|
+
{ className: "aapr-view" },
|
|
706
780
|
h(
|
|
707
781
|
"div",
|
|
708
|
-
{ className: "aapr-
|
|
709
|
-
h("h3", null, "审批记录(最近 50 条,本地持久化保留 200 条,重启不丢)"),
|
|
782
|
+
{ className: "aapr-view-inner" },
|
|
710
783
|
h(
|
|
711
784
|
"div",
|
|
712
|
-
{ className: "aapr-
|
|
713
|
-
h(
|
|
714
|
-
|
|
785
|
+
{ className: "aapr-view-head" },
|
|
786
|
+
h("h3", { className: "aapr-view-title" }, "审批审计(本会话)"),
|
|
787
|
+
enabled === true
|
|
788
|
+
? h("span", { className: "aapr-state-on" }, "● Agent 审批已开启")
|
|
789
|
+
: enabled === false
|
|
790
|
+
? h("span", { className: "aapr-state-off" }, "○ Agent 审批未开启")
|
|
791
|
+
: null,
|
|
792
|
+
h(ui.Button, { variant: "ghost", size: "sm", onClick: () => setTick(tick + 1) }, "刷新"),
|
|
715
793
|
note !== "" ? h("span", { className: "aapr-muted" }, note) : null,
|
|
716
794
|
),
|
|
717
795
|
h(
|
|
718
796
|
"div",
|
|
719
|
-
{ className: "aapr-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
"
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
state !== null && state.records
|
|
736
|
-
? state.records.map((r, i) =>
|
|
797
|
+
{ className: "aapr-muted" },
|
|
798
|
+
"审批结论随会话保存(会话目录内的独立记录文件):随会话恢复,删除会话即随之删除,最新记录在最上。悬停「审批理由」可查看完整理由与工具参数;「审批会话」前缀可在会话列表中找到审批 Agent 的完整会话记录。",
|
|
799
|
+
),
|
|
800
|
+
sessionId === ""
|
|
801
|
+
? h("div", { className: "aapr-muted" }, "当前没有活动会话。")
|
|
802
|
+
: records.length === 0
|
|
803
|
+
? h("div", { className: "aapr-muted" }, "本会话暂无审批记录。")
|
|
804
|
+
: h(
|
|
805
|
+
"div",
|
|
806
|
+
{ className: "aapr-wrap" },
|
|
807
|
+
h(
|
|
808
|
+
"table",
|
|
809
|
+
{ className: "aapr-table" },
|
|
810
|
+
h(
|
|
811
|
+
"thead",
|
|
812
|
+
null,
|
|
737
813
|
h(
|
|
738
814
|
"tr",
|
|
739
|
-
|
|
740
|
-
h("
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
h(
|
|
748
|
-
"td",
|
|
749
|
-
{
|
|
750
|
-
className: "aapr-cell",
|
|
751
|
-
title:
|
|
752
|
-
(r.rationale || "") +
|
|
753
|
-
(r.args ? "\n\n工具参数:" + r.args : "") +
|
|
754
|
-
(r.childSessionId ? "\n\n审批会话:" + r.childSessionId : ""),
|
|
755
|
-
},
|
|
756
|
-
truncText(r.rationale, 110),
|
|
757
|
-
),
|
|
815
|
+
null,
|
|
816
|
+
["时间", "工具", "结果", "风险", "模型", "耗时", "审批理由", "规则"].map((t) => h("th", { key: t }, t)),
|
|
817
|
+
),
|
|
818
|
+
),
|
|
819
|
+
h(
|
|
820
|
+
"tbody",
|
|
821
|
+
null,
|
|
822
|
+
ordered.map((r, i) =>
|
|
758
823
|
h(
|
|
759
|
-
"
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
824
|
+
"tr",
|
|
825
|
+
{ key: String(r.at) + "-" + String(i) },
|
|
826
|
+
h("td", null, fmtTime(r.at)),
|
|
827
|
+
h("td", null, String(r.toolName)),
|
|
828
|
+
h("td", { className: r.outcome === "allowed-once" ? "aapr-ok" : "aapr-no" }, OUTCOME_LABEL[r.outcome] || String(r.outcome)),
|
|
829
|
+
h("td", null, RISK_LABEL[r.riskLevel] || String(r.riskLevel || "-")),
|
|
830
|
+
h("td", null, String(r.model)),
|
|
831
|
+
h("td", null, String(r.durationMs) + "ms"),
|
|
832
|
+
h(
|
|
833
|
+
"td",
|
|
834
|
+
{
|
|
835
|
+
className: "aapr-cell",
|
|
836
|
+
title:
|
|
837
|
+
(r.rationale || "") +
|
|
838
|
+
(r.args ? "\n\n工具参数:" + r.args : "") +
|
|
839
|
+
(r.childSessionId ? "\n\n审批会话:" + r.childSessionId : ""),
|
|
840
|
+
},
|
|
841
|
+
truncText(r.rationale, 110),
|
|
842
|
+
),
|
|
843
|
+
h(
|
|
844
|
+
"td",
|
|
845
|
+
null,
|
|
846
|
+
r.outcome === "allowed-once" && r.args && r.model !== "rule"
|
|
847
|
+
? h(
|
|
848
|
+
"button",
|
|
849
|
+
{
|
|
850
|
+
className: "aapr-whitelist",
|
|
851
|
+
onClick: () => whitelistRecord(r),
|
|
852
|
+
title: "把该操作存为放行规则(工具 + 参数子串):今后直接放行,不再经过审批模型",
|
|
853
|
+
},
|
|
854
|
+
"加白",
|
|
855
|
+
)
|
|
856
|
+
: null,
|
|
857
|
+
),
|
|
772
858
|
),
|
|
773
859
|
),
|
|
774
|
-
)
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
),
|
|
778
|
-
),
|
|
779
|
-
h(
|
|
780
|
-
"div",
|
|
781
|
-
{ className: "aapr-muted" },
|
|
782
|
-
"悬停“审批理由”可查看完整理由与工具参数;“审批会话”前缀可在会话列表中找到审批 Agent 的完整会话记录。「加白」把一条已批准的操作存为放行规则(模型列显示 rule/trust 的行分别来自规则命中与会话内信任缓存)。",
|
|
783
|
-
),
|
|
860
|
+
),
|
|
861
|
+
),
|
|
862
|
+
),
|
|
784
863
|
),
|
|
785
864
|
);
|
|
786
865
|
}
|
|
@@ -792,6 +871,18 @@ window.__ModuleLoader__.load({
|
|
|
792
871
|
AgentApprovalSection,
|
|
793
872
|
),
|
|
794
873
|
);
|
|
874
|
+
|
|
875
|
+
// Conversation entry: the「审批」audit tab in the view ring, right
|
|
876
|
+
// beside 轨迹 (chat order 0, trajectory order 10, audit order 11).
|
|
877
|
+
// Session-scoped: renders per conversation with the session's own
|
|
878
|
+
// records; registration rides the slot ledger so plugin unload
|
|
879
|
+
// removes the tab.
|
|
880
|
+
ctx.slots.inject("conversation.view", () =>
|
|
881
|
+
ctx.slots.register(
|
|
882
|
+
{ name: "conversation.view", id: "agent-approval-audit", order: 11, label: () => "审批" },
|
|
883
|
+
ApprovalAuditView,
|
|
884
|
+
),
|
|
885
|
+
);
|
|
795
886
|
}
|
|
796
887
|
|
|
797
888
|
exports.apply = apply;
|
package/index.js
CHANGED
|
@@ -36,10 +36,18 @@
|
|
|
36
36
|
* cancellation maps to the fail-closed approval outcomes
|
|
37
37
|
* (`unavailable` / `cancelled`), never to a grant.
|
|
38
38
|
*
|
|
39
|
-
* 4. AUDIT — every decision is
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the session
|
|
39
|
+
* 4. AUDIT — every decision is appended to a SIDECAR file inside the
|
|
40
|
+
* requesting session's OWN persistence directory
|
|
41
|
+
* (`<sessionDir>/agent-approval.jsonl`, resolved via
|
|
42
|
+
* `sessionPersistence.locate`), so the audit trail follows the session
|
|
43
|
+
* exactly: it survives restarts with the session, disappears when the
|
|
44
|
+
* session is deleted, and NEVER touches the durable event log — no
|
|
45
|
+
* custom events written, none read (the log's strict event-type
|
|
46
|
+
* vocabulary makes plugin-defined types unsafe, and per project ruling
|
|
47
|
+
* session.jsonl.zstd carries zero plugin data). The conversation
|
|
48
|
+
* window's「审批」tab (next to 轨迹) folds those records per session;
|
|
49
|
+
* the judge's own child session id is kept so the full reasoning trail
|
|
50
|
+
* can be inspected in the session list.
|
|
43
51
|
*
|
|
44
52
|
* Mount on the HOST plane (profile `cordis.patch.yml` insert row): the
|
|
45
53
|
* approval waterfall listener must be unscoped to see every live agent, and
|
|
@@ -50,7 +58,7 @@ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
|
50
58
|
import { Service } from "@deepseek-ai/cordis";
|
|
51
59
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
52
60
|
import { homedir } from "node:os";
|
|
53
|
-
import { join } from "node:path";
|
|
61
|
+
import { dirname, join } from "node:path";
|
|
54
62
|
|
|
55
63
|
// ---- constants --------------------------------------------------------------
|
|
56
64
|
|
|
@@ -66,16 +74,34 @@ const PRESET_NAME = "agent-approval";
|
|
|
66
74
|
const DEFAULT_TIMEOUT_MS = 120000;
|
|
67
75
|
const MIN_TIMEOUT_MS = 30000;
|
|
68
76
|
const MAX_TIMEOUT_MS = 600000;
|
|
69
|
-
/** In-memory audit ring size (the Settings page shows the latest 50). */
|
|
70
|
-
const MAX_RECORDS = 200;
|
|
71
77
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
78
|
+
* v1.5.1, tightened in v1.5.2: audit records live in a SIDECAR FILE inside
|
|
79
|
+
* the session's OWN persistence directory (`<sessionDir>/agent-approval.jsonl`,
|
|
80
|
+
* resolved via `sessionPersistence.locate(header)`), so they still follow the
|
|
81
|
+
* session exactly — restored/kept with it, gone when the session directory is
|
|
82
|
+
* deleted. The durable event log (session.jsonl.zstd) is NEVER read for
|
|
83
|
+
* records and NEVER written by this plugin: writing custom event types into
|
|
84
|
+
* the log (v1.5.0's approach) is NOT viable — the persistence read path
|
|
85
|
+
* refuses a whole log containing an event type outside
|
|
86
|
+
* `KNOWN_SESSION_EVENT_TYPES` unless the envelope carries `ignorable: true`,
|
|
87
|
+
* and the live-session writer `session.append()` cannot set that marker —
|
|
88
|
+
* the first judged escalation made the session unresumable (2026-09-06, two
|
|
89
|
+
* poisoned log events repaired in place). Per the final ruling: the log
|
|
90
|
+
* carries ZERO plugin-defined data, and the audit tab reads the sidecar
|
|
91
|
+
* only. A handful of ignorable-marked v1.5.0-era record events remain in one
|
|
92
|
+
* historical log as inert, load-verified history; physically deleting them
|
|
93
|
+
* would require whole-log seq renumbering and is not worth the corruption
|
|
94
|
+
* risk.
|
|
95
|
+
*/
|
|
96
|
+
/** The sidecar file name inside a session's persistence directory. */
|
|
97
|
+
const RECORDS_SIDECAR = "agent-approval.jsonl";
|
|
98
|
+
/**
|
|
99
|
+
* On-disk persistence for the judge settings (model override + timeout +
|
|
100
|
+
* rules). Lives under DSH_HOME (same resolution as the plugin's own README
|
|
101
|
+
* documents), outside any profile's node_modules so reinstalls and upgrades
|
|
102
|
+
* never touch it.
|
|
76
103
|
*/
|
|
77
104
|
const DATA_DIR = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "agent-approval");
|
|
78
|
-
const RECORDS_FILE = join(DATA_DIR, "records.jsonl");
|
|
79
105
|
const CONFIG_FILE = join(DATA_DIR, "config.json");
|
|
80
106
|
|
|
81
107
|
/**
|
|
@@ -203,7 +229,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
203
229
|
markRemoteMethod(this, "toggle", "toggle");
|
|
204
230
|
markRemoteMethod(this, "addRule", "addRule");
|
|
205
231
|
markRemoteMethod(this, "removeRule", "removeRule");
|
|
206
|
-
markRemoteMethod(this, "
|
|
232
|
+
markRemoteMethod(this, "sessionRecords", "sessionRecords");
|
|
207
233
|
markRemoteMethod(this, "directory", "directory");
|
|
208
234
|
|
|
209
235
|
/** Judge model override; empty strings = use the harness default route. */
|
|
@@ -212,8 +238,6 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
212
238
|
this._timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
213
239
|
/** sessionId -> { prevSandbox?: string, prevApproval?: string } */
|
|
214
240
|
this._enabled = new Map();
|
|
215
|
-
/** Audit records, oldest first, capped at MAX_RECORDS. */
|
|
216
|
-
this._records = [];
|
|
217
241
|
/**
|
|
218
242
|
* Deterministic rules judged BEFORE the model (persisted in config.json):
|
|
219
243
|
* [{ id, effect: "allow"|"deny", tool, match, note, createdAt }]. A hit
|
|
@@ -338,9 +362,26 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
338
362
|
|
|
339
363
|
// ---- knob plumbing --------------------------------------------------------
|
|
340
364
|
|
|
365
|
+
/**
|
|
366
|
+
* The session's durable events as a plain readonly array. DSH 0.1.2-rc.1
|
|
367
|
+
* removed the public `session.events` snapshot array in favor of
|
|
368
|
+
* `snapshotEvents(from?, to?)` / `eventAt(seq)` / `seq` — reading the old
|
|
369
|
+
* field yields undefined and every fold below threw TypeError, which the
|
|
370
|
+
* listeners' try/catch swallowed, so no session could ever enter `_enabled`
|
|
371
|
+
* (every escalation fell through to the human answerer). Prefer the new
|
|
372
|
+
* accessor; fall back to the legacy array on 0.1.1.
|
|
373
|
+
*/
|
|
374
|
+
_eventsOf(session) {
|
|
375
|
+
if (session && typeof session.snapshotEvents === "function") {
|
|
376
|
+
return session.snapshotEvents();
|
|
377
|
+
}
|
|
378
|
+
const legacy = session ? session.events : undefined;
|
|
379
|
+
return Array.isArray(legacy) ? legacy : [];
|
|
380
|
+
}
|
|
381
|
+
|
|
341
382
|
/** Last `sandbox/mode` / `approval/policy` value in the session log fold. */
|
|
342
383
|
_lastKnob(session, type, field) {
|
|
343
|
-
const events = session
|
|
384
|
+
const events = this._eventsOf(session);
|
|
344
385
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
345
386
|
const e = events[i];
|
|
346
387
|
if (e.type === type) return e.data[field];
|
|
@@ -554,11 +595,16 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
554
595
|
|
|
555
596
|
// ---- audit ----------------------------------------------------------------
|
|
556
597
|
|
|
557
|
-
/**
|
|
558
|
-
|
|
598
|
+
/**
|
|
599
|
+
* Coerce one entry to the strict wire shape (typert result schema). The
|
|
600
|
+
* session column is filled by the reader — the sidecar lives inside the
|
|
601
|
+
* session's own directory, so the id is implied but still stamped into
|
|
602
|
+
* every line to keep the file self-describing.
|
|
603
|
+
*/
|
|
604
|
+
_recordShape(sessionId, entry) {
|
|
559
605
|
return {
|
|
560
606
|
at: String(entry.at),
|
|
561
|
-
sessionId: String(
|
|
607
|
+
sessionId: String(sessionId),
|
|
562
608
|
toolName: String(entry.toolName),
|
|
563
609
|
reason: String(entry.reason),
|
|
564
610
|
args: String(entry.args),
|
|
@@ -571,29 +617,76 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
571
617
|
};
|
|
572
618
|
}
|
|
573
619
|
|
|
574
|
-
/**
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
620
|
+
/**
|
|
621
|
+
* Resolve the audit sidecar for one session: `agent-approval.jsonl` inside
|
|
622
|
+
* the session's persistence directory (same directory as the session's own
|
|
623
|
+
* durable log, via `sessionPersistence.locate(header)` — a pure path
|
|
624
|
+
* resolution that also works for live sessions). Falls back to a
|
|
625
|
+
* plugin-owned per-session file under DSH_HOME when the seam or the
|
|
626
|
+
* location is unavailable; the fallback keeps restart-safety at the cost
|
|
627
|
+
* of not being cleaned up when the session is deleted.
|
|
628
|
+
*/
|
|
629
|
+
async _recordsFileOf(session) {
|
|
630
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
631
|
+
if (persistence !== undefined && typeof persistence.locate === "function") {
|
|
632
|
+
try {
|
|
633
|
+
const loc = persistence.locate(session.header);
|
|
634
|
+
if (loc && typeof loc.path === "string" && loc.path !== "") {
|
|
635
|
+
return join(dirname(loc.path), RECORDS_SIDECAR);
|
|
636
|
+
}
|
|
637
|
+
} catch (e) {
|
|
638
|
+
/* fall through to the plugin-owned fallback */
|
|
639
|
+
}
|
|
580
640
|
}
|
|
581
|
-
|
|
582
|
-
.then(() => appendFile(RECORDS_FILE, JSON.stringify(shape) + "\n", "utf8"))
|
|
583
|
-
.catch(() => {
|
|
584
|
-
/* persistence is best-effort; the in-memory ring still works */
|
|
585
|
-
});
|
|
641
|
+
return join(DATA_DIR, "records", `${String(session.id)}.jsonl`);
|
|
586
642
|
}
|
|
587
643
|
|
|
588
|
-
/**
|
|
589
|
-
|
|
644
|
+
/**
|
|
645
|
+
* Append one audit record to the session's SIDECAR file (see
|
|
646
|
+
* `_recordsFileOf`). Appending must never break the approval flow it
|
|
647
|
+
* audits: fire-and-forget with every failure swallowed.
|
|
648
|
+
*/
|
|
649
|
+
_record(session, entry) {
|
|
650
|
+
const shape = this._recordShape(session.id, entry);
|
|
651
|
+
void (async () => {
|
|
652
|
+
try {
|
|
653
|
+
const file = await this._recordsFileOf(session);
|
|
654
|
+
await mkdir(dirname(file), { recursive: true });
|
|
655
|
+
await appendFile(file, JSON.stringify(shape) + "\n", "utf8");
|
|
656
|
+
} catch (e) {
|
|
657
|
+
/* audit is best-effort; the approval outcome still stands */
|
|
658
|
+
}
|
|
659
|
+
})();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Fold one session's audit records (chronological by `at`). The sidecar
|
|
664
|
+
* file is the ONLY source — the durable event log is never consulted
|
|
665
|
+
* (v1.5.2: zero custom data read from or written to session.jsonl.zstd).
|
|
666
|
+
* Never throws.
|
|
667
|
+
*/
|
|
668
|
+
async _recordsOf(session) {
|
|
669
|
+
const out = [];
|
|
590
670
|
try {
|
|
591
|
-
|
|
592
|
-
const
|
|
593
|
-
|
|
671
|
+
const file = await this._recordsFileOf(session);
|
|
672
|
+
const text = await readFile(file, "utf8");
|
|
673
|
+
for (const raw of text.split("\n")) {
|
|
674
|
+
const line = raw.trim();
|
|
675
|
+
if (line === "") continue;
|
|
676
|
+
try {
|
|
677
|
+
const parsed = JSON.parse(line);
|
|
678
|
+
if (parsed && typeof parsed === "object" && typeof parsed.at === "string") {
|
|
679
|
+
out.push(this._recordShape(session.id, parsed));
|
|
680
|
+
}
|
|
681
|
+
} catch (e) {
|
|
682
|
+
/* skip the corrupt line */
|
|
683
|
+
}
|
|
684
|
+
}
|
|
594
685
|
} catch (e) {
|
|
595
|
-
/*
|
|
686
|
+
/* no sidecar yet */
|
|
596
687
|
}
|
|
688
|
+
out.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
689
|
+
return out;
|
|
597
690
|
}
|
|
598
691
|
|
|
599
692
|
/** Persist the judge settings (model override + timeout + rules) to config.json. */
|
|
@@ -611,9 +704,9 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
611
704
|
}
|
|
612
705
|
|
|
613
706
|
/**
|
|
614
|
-
* Load persisted settings
|
|
615
|
-
*
|
|
616
|
-
*
|
|
707
|
+
* Load persisted judge settings at startup (audit records need no loading —
|
|
708
|
+
* they live in the session logs and are folded per session on demand).
|
|
709
|
+
* Corrupt config is skipped; never throws.
|
|
617
710
|
*/
|
|
618
711
|
async _loadPersisted() {
|
|
619
712
|
try {
|
|
@@ -654,28 +747,6 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
654
747
|
} catch (e) {
|
|
655
748
|
/* first run or unreadable config — keep the defaults */
|
|
656
749
|
}
|
|
657
|
-
try {
|
|
658
|
-
const text = await readFile(RECORDS_FILE, "utf8");
|
|
659
|
-
const lines = text.split("\n");
|
|
660
|
-
const kept = [];
|
|
661
|
-
for (let i = lines.length - 1; i >= 0 && kept.length < MAX_RECORDS; i--) {
|
|
662
|
-
const line = lines[i].trim();
|
|
663
|
-
if (line === "") continue;
|
|
664
|
-
try {
|
|
665
|
-
const parsed = JSON.parse(line);
|
|
666
|
-
if (parsed && typeof parsed === "object" && typeof parsed.at === "string") {
|
|
667
|
-
kept.push(this._recordShape(parsed));
|
|
668
|
-
}
|
|
669
|
-
} catch (e) {
|
|
670
|
-
/* skip the corrupt line */
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
kept.reverse();
|
|
674
|
-
this._records = kept;
|
|
675
|
-
if (lines.length > kept.length) await this._rewriteRecordsFile();
|
|
676
|
-
} catch (e) {
|
|
677
|
-
/* no records file yet */
|
|
678
|
-
}
|
|
679
750
|
}
|
|
680
751
|
|
|
681
752
|
// ---- the claimer ----------------------------------------------------------
|
|
@@ -683,7 +754,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
683
754
|
/** Read the exact tool-call arguments JSON from the session log by callId. */
|
|
684
755
|
_callArgsOf(session, callId) {
|
|
685
756
|
if (callId === undefined) return undefined;
|
|
686
|
-
const events = session
|
|
757
|
+
const events = this._eventsOf(session);
|
|
687
758
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
688
759
|
const e = events[i];
|
|
689
760
|
if (e.type === "tool/call" && e.data.callId === callId) return e.data.arguments;
|
|
@@ -701,7 +772,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
701
772
|
* the requesting agent phrased its justification.
|
|
702
773
|
*/
|
|
703
774
|
_recentUserContext(session) {
|
|
704
|
-
const events = session
|
|
775
|
+
const events = this._eventsOf(session);
|
|
705
776
|
let first = "";
|
|
706
777
|
const last = []; // chronological, capped at 3
|
|
707
778
|
for (let i = 0; i < events.length; i++) {
|
|
@@ -794,9 +865,8 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
794
865
|
// A listener throw would make the whole waterfall fail closed with
|
|
795
866
|
// 'unavailable' anyway; record what we can and resolve the same way.
|
|
796
867
|
try {
|
|
797
|
-
this._record({
|
|
868
|
+
this._record(session, {
|
|
798
869
|
at: new Date().toISOString(),
|
|
799
|
-
sessionId: shortId(session.id),
|
|
800
870
|
toolName: String(req.toolName),
|
|
801
871
|
reason: trunc(req.reason, 300),
|
|
802
872
|
args: "",
|
|
@@ -853,7 +923,6 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
853
923
|
const toolName = String(req.toolName);
|
|
854
924
|
const base = {
|
|
855
925
|
at: startedAt,
|
|
856
|
-
sessionId: shortId(session.id),
|
|
857
926
|
toolName: toolName,
|
|
858
927
|
reason: trunc(req.reason, 300),
|
|
859
928
|
args: trunc(argsRaw, 2000),
|
|
@@ -871,10 +940,10 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
871
940
|
(rule.note !== "" ? " — " + rule.note : "");
|
|
872
941
|
base.durationMs = Date.now() - t0;
|
|
873
942
|
if (rule.effect === "deny") {
|
|
874
|
-
this._record({ ...base, outcome: "rejected", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
943
|
+
this._record(session, { ...base, outcome: "rejected", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
875
944
|
return "rejected";
|
|
876
945
|
}
|
|
877
|
-
this._record({ ...base, outcome: "allowed-once", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
946
|
+
this._record(session, { ...base, outcome: "allowed-once", riskLevel: "-", model: "rule", rationale: trunc(text, 600) });
|
|
878
947
|
return "allowed-once";
|
|
879
948
|
}
|
|
880
949
|
|
|
@@ -884,7 +953,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
884
953
|
const trusted = this._trusted.get(session.id);
|
|
885
954
|
if (trustKey !== undefined && trusted !== undefined && trusted.has(trustKey)) {
|
|
886
955
|
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" });
|
|
956
|
+
this._record(session, { ...base, outcome: "allowed-once", riskLevel: "-", model: "trust", rationale: "trusted: an identical operation was already approved in this session" });
|
|
888
957
|
return "allowed-once";
|
|
889
958
|
}
|
|
890
959
|
|
|
@@ -906,7 +975,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
906
975
|
persona: APPROVER_PERSONA,
|
|
907
976
|
});
|
|
908
977
|
} catch (error) {
|
|
909
|
-
this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent failed to start: " + errText(error) });
|
|
978
|
+
this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent failed to start: " + errText(error) });
|
|
910
979
|
return "unavailable";
|
|
911
980
|
}
|
|
912
981
|
base.childSessionId = shortId(run.id);
|
|
@@ -943,7 +1012,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
943
1012
|
(verdict.decision === "approve" || verdict.decision === "reject")
|
|
944
1013
|
) {
|
|
945
1014
|
const approved = verdict.decision === "approve";
|
|
946
|
-
this._record({
|
|
1015
|
+
this._record(session, {
|
|
947
1016
|
...base,
|
|
948
1017
|
outcome: approved ? "allowed-once" : "rejected",
|
|
949
1018
|
riskLevel: String(verdict.riskLevel || "-"),
|
|
@@ -962,7 +1031,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
962
1031
|
}
|
|
963
1032
|
return approved ? "allowed-once" : "rejected";
|
|
964
1033
|
}
|
|
965
|
-
this._record({
|
|
1034
|
+
this._record(session, {
|
|
966
1035
|
...base,
|
|
967
1036
|
outcome: "unavailable",
|
|
968
1037
|
riskLevel: "-",
|
|
@@ -973,11 +1042,11 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
973
1042
|
return "unavailable";
|
|
974
1043
|
}
|
|
975
1044
|
if (winner.kind === "aborted") {
|
|
976
|
-
this._record({ ...base, outcome: "cancelled", riskLevel: "-", model: route.label, rationale: "request cancelled while the approval agent was judging" });
|
|
1045
|
+
this._record(session, { ...base, outcome: "cancelled", riskLevel: "-", model: route.label, rationale: "request cancelled while the approval agent was judging" });
|
|
977
1046
|
return "cancelled";
|
|
978
1047
|
}
|
|
979
1048
|
if (winner.kind === "timeout") {
|
|
980
|
-
this._record({
|
|
1049
|
+
this._record(session, {
|
|
981
1050
|
...base,
|
|
982
1051
|
outcome: "unavailable",
|
|
983
1052
|
riskLevel: "-",
|
|
@@ -986,7 +1055,7 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
986
1055
|
});
|
|
987
1056
|
return "unavailable";
|
|
988
1057
|
}
|
|
989
|
-
this._record({ ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent infrastructure fault: " + errText(winner.error) });
|
|
1058
|
+
this._record(session, { ...base, outcome: "unavailable", riskLevel: "-", model: route.label, rationale: "approval agent infrastructure fault: " + errText(winner.error) });
|
|
990
1059
|
return "unavailable";
|
|
991
1060
|
}
|
|
992
1061
|
|
|
@@ -1034,7 +1103,6 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1034
1103
|
timeoutMs: this._timeoutMs,
|
|
1035
1104
|
enabledSessions: this._sessionInfos(),
|
|
1036
1105
|
rules: this._rulesSnapshot(),
|
|
1037
|
-
records: this._records.slice(-50).reverse(),
|
|
1038
1106
|
},
|
|
1039
1107
|
};
|
|
1040
1108
|
}
|
|
@@ -1119,11 +1187,34 @@ export class AgentApprovalService extends TypertRemoteService {
|
|
|
1119
1187
|
return { ok: true, value: { rules: this._rulesSnapshot() } };
|
|
1120
1188
|
}
|
|
1121
1189
|
|
|
1122
|
-
/**
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1190
|
+
/**
|
|
1191
|
+
* Fold ONE session's audit records out of its sidecar storage (see
|
|
1192
|
+
* `_recordsFileOf` / `_recordsOf`). Powers the conversation window's「审批」
|
|
1193
|
+
* tab — the records are requested per session and rendered next to the
|
|
1194
|
+
* 轨迹 tab, exactly where they were produced. The session must be live (it
|
|
1195
|
+
* always is when its conversation window is open). Also reports whether
|
|
1196
|
+
* the mode is currently enabled for the session so the tab can show the
|
|
1197
|
+
* state.
|
|
1198
|
+
*/
|
|
1199
|
+
async sessionRecords(request) {
|
|
1200
|
+
const sessionId = request && typeof request.sessionId === "string" ? request.sessionId : "";
|
|
1201
|
+
if (sessionId === "") {
|
|
1202
|
+
return { ok: false, error: { code: "invalid-session", message: "sessionId is required" } };
|
|
1203
|
+
}
|
|
1204
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
1205
|
+
if (agent === undefined) {
|
|
1206
|
+
return {
|
|
1207
|
+
ok: false,
|
|
1208
|
+
error: { code: "session-not-live", message: "that session is not live right now" },
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
ok: true,
|
|
1213
|
+
value: {
|
|
1214
|
+
records: await this._recordsOf(agent.session),
|
|
1215
|
+
enabled: this._enabled.has(sessionId),
|
|
1216
|
+
},
|
|
1217
|
+
};
|
|
1127
1218
|
}
|
|
1128
1219
|
|
|
1129
1220
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duke-dsh-plugins/dsh-agent-approval",
|
|
3
|
-
"version": "1.
|
|
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
|
|
3
|
+
"version": "1.5.0",
|
|
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 a per-session audit trail in the conversation window's 审批 tab.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/MoonlitDropOfBlood/dsh-agent-approval.git"
|
package/typert.host.js
CHANGED
|
@@ -66,7 +66,6 @@ const stateValueSchema = z
|
|
|
66
66
|
timeoutMs: z.number(),
|
|
67
67
|
enabledSessions: z.array(enabledSessionSchema).readonly(),
|
|
68
68
|
rules: z.array(ruleSchema).readonly(),
|
|
69
|
-
records: z.array(recordSchema).readonly(),
|
|
70
69
|
})
|
|
71
70
|
.readonly();
|
|
72
71
|
|
|
@@ -94,9 +93,10 @@ const rulesValueSchema = z
|
|
|
94
93
|
})
|
|
95
94
|
.readonly();
|
|
96
95
|
|
|
97
|
-
const
|
|
96
|
+
const sessionRecordsValueSchema = z
|
|
98
97
|
.object({
|
|
99
|
-
|
|
98
|
+
records: z.array(recordSchema).readonly(),
|
|
99
|
+
enabled: z.boolean(),
|
|
100
100
|
})
|
|
101
101
|
.readonly();
|
|
102
102
|
|
|
@@ -156,7 +156,7 @@ const setTimeoutResultSchema = okResult(setTimeoutValueSchema);
|
|
|
156
156
|
const toggleResultSchema = okResult(toggleValueSchema);
|
|
157
157
|
const addRuleResultSchema = okResult(rulesValueSchema);
|
|
158
158
|
const removeRuleResultSchema = okResult(rulesValueSchema);
|
|
159
|
-
const
|
|
159
|
+
const sessionRecordsResultSchema = okResult(sessionRecordsValueSchema);
|
|
160
160
|
const directoryResultSchema = okResult(directoryValueSchema);
|
|
161
161
|
|
|
162
162
|
// ---- per-invocation parameter schemas ----------------------------------------
|
|
@@ -186,6 +186,10 @@ const _agentApproval_removeRule_parameter_0$schema = z.object({
|
|
|
186
186
|
id: z.string(),
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
const _agentApproval_sessionRecords_parameter_0$schema = z.object({
|
|
190
|
+
sessionId: z.string(),
|
|
191
|
+
});
|
|
192
|
+
|
|
189
193
|
export const TYPERT = {
|
|
190
194
|
package: "@duke-dsh-plugins/dsh-agent-approval",
|
|
191
195
|
face: "host",
|
|
@@ -331,16 +335,27 @@ export const TYPERT = {
|
|
|
331
335
|
sourceLocation: { file: "index.js", line: 1, column: 1 },
|
|
332
336
|
},
|
|
333
337
|
{
|
|
334
|
-
id: "dsh-agent-approval#agentApproval/
|
|
338
|
+
id: "dsh-agent-approval#agentApproval/sessionRecords",
|
|
335
339
|
service: "agentApproval",
|
|
336
340
|
namespace: "agentApproval",
|
|
337
|
-
method: "
|
|
341
|
+
method: "sessionRecords",
|
|
338
342
|
invocation: { kind: "direct" },
|
|
339
|
-
parameters: [
|
|
343
|
+
parameters: [
|
|
344
|
+
{
|
|
345
|
+
name: "request",
|
|
346
|
+
wire: "request",
|
|
347
|
+
source: "json",
|
|
348
|
+
codec: {
|
|
349
|
+
mode: "strict",
|
|
350
|
+
typeSymbol: "dsh-agent-approval#AgentApprovalSessionRecordsRequest",
|
|
351
|
+
schema: _agentApproval_sessionRecords_parameter_0$schema,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
],
|
|
340
355
|
result: {
|
|
341
356
|
mode: "strict",
|
|
342
|
-
typeSymbol: "dsh-agent-approval#
|
|
343
|
-
schema:
|
|
357
|
+
typeSymbol: "dsh-agent-approval#AgentApprovalSessionRecordsResult",
|
|
358
|
+
schema: sessionRecordsResultSchema,
|
|
344
359
|
},
|
|
345
360
|
sourceLocation: { file: "index.js", line: 1, column: 1 },
|
|
346
361
|
},
|
|
@@ -363,7 +378,7 @@ export const TYPERT = {
|
|
|
363
378
|
services: [
|
|
364
379
|
{
|
|
365
380
|
description:
|
|
366
|
-
"Agent-approval permission mode service: pins enabled sessions to a workspace-write base, judges every sandbox escalation with an independent approval subagent (fail closed),
|
|
381
|
+
"Agent-approval permission mode service: pins enabled sessions to a workspace-write base, judges every sandbox escalation with an independent approval subagent (fail closed), appends the audit trail to each session's own log, and exposes model config to the DeepSeek Harness web UI.",
|
|
367
382
|
summary: "Agent-approval permission mode service.",
|
|
368
383
|
tags: [],
|
|
369
384
|
jsDoc:
|
|
@@ -375,9 +390,9 @@ export const TYPERT = {
|
|
|
375
390
|
kind: "method",
|
|
376
391
|
name: "getState",
|
|
377
392
|
signature: "@Remote('getState') async getState(): Promise<AgentApprovalStateResult>",
|
|
378
|
-
summary: "Snapshot for the Settings page
|
|
393
|
+
summary: "Snapshot for the Settings page (model route, timeout, enabled sessions, rules).",
|
|
379
394
|
jsDoc:
|
|
380
|
-
"/**\n * Return the judge model route, timeout, enabled sessions (id + session-list title + workspace cwd), and the
|
|
395
|
+
"/**\n * Return the judge model route, timeout, enabled sessions (id + session-list title + workspace cwd), and the rule table.\n * @returns success or a business failure.\n */",
|
|
381
396
|
},
|
|
382
397
|
{
|
|
383
398
|
kind: "method",
|
|
@@ -421,11 +436,11 @@ export const TYPERT = {
|
|
|
421
436
|
},
|
|
422
437
|
{
|
|
423
438
|
kind: "method",
|
|
424
|
-
name: "
|
|
425
|
-
signature: "@Remote('
|
|
426
|
-
summary: "
|
|
439
|
+
name: "sessionRecords",
|
|
440
|
+
signature: "@Remote('sessionRecords') async sessionRecords(request: AgentApprovalSessionRecordsRequest): Promise<AgentApprovalSessionRecordsResult>",
|
|
441
|
+
summary: "Fold one live session's audit records out of its own durable log.",
|
|
427
442
|
jsDoc:
|
|
428
|
-
"/**\n *
|
|
443
|
+
"/**\n * Fold the agent-approval/record events of one live session (chronological) plus the current enabled state. Records follow the session: persisted in its log, restored with it, gone when it is deleted.\n * @param request - { sessionId }.\n * @returns the session's records, or session-not-live.\n */",
|
|
429
444
|
},
|
|
430
445
|
{
|
|
431
446
|
kind: "method",
|
|
@@ -465,7 +480,22 @@ export const TYPERT = {
|
|
|
465
480
|
{
|
|
466
481
|
name: "AgentApprovalStateValue",
|
|
467
482
|
declaration:
|
|
468
|
-
"export interface AgentApprovalStateValue {\n readonly model: AgentApprovalModelRoute;\n readonly timeoutMs: number;\n readonly enabledSessions: readonly AgentApprovalEnabledSession[];\n readonly rules: readonly AgentApprovalRule[];\n
|
|
483
|
+
"export interface AgentApprovalStateValue {\n readonly model: AgentApprovalModelRoute;\n readonly timeoutMs: number;\n readonly enabledSessions: readonly AgentApprovalEnabledSession[];\n readonly rules: readonly AgentApprovalRule[];\n}",
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
name: "AgentApprovalSessionRecordsRequest",
|
|
487
|
+
declaration:
|
|
488
|
+
"export interface AgentApprovalSessionRecordsRequest {\n readonly sessionId: string;\n}",
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: "AgentApprovalSessionRecordsValue",
|
|
492
|
+
declaration:
|
|
493
|
+
"export interface AgentApprovalSessionRecordsValue {\n readonly records: readonly AgentApprovalRecord[];\n readonly enabled: boolean;\n}",
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: "AgentApprovalSessionRecordsResult",
|
|
497
|
+
declaration:
|
|
498
|
+
"export type AgentApprovalSessionRecordsResult = { ok: true; value: AgentApprovalSessionRecordsValue } | { ok: false; error: { code: string; message?: string } };",
|
|
469
499
|
},
|
|
470
500
|
{
|
|
471
501
|
name: "AgentApprovalStateResult",
|