@duke-dsh-plugins/dsh-agent-approval 1.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 duke
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ <p align="center">
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" color="#4D6BFE"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>
3
+ </p>
4
+
5
+ <h3 align="center">DeepSeek Harness Agent 审批权限插件</h3>
6
+
7
+ <p align="center">
8
+ <img src="https://img.shields.io/badge/DSH-Plugin-4D6BFE?style=flat" alt="DSH plugin">
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat" alt="MIT License"></a>
10
+ <img src="https://img.shields.io/badge/Web%20UI-Yes-22C55E?style=flat" alt="Web UI">
11
+ </p>
12
+
13
+ <p align="center"><sub>中文</sub></p>
14
+
15
+ ---
16
+
17
+ 为 [DeepSeek Harness(DSH)](https://github.com/deepseek-ai/deepseek-harness) Web UI 打造的 **Agent 审批**权限插件:当内置的权限选项(`workspace-write + ask` / `danger-full-access + never`)不能满足需求时,为会话开启第三种模式——**以 workspace-write 为基线,提权请求交由独立审批 Agent 裁决,有风险就拒绝**。
18
+
19
+ ## 功能
20
+
21
+ | 功能 | 说明 |
22
+ |---|---|
23
+ | 🛡 **权限菜单第四项** | `/permission` 菜单新增 **Agent 审批** 预设;选中即开启,切到其他预设自动关闭,跨重启保持 |
24
+ | 🤖 新权限模式 | 开启后:沙箱基线固定 `workspace-write`,审批策略切到 `ask`(内部接管),**不再弹人工审批** |
25
+ | 🤖 独立审批 Agent | 每次提权请求由一次性 `spawn` 子代理裁决:独立会话、零工具、只读材料,结构化输出 `{decision, riskLevel, rationale}` |
26
+ | ⛔ 风险即拒绝 | 破坏性 / 不可逆 / 越界(含修改操作系统或其他应用数据)/ 理由与实际命令不符 → 直接 `reject`;仅"安全、可逆、与任务相符、理由诚实"才 `approve`——项目自身的安装/部署脚本写其文档指定路径属任务所需 |
27
+ | 🔒 Fail-closed | 审批 Agent 启动失败、超时(可配 30s–600s)、结果不合法 → 一律按拒绝处理,绝不静默放行 |
28
+ | ⚙️ 审批模型可配置 | 设置页选择 Provider + Model,不选则固定用 **Harness 默认模型**(不跟随请求会话,口径稳定);选择与超时**持久保存**,重启不丢 |
29
+ | 📋 审计记录 | 设置页查看最近审批:结论 / 风险等级 / 模型 / 耗时 / 理由;悬停看完整理由与**精确工具参数**;审批 Agent 的会话 id 可回溯完整推理;记录**本地持久化**(重启保留最近 200 条) |
30
+ | 🔁 可逆开关 | 权限菜单「Agent 审批」预设、`/agent-approval on\|off` 命令两条等价路径;关闭时**恢复开启前的权限旋钮** |
31
+
32
+ ## 工作原理
33
+
34
+ ```
35
+ 开启(菜单 / 命令)
36
+ └─ 记住旧旋钮 → sandbox/mode=workspace-write + approval/policy=ask(规范写路径,可恢复)
37
+ │
38
+ 工具请求提权(sandbox_permissions / 人工 ask)
39
+ └─ ctx.approval.request() → approval/request 瀑布
40
+ └─ 本插件 prepend 抢占(先于人工弹窗 answerer)
41
+ └─ spawn 审批 Agent(独立会话 · 零工具 · 结构化裁决 · 不会递归审批)
42
+ ├─ approve → allowed-once(该次放行)
43
+ ├─ reject → rejected(风险操作,最终拒绝)
44
+ └─ 超时/故障/取消 → fail-closed(按拒绝处理)
45
+ └─ 记入审计(设置页可见)
46
+ ```
47
+
48
+ - 审批 Agent 只能看到:workspace 路径、**最近的用户消息**(任务上下文)、工具名、提权理由、**精确的工具参数 JSON**(按 `callId` 从会话日志回查)。裁决看"操作 vs 用户任务"的客观对齐,不依赖理由措辞。
49
+ - 子代理审批策略被 DSH 委派机制钉死为 `never`,不存在递归审批;全局工具全部空白,审批员只能"判"不能"做"。
50
+ - 未开启的会话完全不受影响(监听器原样 `next()`,人工审批行为不变)。
51
+
52
+ ## 安装
53
+
54
+ ### 标准安装(推荐)
55
+
56
+ 本插件是**标准 DSH bundle**:`package.json` 声明 `dsh.bundle.patch`,包内 `cordis.patch.yml` 同时完成两件事——`- insert:` 挂载插件本身,`- id: permission` 把 **Agent 审批** 预设注册进 `/permission` 菜单。用官方 `dsh plugin` 命令安装:
57
+
58
+ ```bash
59
+ # 本地开发:pnpm 软链到本仓库,改代码即生效(无需重新复制)
60
+ dsh plugin --profile web add /path/to/dsh-agent-approval
61
+
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
64
+ ```
65
+
66
+ 重启 DSH 后:设置面板出现 **Agent 审批** 页;`/permission` 菜单出现第四项 **Agent 审批**。
67
+
68
+ > **可选:权限菜单图标**。菜单图标硬编码在官方 `dsh-client-ui-conversation` 的 `permissionGlyphs` 映射里(无公开注册口),标准安装不会补它——不跑下面的命令只是**菜单项没有图标**,预设与功能不受影响。想让菜单项带盾牌图标,装完再跑一次(幂等;DSH 升级重装原版 bundle 后重跑即可):
69
+ > ```bash
70
+ > npm run patch:glyph
71
+ > ```
72
+
73
+ > `dsh plugin add` 把插件装成 profile 的 npm 依赖并追加到 `dsh.profile.bundles`,启动时自动应用包内 patch。卸载:`dsh plugin --profile web remove dsh-agent-approval`。
74
+
75
+ ## 使用
76
+
77
+ 1. **开启**:在 `/permission` 菜单选 **Agent 审批**,或输入 `/agent-approval on`。
78
+ 2. **自动裁决**:之后该会话里的提权请求(例如命令被沙箱拒绝后带 `sandbox_permissions` 的重试)不再弹窗,由审批 Agent 在后台裁决并放行/拒绝。
79
+ 3. **审计**:设置 → **Agent 审批** → 审批记录;悬停"审批理由"看完整理由与工具参数。
80
+ 4. **配置**:同页设置审批模型(不选则用 Harness 默认模型)与审批超时。
81
+ 5. **关闭**:菜单切回其他预设,或 `/agent-approval off`,恢复开启前的沙箱模式与审批策略。
82
+
83
+ ## 目录结构
84
+
85
+ ```
86
+ dsh-agent-approval/
87
+ ├── index.js # Host 半:AgentApprovalService(审批瀑布抢占 + spawn 审批 Agent + 审计)
88
+ ├── client.js # Client 半:设置页「Agent 审批」+ 输入框开关 UI bundle
89
+ ├── typert.host.js # Typert Host manifest(agentApproval 6 个方法的描述)
90
+ ├── cordis.patch.yml # dsh bundle patch(挂载行 + permission 预设表覆盖)
91
+ ├── scripts/patch-glyph.mjs # 可选:权限菜单图标补丁(标准安装不自动执行)
92
+ ├── .github/workflows/ # GitHub Actions 发布
93
+ ├── AGENTS.md # 面向 AI agent 的开发指南(含踩坑)
94
+ └── LICENSE # MIT
95
+ ```
96
+
97
+ ## 开发
98
+
99
+ ```bash
100
+ npm run check # node --check index.js client.js typert.host.js
101
+ dsh plugin --profile web add /path/to/dsh-agent-approval # 安装/重装到本机 DSH profile
102
+ npm run patch:glyph # 可选:权限菜单图标
103
+ ```
104
+
105
+ 详见 [AGENTS.md](AGENTS.md)——记录了 DSH 正式插件(Host/Client/Typert 三件套)的完整机制、审批瀑布 prepend 抢占与结构化子代理裁决的踩坑。
106
+
107
+ ## License
108
+
109
+ 本项目遵循 [MIT License](LICENSE)。
110
+
111
+ > 本项目是基于 DeepSeek Harness 构建的社区插件,并非 DeepSeek 官方产品。
package/client.js ADDED
@@ -0,0 +1,523 @@
1
+ /**
2
+ * dsh-agent-approval — Client half (web bundle).
3
+ *
4
+ * Rendered by the DSH web shell via `window.__ModuleLoader__.load`. Adds:
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,
10
+ * - the latest approval audit records (verdict, risk, model, duration,
11
+ * rationale; hover for the full rationale + tool arguments).
12
+ *
13
+ * Session-level on/off lives in the /permission menu (the "Agent 审批"
14
+ * preset, registered by the package's cordis.patch.yml bundle patch) and the
15
+ * /agent-approval command — deliberately NO composer chip: a second toggle
16
+ * beside the permission menu it belongs to was redundant.
17
+ *
18
+ * Host communication goes through the `agentApproval` Remote namespace
19
+ * (`ctx.remote.agentApproval.*`), published by the Host half in `index.js`.
20
+ */
21
+ window.__ModuleLoader__.load({
22
+ id: "dsh-agent-approval",
23
+ factory: (require) => {
24
+ var module = { exports: {} };
25
+ var exports = module.exports;
26
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
27
+ const React = require("react");
28
+ // Official DSH design-system atoms (Button etc.). The Button variants are
29
+ // backed by the `--dsw-alias-button-*` token family, so light/dark themes
30
+ // are automatic (same pattern as dsh-memory-manager).
31
+ const ui = require("@deepseek-ai/dsh-client-ui-primitives");
32
+
33
+ // ---- CSS (package-owned, uses DSH design tokens) -------------------------
34
+ const CSS = `
35
+ .aapr-page{display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary);font-size:13px;max-width:820px}
36
+ .aapr-card{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:12px 14px;background:var(--dsw-alias-bg-layer-1);display:flex;flex-direction:column;gap:8px}
37
+ .aapr-card h3{margin:0;font-size:13px;font-weight:600}
38
+ .aapr-muted{color:var(--dsw-alias-label-secondary);line-height:1.5}
39
+ .aapr-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
40
+ .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);font-family:monospace}
42
+ .aapr-chip button{background:none;border:none;color:var(--dsw-alias-state-error-primary);cursor:pointer;font-size:12px;padding:0 2px}
43
+ .aapr-wrap{overflow-x:auto}
44
+ .aapr-table{width:100%;border-collapse:collapse;font-size:12px}
45
+ .aapr-table th{text-align:left;color:var(--dsw-alias-label-secondary);font-weight:500;padding:4px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap}
46
+ .aapr-table td{padding:4px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);vertical-align:top}
47
+ .aapr-cell{max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:normal}
48
+ .aapr-ok{color:var(--dsw-alias-state-success-primary);white-space:nowrap}
49
+ .aapr-no{color:var(--dsw-alias-state-error-primary);white-space:nowrap}
50
+
51
+ /* Settings nav icon: DSH 0.1.x settings.section only projects id/order/
52
+ label, and the settings shell paints a generic gear for every external
53
+ section (client-ui-settings-general's navIcon()). registerSettingsNavIcon
54
+ marks our own nav row; hide the shell's gear and draw the shield-check
55
+ Lucide glyph as a currentColor mask so it follows the native nav
56
+ hover/active colors without changing the shell's 16px icon rhythm. */
57
+ [data-dsh-agent-approval-settings-nav]>svg:first-child{display:none}
58
+ [data-dsh-agent-approval-settings-nav]::before{content:'';flex:none;width:16px;height:16px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z'/%3E%3Cpath d='m9 12 2 2 4-4'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z'/%3E%3Cpath d='m9 12 2 2 4-4'/%3E%3C/svg%3E") center/contain no-repeat}
59
+ `;
60
+
61
+ // ---- Settings nav icon --------------------------------------------------
62
+ // DSH 0.1.x does not yet carry an icon through the settings.section
63
+ // registration contract: its shell projects only id/order/label and
64
+ // paints a generic gear for every external section. Mark only this
65
+ // plugin's localized nav row so the CSS above can replace the fallback
66
+ // gear; the disposer clears the marker for HMR / plugin disable.
67
+ const SETTINGS_LABEL = "Agent 审批";
68
+ const SETTINGS_NAV_MARKER = "data-dsh-agent-approval-settings-nav";
69
+
70
+ function registerSettingsNavIcon(label) {
71
+ let disposed = false;
72
+ const sync = function () {
73
+ if (disposed) return;
74
+ const currentLabel = String(label).trim();
75
+ const buttons = document.querySelectorAll('[role="dialog"] nav button');
76
+ for (let i = 0; i < buttons.length; i++) {
77
+ const button = buttons[i];
78
+ const text = button.textContent ? button.textContent.trim() : "";
79
+ if (currentLabel.length > 0 && text === currentLabel) {
80
+ button.setAttribute(SETTINGS_NAV_MARKER, "");
81
+ } else {
82
+ button.removeAttribute(SETTINGS_NAV_MARKER);
83
+ }
84
+ }
85
+ };
86
+ sync();
87
+ const observer = new MutationObserver(sync);
88
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
89
+ return function () {
90
+ disposed = true;
91
+ observer.disconnect();
92
+ const marked = document.querySelectorAll("[" + SETTINGS_NAV_MARKER + "]");
93
+ for (let i = 0; i < marked.length; i++) marked[i].removeAttribute(SETTINGS_NAV_MARKER);
94
+ };
95
+ }
96
+
97
+ // ---- Client Remote contribution -------------------------------------------
98
+ // The browser-side `remote.agentApproval` service only exists after this
99
+ // module mounts its namespace via ctx.remote.$mount(): dsh-api-remotes'
100
+ // client assembly mounts only the official namespaces, so a plugin must
101
+ // mount its own. Mirrors the invocations in typert.host.js (id,
102
+ // service/namespace/method). zod is not requirable in the browser module
103
+ // loader, so codecs use passthrough schemas — the runtime contract only
104
+ // requires typeSymbol + schema.parse().
105
+ const passthrough = () => ({ parse: (v) => v });
106
+ const param = (typeSymbol) => [
107
+ {
108
+ name: "request",
109
+ wire: "request",
110
+ source: "json",
111
+ codec: { mode: "strict", typeSymbol, schema: passthrough() },
112
+ },
113
+ ];
114
+ const result = (typeSymbol) => ({
115
+ mode: "strict",
116
+ typeSymbol,
117
+ schema: passthrough(),
118
+ });
119
+ const CLIENT_REMOTE = {
120
+ package: "dsh-agent-approval",
121
+ descriptors: [
122
+ {
123
+ id: "dsh-agent-approval#agentApproval/getState",
124
+ service: "agentApproval",
125
+ namespace: "agentApproval",
126
+ method: "getState",
127
+ invocation: { kind: "direct" },
128
+ parameters: [],
129
+ result: result("dsh-agent-approval#AgentApprovalStateResult"),
130
+ },
131
+ {
132
+ id: "dsh-agent-approval#agentApproval/setModel",
133
+ service: "agentApproval",
134
+ namespace: "agentApproval",
135
+ method: "setModel",
136
+ invocation: { kind: "direct" },
137
+ parameters: param("dsh-agent-approval#AgentApprovalSetModelRequest"),
138
+ result: result("dsh-agent-approval#AgentApprovalSetModelResult"),
139
+ },
140
+ {
141
+ id: "dsh-agent-approval#agentApproval/setApprovalTimeout",
142
+ service: "agentApproval",
143
+ namespace: "agentApproval",
144
+ method: "setApprovalTimeout",
145
+ invocation: { kind: "direct" },
146
+ parameters: param("dsh-agent-approval#AgentApprovalSetTimeoutRequest"),
147
+ result: result("dsh-agent-approval#AgentApprovalSetTimeoutResult"),
148
+ },
149
+ {
150
+ id: "dsh-agent-approval#agentApproval/toggle",
151
+ service: "agentApproval",
152
+ namespace: "agentApproval",
153
+ method: "toggle",
154
+ invocation: { kind: "direct" },
155
+ parameters: param("dsh-agent-approval#AgentApprovalToggleRequest"),
156
+ result: result("dsh-agent-approval#AgentApprovalToggleResult"),
157
+ },
158
+ {
159
+ id: "dsh-agent-approval#agentApproval/clearRecords",
160
+ service: "agentApproval",
161
+ namespace: "agentApproval",
162
+ method: "clearRecords",
163
+ invocation: { kind: "direct" },
164
+ parameters: [],
165
+ result: result("dsh-agent-approval#AgentApprovalClearRecordsResult"),
166
+ },
167
+ {
168
+ id: "dsh-agent-approval#agentApproval/directory",
169
+ service: "agentApproval",
170
+ namespace: "agentApproval",
171
+ method: "directory",
172
+ invocation: { kind: "direct" },
173
+ parameters: [],
174
+ result: result("dsh-agent-approval#AgentApprovalDirectoryResult"),
175
+ },
176
+ ],
177
+ };
178
+
179
+ async function apply(ctx) {
180
+ // Mount the agentApproval namespace before anything touches it; the
181
+ // mount's lifetime is bound to this plugin's context by $mount itself.
182
+ await ctx.remote.$mount(CLIENT_REMOTE);
183
+
184
+ const styleTag = document.createElement("style");
185
+ styleTag.textContent = CSS;
186
+ document.head.appendChild(styleTag);
187
+ ctx.effect(() => () => styleTag.remove());
188
+
189
+ // Mark our settings-nav row so the CSS above replaces the shell's
190
+ // fallback gear (no icon field exists in settings.section yet).
191
+ ctx.effect(() => registerSettingsNavIcon(SETTINGS_LABEL));
192
+
193
+ // ctx.get() reads the service without the property-accessor inject guard.
194
+ const remote = ctx.get("remote.agentApproval");
195
+
196
+ // ---- helpers -----------------------------------------------------------
197
+
198
+ /**
199
+ * The Remote gateway returns `res.value` = the Host method's full
200
+ * `{ ok, value }` envelope; unwrap it (tolerate both shapes) and surface
201
+ * either error layer.
202
+ */
203
+ function pick(res) {
204
+ if (res && res.ok === false) {
205
+ const err = res.error || {};
206
+ throw new Error(err.message || err.code || "request failed");
207
+ }
208
+ const v = res && res.value;
209
+ if (v && typeof v === "object" && v.ok === false) {
210
+ const err = v.error || {};
211
+ throw new Error(err.message || err.code || "request failed");
212
+ }
213
+ if (v && typeof v === "object" && v.ok === true) return v.value;
214
+ return v;
215
+ }
216
+
217
+ const h = React.createElement;
218
+ const OUTCOME_LABEL = {
219
+ "allowed-once": "✅ 批准",
220
+ rejected: "⛔ 拒绝",
221
+ cancelled: "⚡ 已取消",
222
+ unavailable: "⛔ 失败未放行",
223
+ };
224
+ const RISK_LABEL = { low: "低", medium: "中", high: "高" };
225
+
226
+ function truncText(s, n) {
227
+ return typeof s === "string" && s.length > n ? s.slice(0, n) + "…" : s || "";
228
+ }
229
+
230
+ function fmtTime(iso) {
231
+ try {
232
+ const d = new Date(iso);
233
+ const p = (v) => String(v).padStart(2, "0");
234
+ return (
235
+ p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds())
236
+ );
237
+ } catch (e) {
238
+ return String(iso);
239
+ }
240
+ }
241
+
242
+ // NOTE: no composer chip anymore. The mode lives in the /permission
243
+ // menu as the "Agent 审批" preset (same place users switch Read-only /
244
+ // Workspace Write / Full access); a second toggle beside that menu was
245
+ // redundant. Enabling surfaces: the menu, the /agent-approval command,
246
+ // and this Settings page (whose session chips can also disable one).
247
+
248
+ // ---- settings page (settings.section) -----------------------------------
249
+
250
+ function AgentApprovalSection(props) {
251
+ const stateSlot = React.useState(null);
252
+ const setState = stateSlot[1];
253
+ const dirSlot = React.useState(null);
254
+ const setDir = dirSlot[1];
255
+ const providerSlot = React.useState("");
256
+ const setProvider = providerSlot[1];
257
+ const modelSlot = React.useState("");
258
+ const setModel = modelSlot[1];
259
+ const timeoutSlot = React.useState("");
260
+ const setTimeoutDraft = timeoutSlot[1];
261
+ const noteSlot = React.useState("");
262
+ const note = noteSlot[0];
263
+ const setNote = noteSlot[1];
264
+
265
+ const refresh = () => {
266
+ remote
267
+ .getState()
268
+ .then((res) => {
269
+ const s = pick(res);
270
+ setState(s);
271
+ setProvider(s.model.provider);
272
+ setModel(s.model.model);
273
+ setTimeoutDraft(String(s.timeoutMs));
274
+ })
275
+ .catch((e) => setNote("无法读取状态:" + (e && e.message ? e.message : String(e))));
276
+ };
277
+
278
+ React.useEffect(() => {
279
+ refresh();
280
+ remote
281
+ .directory()
282
+ .then((res) => setDir(pick(res)))
283
+ .catch(() => setDir({ providers: [], models: [], defaultSelection: null }));
284
+ }, []);
285
+
286
+ const state = stateSlot[0];
287
+ const dir = dirSlot[0];
288
+ const provider = providerSlot[0];
289
+ const model = modelSlot[0];
290
+ const timeoutDraft = timeoutSlot[0];
291
+
292
+ const saveModel = () => {
293
+ remote
294
+ .setModel({ provider: provider, model: model })
295
+ .then(() => {
296
+ refresh();
297
+ setNote("审批模型已保存");
298
+ })
299
+ .catch((e) => setNote("保存失败:" + (e && e.message ? e.message : String(e))));
300
+ };
301
+ const saveTimeout = () => {
302
+ const parsed = Number(timeoutDraft);
303
+ if (!Number.isFinite(parsed)) {
304
+ setNote("超时必须是数字(毫秒)");
305
+ return;
306
+ }
307
+ remote
308
+ .setApprovalTimeout({ timeoutMs: parsed })
309
+ .then((res) => {
310
+ const v = pick(res);
311
+ setTimeoutDraft(String(v.timeoutMs));
312
+ setNote("审批超时已保存");
313
+ })
314
+ .catch((e) => setNote("保存失败:" + (e && e.message ? e.message : String(e))));
315
+ };
316
+ const disableSession = (sid) => {
317
+ remote
318
+ .toggle({ sessionId: sid, on: false })
319
+ .then(refresh)
320
+ .catch(() => {});
321
+ };
322
+ const clearRecords = () => {
323
+ remote
324
+ .clearRecords()
325
+ .then(refresh)
326
+ .catch(() => {});
327
+ };
328
+
329
+ const providerOptions = [{ id: "", name: "默认(Harness 默认模型)" }].concat(
330
+ dir ? dir.providers : [],
331
+ );
332
+ const modelOptions = [{ provider: "", id: "", name: "默认(Harness 默认模型)" }].concat(
333
+ dir && dir.models ? dir.models.filter((m) => m.provider === provider) : [],
334
+ );
335
+ const defaultHint =
336
+ dir && dir.defaultSelection
337
+ ? "未配置时使用 Harness 默认模型;当前默认路由:" +
338
+ dir.defaultSelection.provider +
339
+ " / " +
340
+ dir.defaultSelection.model
341
+ : "未配置时使用 Harness 默认模型路由";
342
+
343
+ return h(
344
+ "div",
345
+ { className: "aapr-page" },
346
+ h(
347
+ "div",
348
+ { className: "aapr-card" },
349
+ h("h3", null, "Agent 审批权限"),
350
+ h(
351
+ "div",
352
+ { className: "aapr-muted" },
353
+ "一种新的权限模式:以 workspace-write 为基线沙箱;当工具请求提权(更宽的沙箱)时,由一个独立的审批 Agent 评估风险——安全、可逆、与任务相符的操作自动批准,破坏性、不可逆、越界或理由不符的操作直接拒绝。",
354
+ h("br", null),
355
+ "在输入框 /permission 菜单选择「Agent 审批」预设,或执行命令 /agent-approval on|off 为会话开启;审批记录见下方审计。",
356
+ ),
357
+ ),
358
+ h(
359
+ "div",
360
+ { className: "aapr-card" },
361
+ h("h3", null, "审批模型"),
362
+ h(
363
+ "div",
364
+ { className: "aapr-row" },
365
+ h(
366
+ "label",
367
+ null,
368
+ "Provider:",
369
+ h(
370
+ "select",
371
+ {
372
+ className: "aapr-select",
373
+ value: provider,
374
+ onChange: (e) => {
375
+ setProvider(e.target.value);
376
+ setModel("");
377
+ },
378
+ },
379
+ providerOptions.map((p) =>
380
+ h("option", { key: p.id, value: p.id }, p.id === "" ? p.name : p.name + "(" + p.id + ")"),
381
+ ),
382
+ ),
383
+ ),
384
+ h(
385
+ "label",
386
+ null,
387
+ "Model:",
388
+ h(
389
+ "select",
390
+ {
391
+ className: "aapr-select",
392
+ value: model,
393
+ onChange: (e) => setModel(e.target.value),
394
+ disabled: dir === null,
395
+ },
396
+ modelOptions.map((m) =>
397
+ h("option", { key: m.provider + "/" + m.id, value: m.id }, m.id === "" ? m.name : m.name + "(" + m.id + ")"),
398
+ ),
399
+ ),
400
+ ),
401
+ h(ui.Button, { variant: "primary", size: "sm", onClick: saveModel }, "保存"),
402
+ ),
403
+ h("div", { className: "aapr-muted" }, defaultHint),
404
+ ),
405
+ h(
406
+ "div",
407
+ { className: "aapr-card" },
408
+ h("h3", null, "审批超时"),
409
+ h(
410
+ "div",
411
+ { className: "aapr-row" },
412
+ h("input", {
413
+ className: "aapr-input",
414
+ type: "number",
415
+ value: timeoutDraft,
416
+ onChange: (e) => setTimeoutDraft(e.target.value),
417
+ }),
418
+ h("span", { className: "aapr-muted" }, "毫秒(30000–600000,超时按拒绝处理,fail-closed)"),
419
+ h(ui.Button, { variant: "primary", size: "sm", onClick: saveTimeout }, "保存"),
420
+ ),
421
+ ),
422
+ h(
423
+ "div",
424
+ { className: "aapr-card" },
425
+ h("h3", null, "已开启的会话"),
426
+ state === null
427
+ ? h("div", { className: "aapr-muted" }, "加载中…")
428
+ : state.enabledSessions.length === 0
429
+ ? h("div", { className: "aapr-muted" }, "当前没有会话开启 Agent 审批。")
430
+ : h(
431
+ "div",
432
+ { className: "aapr-row" },
433
+ state.enabledSessions.map((sid) =>
434
+ h(
435
+ "span",
436
+ { key: sid, className: "aapr-chip" },
437
+ String(sid).slice(0, 8),
438
+ h("button", { onClick: () => disableSession(sid), title: "关闭该会话的 Agent 审批" }, "✕"),
439
+ ),
440
+ ),
441
+ ),
442
+ ),
443
+ h(
444
+ "div",
445
+ { className: "aapr-card" },
446
+ h("h3", null, "审批记录(最近 50 条,本地持久化保留 200 条,重启不丢)"),
447
+ h(
448
+ "div",
449
+ { className: "aapr-row" },
450
+ h(ui.Button, { variant: "ghost", size: "sm", onClick: refresh }, "刷新"),
451
+ h(ui.Button, { variant: "ghost", size: "sm", onClick: clearRecords }, "清空记录"),
452
+ note !== "" ? h("span", { className: "aapr-muted" }, note) : null,
453
+ ),
454
+ h(
455
+ "div",
456
+ { className: "aapr-wrap" },
457
+ h(
458
+ "table",
459
+ { className: "aapr-table" },
460
+ h(
461
+ "thead",
462
+ null,
463
+ h(
464
+ "tr",
465
+ null,
466
+ ["时间", "会话", "工具", "结果", "风险", "模型", "耗时", "审批理由"].map((t) => h("th", { key: t }, t)),
467
+ ),
468
+ ),
469
+ h(
470
+ "tbody",
471
+ null,
472
+ state !== null && state.records
473
+ ? state.records.map((r, i) =>
474
+ h(
475
+ "tr",
476
+ { key: String(i) + "-" + String(r.at) },
477
+ h("td", null, fmtTime(r.at)),
478
+ h("td", null, String(r.sessionId)),
479
+ h("td", null, String(r.toolName)),
480
+ h("td", { className: r.outcome === "allowed-once" ? "aapr-ok" : "aapr-no" }, OUTCOME_LABEL[r.outcome] || String(r.outcome)),
481
+ h("td", null, RISK_LABEL[r.riskLevel] || String(r.riskLevel || "-")),
482
+ h("td", null, String(r.model)),
483
+ h("td", null, String(r.durationMs) + "ms"),
484
+ h(
485
+ "td",
486
+ {
487
+ className: "aapr-cell",
488
+ title:
489
+ (r.rationale || "") +
490
+ (r.args ? "\n\n工具参数:" + r.args : "") +
491
+ (r.childSessionId ? "\n\n审批会话:" + r.childSessionId : ""),
492
+ },
493
+ truncText(r.rationale, 110),
494
+ ),
495
+ ),
496
+ )
497
+ : h("tr", null, h("td", { colSpan: 8 }, h("span", { className: "aapr-muted" }, "暂无记录"))),
498
+ ),
499
+ ),
500
+ ),
501
+ h(
502
+ "div",
503
+ { className: "aapr-muted" },
504
+ "悬停“审批理由”可查看完整理由与工具参数;“审批会话”前缀可在会话列表中找到审批 Agent 的完整会话记录。",
505
+ ),
506
+ ),
507
+ );
508
+ }
509
+
510
+ // Settings entry: a full page under the sidebar Settings panel.
511
+ ctx.slots.inject("settings.section", () =>
512
+ ctx.slots.register(
513
+ { name: "settings.section", id: "agent-approval", order: 30, label: () => SETTINGS_LABEL },
514
+ AgentApprovalSection,
515
+ ),
516
+ );
517
+ }
518
+
519
+ exports.apply = apply;
520
+ exports.inject = ["slots", "remote"];
521
+ return module.exports;
522
+ },
523
+ });