@duke-dsh-plugins/dsh-agent-approval 1.4.2 → 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 CHANGED
@@ -26,7 +26,7 @@
26
26
  | ⛔ 风险即拒绝 | 破坏性 / 不可逆 / 越界(含修改操作系统或其他应用数据)/ 理由与实际命令不符 → 直接 `reject`;仅"安全、可逆、与任务相符、理由诚实"才 `approve`——项目自身的安装/部署脚本写其文档指定路径属任务所需 |
27
27
  | 🔒 Fail-closed | 审批 Agent 启动失败、超时(可配 30s–600s)、结果不合法 → 一律按拒绝处理,绝不静默放行 |
28
28
  | ⚙️ 审批模型可配置 | 设置页选择 Provider + Model,不选则固定用 **Harness 默认模型**(不跟随请求会话,口径稳定);选择与超时**持久保存**,重启不丢 |
29
- | 📋 审计记录 | 设置页查看最近审批:结论 / 风险等级 / 模型 / 耗时 / 理由;悬停看完整理由与**精确工具参数**;审批 Agent 的会话 id 可回溯完整推理;记录**本地持久化**(重启保留最近 200 条) |
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.2.0/dsh-agent-approval-1.2.0.tgz
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. **审计**:设置 → **Agent 审批** → 审批记录;悬停"审批理由"看完整理由与工具参数。
80
- 4. **配置**:同页设置审批模型(不选则用 Harness 默认模型)与审批超时。
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. A "Agent 审批" page in the Settings panel (`settings.section`):
7
- * - approval model picker (provider + model, or the harness default),
8
- * - judge timeout setting (fail-closed),
9
- * - the list of sessions with the mode enabled (session-list title +
10
- * workspace, so each chip is recognizable),
11
- * - the latest approval audit records (verdict, risk, model, duration,
12
- * rationale; hover for the full rationale + tool arguments).
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/clearRecords",
296
+ id: "dsh-agent-approval#agentApproval/sessionRecords",
281
297
  service: "agentApproval",
282
298
  namespace: "agentApproval",
283
- method: "clearRecords",
299
+ method: "sessionRecords",
284
300
  invocation: { kind: "direct" },
285
- parameters: [],
286
- result: result("dsh-agent-approval#AgentApprovalClearRecordsResult"),
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-card" },
709
- h("h3", null, "审批记录(最近 50 条,本地持久化保留 200 条,重启不丢)"),
782
+ { className: "aapr-view-inner" },
710
783
  h(
711
784
  "div",
712
- { className: "aapr-row" },
713
- h(ui.Button, { variant: "ghost", size: "sm", onClick: refresh }, "刷新"),
714
- h(ui.Button, { variant: "ghost", size: "sm", onClick: clearRecords }, "清空记录"),
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-wrap" },
720
- h(
721
- "table",
722
- { className: "aapr-table" },
723
- h(
724
- "thead",
725
- null,
726
- h(
727
- "tr",
728
- null,
729
- ["时间", "会话", "工具", "结果", "风险", "模型", "耗时", "审批理由", "规则"].map((t) => h("th", { key: t }, t)),
730
- ),
731
- ),
732
- h(
733
- "tbody",
734
- null,
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
- { key: String(i) + "-" + String(r.at) },
740
- h("td", null, fmtTime(r.at)),
741
- h("td", null, String(r.sessionId)),
742
- h("td", null, String(r.toolName)),
743
- h("td", { className: r.outcome === "allowed-once" ? "aapr-ok" : "aapr-no" }, OUTCOME_LABEL[r.outcome] || String(r.outcome)),
744
- h("td", null, RISK_LABEL[r.riskLevel] || String(r.riskLevel || "-")),
745
- h("td", null, String(r.model)),
746
- h("td", null, String(r.durationMs) + "ms"),
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
- "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,
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
- : h("tr", null, h("td", { colSpan: 9 }, h("span", { className: "aapr-muted" }, "暂无记录"))),
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 recorded (memory ring + JSONL under
40
- * DSH_HOME) and shown in the Settings page; the judge's own child
41
- * session id is kept so the full reasoning trail can be inspected in
42
- * the session list.
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
- * On-disk persistence: one JSON object per line in records.jsonl plus the
73
- * judge settings in config.json. Lives under DSH_HOME (same resolution as
74
- * the plugin's own README documents), outside any profile's node_modules so
75
- * reinstalls and upgrades never touch it.
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, "clearRecords", "clearRecords");
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
@@ -571,11 +595,16 @@ export class AgentApprovalService extends TypertRemoteService {
571
595
 
572
596
  // ---- audit ----------------------------------------------------------------
573
597
 
574
- /** Coerce one entry to the strict wire shape (typert result schema). */
575
- _recordShape(entry) {
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) {
576
605
  return {
577
606
  at: String(entry.at),
578
- sessionId: String(entry.sessionId),
607
+ sessionId: String(sessionId),
579
608
  toolName: String(entry.toolName),
580
609
  reason: String(entry.reason),
581
610
  args: String(entry.args),
@@ -588,29 +617,76 @@ export class AgentApprovalService extends TypertRemoteService {
588
617
  };
589
618
  }
590
619
 
591
- /** Append one audit record (coerced), cap the ring, persist as JSONL. */
592
- _record(entry) {
593
- const shape = this._recordShape(entry);
594
- this._records.push(shape);
595
- if (this._records.length > MAX_RECORDS) {
596
- this._records.splice(0, this._records.length - MAX_RECORDS);
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
+ }
597
640
  }
598
- mkdir(DATA_DIR, { recursive: true })
599
- .then(() => appendFile(RECORDS_FILE, JSON.stringify(shape) + "\n", "utf8"))
600
- .catch(() => {
601
- /* persistence is best-effort; the in-memory ring still works */
602
- });
641
+ return join(DATA_DIR, "records", `${String(session.id)}.jsonl`);
603
642
  }
604
643
 
605
- /** Rewrite the whole records file from the in-memory ring (clear/compact). */
606
- async _rewriteRecordsFile() {
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 = [];
607
670
  try {
608
- await mkdir(DATA_DIR, { recursive: true });
609
- const body = this._records.map((r) => JSON.stringify(r)).join("\n");
610
- await writeFile(RECORDS_FILE, body === "" ? "" : body + "\n", "utf8");
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
+ }
611
685
  } catch (e) {
612
- /* best-effort */
686
+ /* no sidecar yet */
613
687
  }
688
+ out.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
689
+ return out;
614
690
  }
615
691
 
616
692
  /** Persist the judge settings (model override + timeout + rules) to config.json. */
@@ -628,9 +704,9 @@ export class AgentApprovalService extends TypertRemoteService {
628
704
  }
629
705
 
630
706
  /**
631
- * Load persisted settings + records at startup. Corrupt files/lines are
632
- * skipped individually; the records file is compacted back down to the ring
633
- * size so it cannot grow without bound. Never throws.
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.
634
710
  */
635
711
  async _loadPersisted() {
636
712
  try {
@@ -671,28 +747,6 @@ export class AgentApprovalService extends TypertRemoteService {
671
747
  } catch (e) {
672
748
  /* first run or unreadable config — keep the defaults */
673
749
  }
674
- try {
675
- const text = await readFile(RECORDS_FILE, "utf8");
676
- const lines = text.split("\n");
677
- const kept = [];
678
- for (let i = lines.length - 1; i >= 0 && kept.length < MAX_RECORDS; i--) {
679
- const line = lines[i].trim();
680
- if (line === "") continue;
681
- try {
682
- const parsed = JSON.parse(line);
683
- if (parsed && typeof parsed === "object" && typeof parsed.at === "string") {
684
- kept.push(this._recordShape(parsed));
685
- }
686
- } catch (e) {
687
- /* skip the corrupt line */
688
- }
689
- }
690
- kept.reverse();
691
- this._records = kept;
692
- if (lines.length > kept.length) await this._rewriteRecordsFile();
693
- } catch (e) {
694
- /* no records file yet */
695
- }
696
750
  }
697
751
 
698
752
  // ---- the claimer ----------------------------------------------------------
@@ -811,9 +865,8 @@ export class AgentApprovalService extends TypertRemoteService {
811
865
  // A listener throw would make the whole waterfall fail closed with
812
866
  // 'unavailable' anyway; record what we can and resolve the same way.
813
867
  try {
814
- this._record({
868
+ this._record(session, {
815
869
  at: new Date().toISOString(),
816
- sessionId: shortId(session.id),
817
870
  toolName: String(req.toolName),
818
871
  reason: trunc(req.reason, 300),
819
872
  args: "",
@@ -870,7 +923,6 @@ export class AgentApprovalService extends TypertRemoteService {
870
923
  const toolName = String(req.toolName);
871
924
  const base = {
872
925
  at: startedAt,
873
- sessionId: shortId(session.id),
874
926
  toolName: toolName,
875
927
  reason: trunc(req.reason, 300),
876
928
  args: trunc(argsRaw, 2000),
@@ -888,10 +940,10 @@ export class AgentApprovalService extends TypertRemoteService {
888
940
  (rule.note !== "" ? " — " + rule.note : "");
889
941
  base.durationMs = Date.now() - t0;
890
942
  if (rule.effect === "deny") {
891
- 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) });
892
944
  return "rejected";
893
945
  }
894
- 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) });
895
947
  return "allowed-once";
896
948
  }
897
949
 
@@ -901,7 +953,7 @@ export class AgentApprovalService extends TypertRemoteService {
901
953
  const trusted = this._trusted.get(session.id);
902
954
  if (trustKey !== undefined && trusted !== undefined && trusted.has(trustKey)) {
903
955
  base.durationMs = Date.now() - t0;
904
- 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" });
905
957
  return "allowed-once";
906
958
  }
907
959
 
@@ -923,7 +975,7 @@ export class AgentApprovalService extends TypertRemoteService {
923
975
  persona: APPROVER_PERSONA,
924
976
  });
925
977
  } catch (error) {
926
- 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) });
927
979
  return "unavailable";
928
980
  }
929
981
  base.childSessionId = shortId(run.id);
@@ -960,7 +1012,7 @@ export class AgentApprovalService extends TypertRemoteService {
960
1012
  (verdict.decision === "approve" || verdict.decision === "reject")
961
1013
  ) {
962
1014
  const approved = verdict.decision === "approve";
963
- this._record({
1015
+ this._record(session, {
964
1016
  ...base,
965
1017
  outcome: approved ? "allowed-once" : "rejected",
966
1018
  riskLevel: String(verdict.riskLevel || "-"),
@@ -979,7 +1031,7 @@ export class AgentApprovalService extends TypertRemoteService {
979
1031
  }
980
1032
  return approved ? "allowed-once" : "rejected";
981
1033
  }
982
- this._record({
1034
+ this._record(session, {
983
1035
  ...base,
984
1036
  outcome: "unavailable",
985
1037
  riskLevel: "-",
@@ -990,11 +1042,11 @@ export class AgentApprovalService extends TypertRemoteService {
990
1042
  return "unavailable";
991
1043
  }
992
1044
  if (winner.kind === "aborted") {
993
- 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" });
994
1046
  return "cancelled";
995
1047
  }
996
1048
  if (winner.kind === "timeout") {
997
- this._record({
1049
+ this._record(session, {
998
1050
  ...base,
999
1051
  outcome: "unavailable",
1000
1052
  riskLevel: "-",
@@ -1003,7 +1055,7 @@ export class AgentApprovalService extends TypertRemoteService {
1003
1055
  });
1004
1056
  return "unavailable";
1005
1057
  }
1006
- 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) });
1007
1059
  return "unavailable";
1008
1060
  }
1009
1061
 
@@ -1051,7 +1103,6 @@ export class AgentApprovalService extends TypertRemoteService {
1051
1103
  timeoutMs: this._timeoutMs,
1052
1104
  enabledSessions: this._sessionInfos(),
1053
1105
  rules: this._rulesSnapshot(),
1054
- records: this._records.slice(-50).reverse(),
1055
1106
  },
1056
1107
  };
1057
1108
  }
@@ -1136,11 +1187,34 @@ export class AgentApprovalService extends TypertRemoteService {
1136
1187
  return { ok: true, value: { rules: this._rulesSnapshot() } };
1137
1188
  }
1138
1189
 
1139
- /** Clear the audit records (memory + persisted file). */
1140
- async clearRecords() {
1141
- this._records = [];
1142
- await this._rewriteRecordsFile();
1143
- return { ok: true, value: { cleared: true } };
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
+ };
1144
1218
  }
1145
1219
 
1146
1220
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@duke-dsh-plugins/dsh-agent-approval",
3
- "version": "1.4.2",
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.",
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 clearRecordsValueSchema = z
96
+ const sessionRecordsValueSchema = z
98
97
  .object({
99
- cleared: z.boolean(),
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 clearRecordsResultSchema = okResult(clearRecordsValueSchema);
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/clearRecords",
338
+ id: "dsh-agent-approval#agentApproval/sessionRecords",
335
339
  service: "agentApproval",
336
340
  namespace: "agentApproval",
337
- method: "clearRecords",
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#AgentApprovalClearRecordsResult",
343
- schema: clearRecordsResultSchema,
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), and exposes model config plus an audit log to the DeepSeek Harness web UI.",
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 and the composer toggle.",
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 latest audit records.\n * @returns success or a business failure.\n */",
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: "clearRecords",
425
- signature: "@Remote('clearRecords') async clearRecords(): Promise<AgentApprovalClearRecordsResult>",
426
- summary: "Clear the in-memory audit records.",
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 * Clear the in-memory audit records.\n * @returns { cleared: true }.\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 readonly records: readonly AgentApprovalRecord[];\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",