Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5511091cd3 |
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Keep the shipped bundles byte-identical to what the plugin writes.
|
||||
# Keep shipped bundles byte-identical to what the plugin writes.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows-only helper scripts keep their CRLF behaviour.
|
||||
# Windows-only helper scripts keep CRLF.
|
||||
*.ps1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
|
||||
+72
-1
@@ -1,12 +1,83 @@
|
||||
# 变更记录 / Changelog
|
||||
|
||||
对照基线:**1.2.0**(上游最后一代,`dshTarget: 0.2.1-rc.2`)。
|
||||
本文件只记 1.3.x 分支相对 1.2.0 的**新增、优化、修复**,以及会影响到你的行为变化。
|
||||
本文件只记 1.3.x / 1.4.x 分支相对 1.2.0 的**新增、优化、修复**,以及会影响到你的行为变化。
|
||||
|
||||
Baseline: **1.2.0**. Everything below is what this fork adds, improves, or fixes on top of it.
|
||||
|
||||
---
|
||||
|
||||
## 1.4.0 — 借 IG5 的做法加固四处工程细节
|
||||
|
||||
参考 [Minglink/dsh-infinite-gen-5](https://github.com/Minglink/dsh-infinite-gen-5) 的 `source/json_output.js`、`source/attachment_lease.js`、审批门与 CAS 修订号模式,挑了四处**与本插件能力相符**的改进。不搬它的逆向引擎、worker 协议、Ghidra / x64dbg 适配层——那些属于另一类插件。
|
||||
|
||||
### 新增:严格 JSON 输出边界(`lib/json-safe.js`)
|
||||
|
||||
宿主会在渲染前校验工具结果。旧实现全程 `JSON.stringify`,三种情况会出事:
|
||||
|
||||
| 情况 | 旧行为 | 现在 |
|
||||
| --- | --- | --- |
|
||||
| `BigInt`(如 64 位地址被算成 BigInt) | 抛异常 → 整个响应 500 | 投影阶段报 `INVALID_PLUGIN_OUTPUT` 并指出路径 |
|
||||
| 循环引用 | 抛异常 → 500 | 同上,带 `at` 定位 |
|
||||
| 字段值 `undefined` | **静默丢字段** → 界面以为「没数据」 | 省略该字段但保留 `null`;不可靠值转成 `{__invalid,__reason,__at}` |
|
||||
|
||||
另外拒绝 `-0`、`NaN`、`Infinity`、Symbol、Function、稀疏数组、带额外属性的数组、accessor、非普通对象(class 实例)。深度上限 64、节点上限 20 万,超限报错而非截断。64 位地址保持字符串精度。
|
||||
|
||||
`sendJson` 全部改走 `stringifySafe` —— **永不抛**,坏字段变成可诊断标记,而不是把 200 变成 500。
|
||||
|
||||
### 新增:文件身份与写入守卫(`lib/host-lease.js`)
|
||||
|
||||
改宿主文件之前要先确认「我改的还是我看到的那个文件」。旧实现只比路径字符串,三种情况会被骗:
|
||||
|
||||
1. 目标被换成符号链接 / 目录联接 → 看到的是另一份文件;
|
||||
2. 同一路径下文件被替换(重装、换版本)→ 路径对、内容不对;
|
||||
3. Windows 路径大小写不敏感,`C:\App` 与 `c:\app` 会被当成两份。
|
||||
|
||||
现在写前取一份 identity(真实路径 + size + mtime + 前 64 KiB 的 SHA-256),写完再取一次,不一致报 `changed` / `vanished`。另附 `writeFileAtomic`(同目录临时文件 → rename),保留原文件 mode。
|
||||
|
||||
### 新增:审批档位与审计流水(`lib/tool-gate.js`)
|
||||
|
||||
`purge_apply` / `purge_revert` / `purge_uninstall` 三个破坏性工具以前没有任何「这次调用批没批」的记录。现在:
|
||||
|
||||
- **审计永远记**:`$DSH_HOME/dsh-purge/audit.jsonl`,每次调用一行(intent / allow / deny / result),失败也记;审计写不进去绝不影响工具结果。
|
||||
- **审批可选**,`approvalMode` 两档:
|
||||
- `audit`(默认):只记流水,行为与旧版**完全一致**,不弹任何东西。
|
||||
- `enforce`:走宿主 `approval.request`,只有明确 `allowed-once` 才 `next()`。缺 agent、缺 approval 服务、抛错、未知结果、signal 已 abort —— 一律 deny 不派发,**从不缓存上一次批准**。
|
||||
|
||||
> 默认不是 enforce 的原因:本插件的主入口是人自己点「应用」,人就是审批者,再弹一次只是多一次点击;而本机策略可能把 approval 一律自动拒绝,强制审批等于把插件锁死。需要更严时(模型自动跑长任务)再切 enforce。
|
||||
|
||||
- 新工具 **`purge_audit`**:读回审计尾部,让模型在长任务里能自查「上一次 apply 到底批没批、结果如何」。
|
||||
- 设置页新增「审批与审计」区块:单选档位 + 展开查看流水(`.dshp-audit`,等宽、限高滚动)。
|
||||
|
||||
### 新增:状态修订号(`lib/revision.js`)
|
||||
|
||||
设置页每 5 秒轮询 `/status`,Web 与桌面可能同时开着。旧实现里 apply / revert 无条件执行,后点的会静默覆盖先点的结果。
|
||||
|
||||
现在状态机有单调递增的 `rev`:`/status` 带出,动作时带回,不一致返回 **409 + 当前 rev + 最后一次动作**。客户端收到 409 提示「另一个窗口刚改过状态」并自动重新拉取,而不是留着旧数字让用户重复点击。不持久化——重启后归零是正确语义。
|
||||
|
||||
### 修复:`hooksDenyBypass` 保存时抹掉同文件其他键
|
||||
|
||||
`saveHookDenySettings` 原来只重建 `hooksDenyBypass` 一个键再覆盖整文件。`approvalMode` 住进同一个 `hooks-deny.json` 后,切一次 hooks 开关就会把审批档位悄悄重置。改成合并写入(实测:`approvalMode` 与 `hooksDenyBypass` 同时保留)。
|
||||
|
||||
### 优化:请求体错误分类
|
||||
|
||||
`readJsonBody` 原来只抛一句 `Error`,所有异常都压成 500,界面无法区分「内容太大」和「JSON 写错了」。现在按 `BODY_TOO_LARGE`(413)、`BODY_INVALID_JSON`(400)、`BODY_NOT_OBJECT`(400)分类,19 处 catch 统一走 `bodyErrorStatus`。
|
||||
|
||||
### 新增接口
|
||||
|
||||
| 路由 | 方法 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `/dsh-purge/approval-mode` | GET / POST | 读/写审批档位,重启后生效 |
|
||||
| `/dsh-purge/audit` | GET | 读审计尾部(`?limit=1..200`,默认 40) |
|
||||
|
||||
`/dsh-purge/status` 新增 `rev`、`last_action`、`approval_mode` 三个字段。
|
||||
|
||||
### 测试
|
||||
|
||||
71 项离线断言(json-safe 20 / host-lease 13 / revision 11 / tool-gate 27),不启动宿主、不碰真实工程。
|
||||
|
||||
---
|
||||
|
||||
## 一、新增 / New
|
||||
|
||||
### 1. 设置页成了正式界面(1.3.0)
|
||||
|
||||
@@ -49,7 +49,20 @@ The upstream project requires attribution and a link back to its repository when
|
||||
- `#40 COMPLETE_PROMPT_KEEP_INJECT` 在 0.2.1 上原来一条 pattern 都不命中:0.2.1 在 `return transformed;` 与 `return {…}` 之间多插了一行 `const requiredContexts = new Set(…)`。状态是 `skipped` 不算失败,但重启前的校验仍能看到 `sections: … : [completeSection]`,于是判定「没洗过」并拒绝重启——表现为点「应用」后提示「清洗没有写进全部宿主,已取消重启」。
|
||||
- 拆成两条只认关键行的替换后,该补丁在 0.2.1 原树上直接 `applied`。
|
||||
|
||||
完整变更见 [release-notes.md](release-notes.md) 与 [docs/compat-0.2.1.md](docs/compat-0.2.1.md)。
|
||||
完整变更见 [CHANGELOG.md](CHANGELOG.md) 与 [docs/compat-0.2.1.md](docs/compat-0.2.1.md)。
|
||||
|
||||
### 1.4.0 的借鉴来源 / What 1.4.0 borrows
|
||||
|
||||
1.4.0 的四处工程加固来自对 **[Minglink/dsh-infinite-gen-5](https://github.com/Minglink/dsh-infinite-gen-5)**(无限五代,CC BY-NC-SA 4.0)的阅读,特此标明来源:
|
||||
|
||||
| 本仓库的实现 | 参考的 IG5 做法 |
|
||||
| --- | --- |
|
||||
| `lib/json-safe.js` 严格 JSON 输出 | `source/json_output.js`:可选字段的 undefined 省略、拒绝 -0 / BigInt / 循环 / accessor / 稀疏数组、64 位地址用字符串 |
|
||||
| `lib/host-lease.js` 文件身份与写入守卫 | `source/attachment_lease.js`:owner + nonce 的 fail-closed 思路(本项目只取「改文件前确认对象没被换」这部分) |
|
||||
| `lib/tool-gate.js` 审批与审计 | `index.js` 的 `installApprovalGate`:显式要 `allowed-once`、不缓存、失败方向为 deny、post-execute 记流水 |
|
||||
| `lib/revision.js` 状态修订号 | 各模块的 `expected_revision` / CAS 前置校验模式 |
|
||||
|
||||
**没有搬的部分**:IG5 的逆向引擎适配层、Python worker 协议、Ghidra / x64dbg 集成、微码与仿真。这些属于「另一个类别的插件」,与本插件的用途不重叠。本仓库也没有复制 IG5 的代码文本——四处实现均按本项目的结构与命名重写,注释为中文并针对本插件场景。如认为某处构成不当复用,请按 [DISCLAIMER.md](DISCLAIMER.md) 第 6 节联系。
|
||||
|
||||
## 仓库里不包含什么 / What is not in this repository
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
<h1 align="center">⚔️ DeepSeek Harness cybersecurity red-team tool (dsh-purge)</h1>
|
||||
|
||||
<p align="center"><strong>Version 1.3.1 · fork</strong> · targets DSH <strong>0.2.1-rc.2</strong></p>
|
||||
<p align="center"><strong>Version 1.4.0 · fork</strong> · targets DSH <strong>0.2.1-rc.2</strong></p>
|
||||
|
||||
> **This is a fork, not upstream.** Original project: [YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge) by **小杨** ([@YuJunZhiXue](https://github.com/YuJunZhiXue)), MIT licence (`Copyright (c) 2026 小杨`, kept verbatim here).
|
||||
> This branch is maintained by **[@JeffKeeping](https://github.com/JeffKeeping)** and only adds **0.2.1-rc.2 compatibility, a stronger prompt kernel, a rebuilt UI, and one patch-anchor fix**. It does not change what the plugin does.
|
||||
> What changed against the previous release (1.2.0), item by item: **[CHANGELOG.md](CHANGELOG.md)**.
|
||||
> 1.4.0 borrows the approval tier, audit trail, strict JSON boundary and revision guard from [Minglink/dsh-infinite-gen-5](https://github.com/Minglink/dsh-infinite-gen-5); scope and boundaries in [NOTICE.md](NOTICE.md).
|
||||
> Changes and attribution: [NOTICE.md](NOTICE.md). Preconditions, no-warranty and legal terms: [DISCLAIMER.md](DISCLAIMER.md).
|
||||
> The upstream **non-commercial** term carries over: no paid resale, no gray- or black-market profit.
|
||||
|
||||
|
||||
+2
-1
@@ -4,11 +4,12 @@
|
||||
|
||||
<h1 align="center">⚔️ DeepSeek Harness 网络安全红队工具(dsh-purge)</h1>
|
||||
|
||||
<p align="center"><strong>Version 1.3.1 · 本分支 fork</strong> · 适配 DSH <strong>0.2.1-rc.2</strong></p>
|
||||
<p align="center"><strong>Version 1.4.0 · 本分支 fork</strong> · 适配 DSH <strong>0.2.1-rc.2</strong></p>
|
||||
|
||||
> **这是 fork,不是上游。** 原项目:[YuJunZhiXue/dsh-purge](https://github.com/YuJunZhiXue/dsh-purge),原作者 **小杨**([@YuJunZhiXue](https://github.com/YuJunZhiXue)),MIT 许可证(`Copyright (c) 2026 小杨`,本仓库原样保留)。
|
||||
> 本分支由 **[@JeffKeeping](https://github.com/JeffKeeping)** 维护,只做 **0.2.1-rc.2 适配 + 破甲内核升级 + 界面重做 + 一处补丁锚点修复**,不改变插件用途与能力范围。
|
||||
> 逐条对比上一版(1.2.0)优化与新增了什么:**[CHANGELOG.md](CHANGELOG.md)**。
|
||||
> 1.4.0 的审批档位 / 审计流水 / 严格 JSON / 修订号借鉴了 [Minglink/dsh-infinite-gen-5](https://github.com/Minglink/dsh-infinite-gen-5),来源与边界见 [NOTICE.md](NOTICE.md)。
|
||||
> 变更清单与署名要求见 [NOTICE.md](NOTICE.md);使用前提、无担保与法律声明见 [DISCLAIMER.md](DISCLAIMER.md)。
|
||||
> 上游的**非商用**条款沿用:禁止商业售卖、付费倒卖与黑灰产牟利。
|
||||
|
||||
|
||||
@@ -278,6 +278,7 @@ window.__ModuleLoader__.load({ id: "dsh-purge", factory: (require) => {
|
||||
"ok.done": "已完成",
|
||||
"err.action": "失败: {error}",
|
||||
"err.status": "读取失败: {error}",
|
||||
"err.stale": "另一个窗口刚改过状态(现在是 rev {rev},最后一次动作 {last})。已重新读取,请确认后再点。",
|
||||
"err.override": "读取失败: {error}",
|
||||
"err.rules": "读取失败: {error}",
|
||||
"err.read": "读取失败: {error}",
|
||||
@@ -459,6 +460,17 @@ window.__ModuleLoader__.load({ id: "dsh-purge", factory: (require) => {
|
||||
"rewind.once.hint": "把这一轮发出去的那句放回输入框,并清掉这一轮",
|
||||
"rewind.round": "回退上一轮",
|
||||
"rewind.round.hint": "把这一轮发出去的那句放回输入框;子代理也只撤这一轮",
|
||||
"gate.title": "审批与审计",
|
||||
"gate.hint": "审计永远记流水。审批档位只影响模型发起的 purge_apply / purge_revert / purge_uninstall;你自己点按钮不受影响。",
|
||||
"gate.mode.audit": "只记流水(默认,行为同旧版)",
|
||||
"gate.mode.enforce": "要求一次批准(allowed-once)",
|
||||
"gate.gated": "受管工具:{list}",
|
||||
"gate.saved": "已保存,重启 dsh 后对工具调用生效。",
|
||||
"gate.audit.show": "查看审计流水",
|
||||
"gate.audit.hide": "收起审计流水",
|
||||
"gate.audit.refresh": "刷新",
|
||||
"gate.audit.busy": "读取中…",
|
||||
"gate.audit.empty": "还没有记录。",
|
||||
"hooksDeny.title": "Hooks 拦截",
|
||||
"hooksDeny.bypass": "放行 hooks 的 deny / ask(补丁 30 / 31 / 64 / 65)",
|
||||
"hooksDeny.hint": "关掉后,用户自己的 PreToolUse / UserPromptSubmit / Stop hook 的 deny 会按官方逻辑生效。官方 ask 分支本身是空操作,关开关不会弹出审批框。改完后点「应用」并重启才写进宿主。",
|
||||
@@ -632,6 +644,7 @@ window.__ModuleLoader__.load({ id: "dsh-purge", factory: (require) => {
|
||||
"ok.done": "Done",
|
||||
"err.action": "Failed: {error}",
|
||||
"err.status": "Read failed: {error}",
|
||||
"err.stale": "Another window changed the state (now rev {rev}, last action {last}). Re-read; check before clicking again.",
|
||||
"err.override": "Read failed: {error}",
|
||||
"err.rules": "Read failed: {error}",
|
||||
"err.read": "Read failed: {error}",
|
||||
@@ -813,6 +826,17 @@ window.__ModuleLoader__.load({ id: "dsh-purge", factory: (require) => {
|
||||
"rewind.once.hint": "Put this round's message back in the box and drop only this round",
|
||||
"rewind.round": "Undo last round",
|
||||
"rewind.round.hint": "Put this round's message back in the box; subagents drop only this round",
|
||||
"gate.title": "Approval and audit",
|
||||
"gate.hint": "Audit always records. The approval tier only affects model-issued purge_apply / purge_revert / purge_uninstall; your own clicks are unaffected.",
|
||||
"gate.mode.audit": "Record only (default, same as before)",
|
||||
"gate.mode.enforce": "Require one-time approval (allowed-once)",
|
||||
"gate.gated": "Gated tools: {list}",
|
||||
"gate.saved": "Saved; applies to tool calls after dsh restarts.",
|
||||
"gate.audit.show": "Show audit trail",
|
||||
"gate.audit.hide": "Hide audit trail",
|
||||
"gate.audit.refresh": "Refresh",
|
||||
"gate.audit.busy": "Reading…",
|
||||
"gate.audit.empty": "No records yet.",
|
||||
"hooksDeny.title": "Hook interception",
|
||||
"hooksDeny.bypass": "Bypass hook deny / ask (patches 30 / 31 / 64 / 65)",
|
||||
"hooksDeny.hint": "When off, your own PreToolUse / UserPromptSubmit / Stop hook deny runs as the official host wrote it. The official ask branch is a no-op; turning this off will not show an approval dialog. Click Apply and restart to write the host files.",
|
||||
@@ -919,6 +943,13 @@ window.__ModuleLoader__.load({ id: "dsh-purge", factory: (require) => {
|
||||
.dshp-actions{text-align:right;white-space:nowrap}
|
||||
.dshp-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.dshp-row+.dshp-row{margin-top:10px}
|
||||
/* 审批档位:与上方 hooks 开关同区块,靠发丝线分隔,不再套一层卡片 */
|
||||
.dshp-gate{margin-top:12px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2,var(--dshp-line))}
|
||||
.dshp-gate .dshp-hint{margin:0 0 6px;color:var(--dsw-alias-label-tertiary,var(--dshp-mute));font-size:12px;line-height:18px}
|
||||
.dshp-gate label{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--dsw-alias-label-primary,inherit)}
|
||||
.dshp-audit{max-height:220px;overflow:auto;margin:8px 0 0;padding:10px 12px;border-radius:var(--dsw-radius-md,12px);
|
||||
background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.03));font-family:var(--dsw-font-family-mono,ui-monospace,monospace);
|
||||
font-size:11.5px;line-height:17px;white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-secondary,var(--dshp-mute))}
|
||||
/* 状态胶囊:中性底 + 语义点,不再用彩色描边 */
|
||||
.dshp-pill{display:inline-flex;align-items:center;gap:6px;font-family:inherit;font-size:12px;line-height:18px;padding:1px 8px;border:.5px solid var(--dsw-alias-border-l3,var(--dshp-line));border-radius:999px;background:transparent;color:var(--dshp-mute);white-space:nowrap}
|
||||
.dshp-pill::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
|
||||
@@ -1287,6 +1318,111 @@ body:not([data-ds-dark-theme]) .dshp-auth-legal{background:var(--dsw-alias-bg-la
|
||||
});
|
||||
}
|
||||
|
||||
/* 审批档位 + 审计流水。
|
||||
* 两个诉求分开:审计永远记(谁调的、批没批、结果如何),审批只是可选门槛。
|
||||
* 默认 audit 表示行为与旧版一致,仅多一份流水;enforce 才要求 allowed-once。 */
|
||||
function GateSection({ t }) {
|
||||
const tr = typeof t === "function" ? t : ((key) => key);
|
||||
const [mode, setMode] = useState(null);
|
||||
const [gated, setGated] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState({ kind: "idle", text: "" });
|
||||
const [auditOpen, setAuditOpen] = useState(false);
|
||||
const [auditText, setAuditText] = useState("");
|
||||
const [auditBusy, setAuditBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiJson("/dsh-purge/approval-mode")
|
||||
.then((d) => {
|
||||
if (d && d.ok) {
|
||||
setMode(d.mode === "enforce" ? "enforce" : "audit");
|
||||
setGated(Array.isArray(d.gated) ? d.gated : []);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const save = (next) => {
|
||||
if (busy || !next) return;
|
||||
const previous = mode;
|
||||
setMode(next);
|
||||
setBusy(true);
|
||||
apiJson("/dsh-purge/approval-mode", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: next }),
|
||||
})
|
||||
.then((d) => {
|
||||
if (d && d.ok) {
|
||||
setNotice({ kind: "ok", text: tr("gate.saved") });
|
||||
} else {
|
||||
setMode(previous);
|
||||
setNotice({ kind: "error", text: tr("err.save", { error: (d && d.error) || "" }) });
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setMode(previous);
|
||||
setNotice({ kind: "error", text: tr("err.save", { error: e.message }) });
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const loadAudit = () => {
|
||||
setAuditBusy(true);
|
||||
apiJson("/dsh-purge/audit?limit=40")
|
||||
.then((d) => setAuditText(d && d.ok ? String(d.text || "") : tr("err.status", { error: (d && d.error) || "" })))
|
||||
.catch((e) => setAuditText(tr("err.status", { error: e.message })))
|
||||
.finally(() => setAuditBusy(false));
|
||||
};
|
||||
|
||||
const toggleAudit = () => {
|
||||
const next = !auditOpen;
|
||||
setAuditOpen(next);
|
||||
if (next) loadAudit();
|
||||
};
|
||||
|
||||
if (!mode) return null;
|
||||
return h("div", { className: "dshp-gate" },
|
||||
h("div", { className: "dshp-sub" },
|
||||
h("h4", null, tr("gate.title")),
|
||||
noticeNode(notice),
|
||||
),
|
||||
h("p", { className: "dshp-hint" }, tr("gate.hint")),
|
||||
h("div", { className: "dshp-row" },
|
||||
h("label", null,
|
||||
h("input", {
|
||||
type: "radio",
|
||||
name: "dshp-approval-mode",
|
||||
checked: mode === "audit",
|
||||
disabled: busy,
|
||||
onChange: () => save("audit"),
|
||||
}),
|
||||
tr("gate.mode.audit"),
|
||||
),
|
||||
h("label", null,
|
||||
h("input", {
|
||||
type: "radio",
|
||||
name: "dshp-approval-mode",
|
||||
checked: mode === "enforce",
|
||||
disabled: busy,
|
||||
onChange: () => save("enforce"),
|
||||
}),
|
||||
tr("gate.mode.enforce"),
|
||||
),
|
||||
),
|
||||
gated.length
|
||||
? h("p", { className: "dshp-hint" }, tr("gate.gated", { list: gated.join(", ") }))
|
||||
: null,
|
||||
h("div", { className: "dshp-row" },
|
||||
h(Btn, { tiny: true, onClick: toggleAudit }, auditOpen ? tr("gate.audit.hide") : tr("gate.audit.show")),
|
||||
auditOpen ? h(Btn, { tiny: true, disabled: auditBusy, onClick: loadAudit }, auditBusy ? tr("gate.audit.busy") : tr("gate.audit.refresh")) : null,
|
||||
),
|
||||
auditOpen
|
||||
? h("pre", { className: "dshp-audit" }, auditBusy ? tr("gate.audit.busy") : (auditText || tr("gate.audit.empty")))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
function HooksDenySection({ onSaved }) {
|
||||
const t = useT();
|
||||
const [cfg, setCfg] = useState(null);
|
||||
@@ -1476,6 +1612,10 @@ body:not([data-ds-dark-theme]) .dshp-auth-legal{background:var(--dsw-alias-bg-la
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const [state, setState] = useState(null);
|
||||
/* doAction 是 useCallback、依赖数组很短,直接闭包读 state 会永远拿到第一次的值。
|
||||
* 用 ref 镜像当前状态,动作时才读真实 rev。 */
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const [override, setOverride] = useState("");
|
||||
const [defaultOverride, setDefaultOverride] = useState("");
|
||||
const [overrideLoaded, setOverrideLoaded] = useState(false);
|
||||
@@ -1719,14 +1859,25 @@ body:not([data-ds-dark-theme]) .dshp-auth-legal{background:var(--dsw-alias-bg-la
|
||||
setAskRestart(false);
|
||||
setNotice({ kind: "idle", text: "" });
|
||||
const applyContent = action === "apply" ? readPromptBox() : "";
|
||||
/* 带上当前修订号:两个窗口同时开着,后点的那个会被服务端 409 挡住,
|
||||
* 而不是静默覆盖先点的结果。rev 来自 /status 与上一次动作的响应。 */
|
||||
const body = action === "apply" ? { content: applyContent, rev: stateRef.current?.rev } : { rev: stateRef.current?.rev };
|
||||
fetch("/dsh-purge/" + action, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(action === "apply" ? { content: applyContent } : {}),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
.then((r) => r.json().then((d) => ({ status: r.status, d })))
|
||||
.then(({ status, d }) => {
|
||||
if (ticket !== actionTicket.current) return;
|
||||
if (status === 409 && d && d.stale) {
|
||||
/* 过期写入:说明别的窗口已经动过。提示后自动重新拉一次状态,
|
||||
* 让界面回到真实值,而不是留着旧数字让用户重复点击。 */
|
||||
setNotice({ kind: "error", text: tr("err.stale", { rev: String(d.rev ?? "?"), last: String(d.last?.action || "?") }) });
|
||||
if (typeof d.rev === "number") setState((prev) => ({ ...(prev || {}), rev: d.rev }));
|
||||
loadAll();
|
||||
return;
|
||||
}
|
||||
if (responseNeedsPrompt(d)) {
|
||||
rejectNeedPrompt(tr);
|
||||
return;
|
||||
@@ -1735,6 +1886,7 @@ body:not([data-ds-dark-theme]) .dshp-auth-legal{background:var(--dsw-alias-bg-la
|
||||
setNotice({ kind: "error", text: tr("err.action", { action: label, error: d.error || "" }) });
|
||||
return;
|
||||
}
|
||||
if (typeof d.rev === "number") setState((prev) => ({ ...(prev || {}), rev: d.rev }));
|
||||
if (action === "apply") {
|
||||
if (typeof d.defaultContent === "string") setDefaultOverride(d.defaultContent);
|
||||
if (typeof d.override_content === "string") {
|
||||
@@ -1799,7 +1951,6 @@ body:not([data-ds-dark-theme]) .dshp-auth-legal{background:var(--dsw-alias-bg-la
|
||||
};
|
||||
run();
|
||||
}, [loadAll, rejectNeedPrompt, state]);
|
||||
|
||||
const saveOverride = useCallback(() => {
|
||||
const tr = t;
|
||||
const ticket = ++actionTicket.current;
|
||||
@@ -2052,6 +2203,7 @@ body:not([data-ds-dark-theme]) .dshp-auth-legal{background:var(--dsw-alias-bg-la
|
||||
h("div", { className: "dshp-bar", "aria-hidden": "true" }, h("i", { style: { width: pct + "%" } })),
|
||||
h(PatchGroups, { state: s }),
|
||||
h(HooksDenySection, { onSaved: loadAll }),
|
||||
h(GateSection, { t }),
|
||||
h("div", { className: "dshp-row", style: { marginTop: 2 } },
|
||||
h(Btn, { kind: "primary", disabled: patchBusy || uninstallBusy || !overrideLoaded, onClick: () => doAction("apply", "action.apply") }, patchBusy ? t("btn.apply.busy") : t("btn.apply")),
|
||||
h(Btn, { kind: "ghost", disabled: patchBusy || uninstallBusy, onClick: () => doAction("revert", "action.revert") }, t("btn.revert")),
|
||||
|
||||
@@ -7120,6 +7120,15 @@ export function setHooksDenySettings(patch) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** 通用插件设置(与 hooks-deny 共用 hooks-deny.json)。 */
|
||||
export function getPurgeSetting(key, fallback) {
|
||||
return hooksDeny.loadPurgeSetting(findDshHome(), key, fallback);
|
||||
}
|
||||
|
||||
export function savePurgeSetting(dshHome, key, value) {
|
||||
return hooksDeny.savePurgeSetting(dshHome || findDshHome(), key, value);
|
||||
}
|
||||
|
||||
export async function restoreHookDenyForBases(bases) {
|
||||
const failed = [];
|
||||
const changed = [];
|
||||
|
||||
+43
-1
@@ -208,13 +208,55 @@ function atomicWriteFileSync(fp, content, encoding = "utf8") {
|
||||
export function saveHookDenySettings(dshHome, patch) {
|
||||
const fp = hookDenySettingsPath(dshHome);
|
||||
const current = loadHookDenySettings(dshHome);
|
||||
/* 合并写入而不是覆盖:同一个文件里还有 approvalMode 等其他键,
|
||||
* 只重建 hooksDenyBypass 会把别的设置一起抹掉(实测会丢 approvalMode)。 */
|
||||
let existing = {};
|
||||
try {
|
||||
if (fs.existsSync(fp)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(fp, "utf8"));
|
||||
if (parsed && typeof parsed === "object") existing = parsed;
|
||||
}
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
const next = {
|
||||
...existing,
|
||||
hooksDenyBypass: typeof patch?.hooksDenyBypass === "boolean"
|
||||
? patch.hooksDenyBypass
|
||||
: current.hooksDenyBypass,
|
||||
};
|
||||
atomicWriteFileSync(fp, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return { ok: true, ...next, path: fp };
|
||||
return { ok: true, hooksDenyBypass: next.hooksDenyBypass, path: fp };
|
||||
}
|
||||
|
||||
/* 通用设置读写。放在同一个 hooks-deny.json 里:都是「本机插件偏好」,
|
||||
* 分成两个文件只会让备份/迁移多一步,也会让「谁写了这个 key」更难查。 */
|
||||
export function loadPurgeSetting(dshHome, key, fallback) {
|
||||
try {
|
||||
const fp = hookDenySettingsPath(dshHome);
|
||||
if (!fs.existsSync(fp)) return fallback;
|
||||
const parsed = JSON.parse(fs.readFileSync(fp, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object") return fallback;
|
||||
return parsed[key] === undefined ? fallback : parsed[key];
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function savePurgeSetting(dshHome, key, value) {
|
||||
const fp = hookDenySettingsPath(dshHome);
|
||||
let current = {};
|
||||
try {
|
||||
if (fs.existsSync(fp)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(fp, "utf8"));
|
||||
if (parsed && typeof parsed === "object") current = parsed;
|
||||
}
|
||||
} catch {
|
||||
current = {};
|
||||
}
|
||||
const next = { ...current, [key]: value };
|
||||
atomicWriteFileSync(fp, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return { ok: true, path: fp, key, value };
|
||||
}
|
||||
|
||||
export function selectPatches(patches, settings) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 主机身份的租约式判定。
|
||||
*
|
||||
* 动手之前要先知道「我要改的这份宿主到底是不是我现在跑着的这一份」。
|
||||
* 老版本只比路径字符串,有三种情况会被骗:
|
||||
*
|
||||
* 1. 目标被换成了符号链接 / 目录联接(junction)→ 看到的是另一份文件;
|
||||
* 2. 同一路径下文件被替换(重装、换版本、另一个进程写入)→ 路径对、内容不对;
|
||||
* 3. 路径大小写在 Windows 上不敏感 → `C:\App` 与 `c:\app` 被当成两份;
|
||||
* 反过来,含 `..` 的相对路径会被当成另一处。
|
||||
*
|
||||
* 这里给出一份可比较的 **identity**(身份):真实路径 + 大小 + mtime + 前若干字节的
|
||||
* 摘要。写入前后各取一次,不一致就拒绝,而不是写完再发现问题。
|
||||
* 参考 IG5 `source/attachment_lease.js` 的 owner/nonce 思路,但只保留与改文件相关的部分。
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
|
||||
/** Windows 上大小写不敏感;统一归一化后再比较,避免同一份被当成两份。 */
|
||||
export function normalizeKey(p) {
|
||||
const resolved = path.resolve(String(p || ""));
|
||||
const lowered = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
||||
return lowered.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export function samePath(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return normalizeKey(a) === normalizeKey(b);
|
||||
}
|
||||
|
||||
/** 真实路径(解析链接与联接);不存在或无权读取时退回归一化的绝对路径。 */
|
||||
export function realPathOf(p) {
|
||||
try {
|
||||
return fs.realpathSync.native(p);
|
||||
} catch {
|
||||
return path.resolve(String(p || ""));
|
||||
}
|
||||
}
|
||||
|
||||
function headDigest(fp, size) {
|
||||
const length = Math.min(HEAD_BYTES, Math.max(0, size));
|
||||
if (length === 0) return "";
|
||||
const fd = fs.openSync(fp, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const read = fs.readSync(fd, buffer, 0, length, 0);
|
||||
return createHash("sha256").update(buffer.subarray(0, read)).digest("hex");
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一份文件的身份。文件不存在时返回 `{ exists: false, path, key }`,
|
||||
* 这是正常结果而不是错误——「目标还没建」和「我读不到」必须分开。
|
||||
*/
|
||||
export function fileIdentity(fp) {
|
||||
const key = normalizeKey(fp);
|
||||
const real = realPathOf(fp);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(fp);
|
||||
} catch (error) {
|
||||
const missing = error && error.code === "ENOENT";
|
||||
return { exists: false, path: String(fp || ""), real, key, missing, error: missing ? "" : String(error && error.message ? error.message : error) };
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
return { exists: false, path: String(fp || ""), real, key, missing: false, error: "not a regular file" };
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
path: String(fp || ""),
|
||||
real,
|
||||
realKey: normalizeKey(real),
|
||||
key,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
mode: stat.mode,
|
||||
head: headDigest(fp, stat.size),
|
||||
};
|
||||
}
|
||||
|
||||
/** 同一份文件的两次观测是否可视为「没动过」。 */
|
||||
export function sameIdentity(a, b) {
|
||||
if (!a || !b) return false;
|
||||
if (a.exists !== b.exists) return false;
|
||||
if (!a.exists) return true;
|
||||
return a.realKey === b.realKey && a.size === b.size && a.mtimeMs === b.mtimeMs && a.head === b.head;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入前后的守卫。用法:
|
||||
*
|
||||
* const lease = await acquireLease(fp);
|
||||
* ...写...
|
||||
* const verdict = lease.verify(true); // mustExist=true 表示写完必须存在
|
||||
* if (!verdict.ok) → 回滚并报错
|
||||
*
|
||||
* `stale` 表示「拿租约之后别人动过」;`changed` 表示「我写完之后内容没落地」。
|
||||
*/
|
||||
export function acquireLease(fp) {
|
||||
const before = fileIdentity(fp);
|
||||
let released = false;
|
||||
return {
|
||||
path: String(fp || ""),
|
||||
before,
|
||||
/** 目标是否本来就不存在(新建文件的合法场景)。 */
|
||||
created: !before.exists,
|
||||
verify(mustExist = false) {
|
||||
const after = fileIdentity(fp);
|
||||
if (released && !mustExist) return { ok: true, after };
|
||||
if (mustExist && !after.exists) {
|
||||
return { ok: false, reason: "missing-after-write", before, after };
|
||||
}
|
||||
if (!mustExist && !before.exists) {
|
||||
return after.exists ? { ok: true, after, appeared: true } : { ok: true, after };
|
||||
}
|
||||
if (!sameIdentity(before, after) && !after.exists) {
|
||||
return { ok: false, reason: "vanished", before, after };
|
||||
}
|
||||
return { ok: true, after, changed: !sameIdentity(before, after) };
|
||||
},
|
||||
release() {
|
||||
released = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子写:先写同目录临时文件再 rename,避免半写坏文件。
|
||||
* 已存在文件保留其 mode(Windows 上 best-effort)。
|
||||
*/
|
||||
export function writeFileAtomic(fp, data, opts = {}) {
|
||||
const target = path.resolve(String(fp || ""));
|
||||
const dir = path.dirname(target);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const temp = path.join(dir, `.${path.basename(target)}.${process.pid}.${Date.now().toString(36)}.tmp`);
|
||||
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data ?? ""), opts.encoding || "utf8");
|
||||
let mode;
|
||||
try {
|
||||
mode = fs.statSync(target).mode;
|
||||
} catch {
|
||||
mode = undefined;
|
||||
}
|
||||
fs.writeFileSync(temp, buffer, { mode: mode ?? 0o644, flag: "wx" });
|
||||
try {
|
||||
fs.renameSync(temp, target);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(temp); } catch { /* 清不掉也不掩盖原错误 */ }
|
||||
throw error;
|
||||
}
|
||||
return { path: target, bytes: buffer.length };
|
||||
}
|
||||
+239
-23
@@ -27,6 +27,10 @@ import * as continueRetry from "./continue-retry.js";
|
||||
import * as uninstaller from "./uninstall.js";
|
||||
import { readDrillGrant, writeDrillGrant } from "./drill-grant.js";
|
||||
import { installRegionGate, readAllowText, writeAllowText } from "./net-scope.js";
|
||||
import { projectJson, safeProject, stringifySafe } from "./json-safe.js";
|
||||
import { acquireLease, fileIdentity, samePath, writeFileAtomic } from "./host-lease.js";
|
||||
import { installToolGate, requestRouteApproval } from "./tool-gate.js";
|
||||
import { bumpRev, checkRev, currentRev, lastActionInfo } from "./revision.js";
|
||||
import {
|
||||
applyShouldRestart,
|
||||
isInactiveContextError,
|
||||
@@ -62,6 +66,8 @@ const DEFAULTS = {
|
||||
// Codex 风格:教模型主动发现 skill / MCP;不改官方注册与工具面。
|
||||
capabilityProtocol: true,
|
||||
hooksDenyBypass: true,
|
||||
// 破坏性工具的审批档位:audit 只记流水(默认,行为同旧版);enforce 必须 allowed-once。
|
||||
approvalMode: "audit",
|
||||
};
|
||||
|
||||
const log = (config, ...args) => {
|
||||
@@ -316,6 +322,59 @@ function injectSnapshot(dshHome = core.findDshHome()) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 审批与结果流水的位置。放在 $DSH_HOME 下,与 override/rules 同级,便于备份与清理。 */
|
||||
function auditFilePath(dshHome = core.findDshHome()) {
|
||||
if (!dshHome) return "";
|
||||
return path.join(dshHome, "dsh-purge", "audit.jsonl");
|
||||
}
|
||||
|
||||
/**
|
||||
* 读回审计尾部。倒序扫描,只保留最后 `limit` 条:日志可能很大,读全量会拖慢
|
||||
* 一次工具调用。单次最多读 4 MiB,超出就停并把 truncated 写进回复。
|
||||
*/
|
||||
function readAuditTail(dshHome, limit = 20) {
|
||||
const fp = auditFilePath(dshHome);
|
||||
if (!fp) return { text: "没有 DSH 主目录,读不到审计流水。", total: 0, truncated: false };
|
||||
let raw = "";
|
||||
try {
|
||||
const stat = fs.statSync(fp);
|
||||
const maxBytes = Math.min(stat.size, 4 * 1024 * 1024);
|
||||
const fd = fs.openSync(fp, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(maxBytes);
|
||||
const read = fs.readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
|
||||
raw = buffer.subarray(0, read).toString("utf8");
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return { text: "还没有审计记录。apply / revert / uninstall 走审批门时会自动记流水。", total: 0, truncated: false };
|
||||
}
|
||||
return { text: `读审计失败: ${String(error && error.message ? error.message : error)}`, total: 0, truncated: false };
|
||||
}
|
||||
const lines = raw.split("\n").filter((line) => line.trim());
|
||||
const truncated = raw.length >= 4 * 1024 * 1024;
|
||||
const tail = lines.slice(-limit).reverse();
|
||||
const rows = [];
|
||||
for (const line of tail) {
|
||||
try {
|
||||
const rec = JSON.parse(line);
|
||||
const parts = [String(rec.ts || "?"), String(rec.tool || "?"), String(rec.phase || rec.decision || "?")];
|
||||
if (rec.what) parts.push(`@${rec.what}`);
|
||||
if (rec.why) parts.push(`(${rec.why})`);
|
||||
if (rec.isError) parts.push("ERROR");
|
||||
if (rec.detail) parts.push(String(rec.detail).slice(0, 160));
|
||||
if (rec.error) parts.push(String(rec.error).slice(0, 160));
|
||||
rows.push(parts.join(" "));
|
||||
} catch {
|
||||
rows.push(`(无法解析的一行) ${line.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
const header = `审计流水 ${rows.length}/${lines.length} 条${truncated ? "(只读了末尾 4 MiB)" : ""}:`;
|
||||
return { text: [header, ...rows].join("\n"), total: lines.length, truncated };
|
||||
}
|
||||
|
||||
/** 框里正在显示的字优先;框空则 resolveInjectText(含 prompt-inject.md)。 */
|
||||
function injectSnapshotFromBox(dshHome, boxText) {
|
||||
if (typeof boxText === "string" && core.normalizeOverride(boxText)) {
|
||||
@@ -1465,6 +1524,12 @@ export function apply(ctx, config) {
|
||||
});
|
||||
core.setHooksDenyConfigValue(cfg.hooksDenyBypass);
|
||||
core.refreshHooksDenySettings();
|
||||
/* approvalMode 以磁盘上的偏好为准:配置文件只是默认值,界面上切过一次就记住。
|
||||
* 读失败时不改,退回 DEFAULTS 的 audit —— 也就是「只记录、不阻拦」。 */
|
||||
try {
|
||||
const persisted = core.getPurgeSetting("approvalMode", "");
|
||||
if (persisted === "audit" || persisted === "enforce") cfg.approvalMode = persisted;
|
||||
} catch { /* 读不到就沿用默认 */ }
|
||||
try {
|
||||
const home = core.pinDshHome() || core.findDshHome();
|
||||
purgeLog("home", { home: home || "(none)" });
|
||||
@@ -1668,6 +1733,7 @@ function applyHostHooks(ctx, cfg) {
|
||||
const applied = report.filter((r) => r.status === "applied").length;
|
||||
const already = report.filter((r) => r.status === "already").length;
|
||||
const scrubNote = scrubbed.length ? ` desktop_scrub=${scrubbed.length}` : "";
|
||||
bumpRev("apply");
|
||||
return { text: `清洗完成 / applied=${applied}, already=${already}.${scrubNote} phase-1=${flash.phase1} 重启 dsh 生效.` };
|
||||
},
|
||||
});
|
||||
@@ -1696,11 +1762,57 @@ function applyHostHooks(ctx, cfg) {
|
||||
}
|
||||
const shimRevert = await core.revertAllShims();
|
||||
lines.push(`shim_dirs=${Object.keys(shimRevert).length}`);
|
||||
bumpRev("revert");
|
||||
return { text: `回滚完成 / ${lines.join(", ")}. 重启 dsh 恢复.` };
|
||||
},
|
||||
});
|
||||
/* 审计读取。写操作经审批门时会记流水,这里只读回放,不改任何状态。
|
||||
* 对应 IG5 的 `type=approvals` 只读入口:让模型在长任务里能自查
|
||||
* 「上一次 apply 到底批没批、结果如何」,而不是靠记忆。 */
|
||||
ctx.tools.register({
|
||||
name: "purge_audit",
|
||||
description: "读取 dsh-purge 破坏性操作的审批与结果流水(apply / revert / uninstall),最新的在前。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
limit: { type: "number", description: "返回多少条,默认 20,最多 200。" },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: { text: { type: "string" }, total: { type: "number" } },
|
||||
},
|
||||
render: (_args, value) => [{ type: "text", text: String(value?.text ?? "") }],
|
||||
},
|
||||
async execute(args) {
|
||||
const wanted = Number(args?.limit);
|
||||
const limit = Number.isFinite(wanted) && wanted > 0 ? Math.min(200, Math.floor(wanted)) : 20;
|
||||
const report = readAuditTail(core.findDshHome(), limit);
|
||||
return { text: report.text, total: report.total };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* 写工具挂审批门。放在 installWebServer 之前:路由层自己单独要审批,
|
||||
* 工具层在这里统一处理,两条入口互不依赖。 */
|
||||
const gate = installToolGate(ctx, {
|
||||
mode: cfg.approvalMode,
|
||||
tools: ["purge_apply", "purge_revert", "purge_uninstall"],
|
||||
auditPath: () => auditFilePath(core.findDshHome()),
|
||||
fs,
|
||||
path,
|
||||
log: (tag, kv) => purgeLog(`gate.${tag}`, kv),
|
||||
});
|
||||
try {
|
||||
ctx.effect(() => () => gate.dispose());
|
||||
} catch {
|
||||
/* 没有 effect 的旧上下文:disposer 留在闭包里,进程退出时一并消失 */
|
||||
}
|
||||
purgeLog("gate", { installed: gate.installed, gated: gate.gated.join("|") });
|
||||
|
||||
installWebServer(ctx, cfg);
|
||||
}
|
||||
|
||||
@@ -1786,6 +1898,11 @@ function installWebServer(ctx, cfg) {
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
ready: true,
|
||||
/* 状态修订号:客户端下次动作时带回来,服务端据此拒绝过期写入。
|
||||
* 与 last_action 一起给,界面可以直接提示「上一次动作是 apply」。 */
|
||||
rev: currentRev(),
|
||||
last_action: lastActionInfo(),
|
||||
approval_mode: cfg.approvalMode,
|
||||
surface: state.surface || hostctl.currentSurface(),
|
||||
ai_base: state.ai_base,
|
||||
desktop_install: state.desktop_install || "",
|
||||
@@ -1822,7 +1939,7 @@ function installWebServer(ctx, cfg) {
|
||||
...(await skills.skillsStatus(core.findDshHome()).catch(() => ({ skills: [] }))),
|
||||
});
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
},
|
||||
}, "dsh-purge: status");
|
||||
@@ -1835,7 +1952,7 @@ function installWebServer(ctx, cfg) {
|
||||
const settings = core.refreshHooksDenySettings();
|
||||
sendJson(response, 200, { ok: true, hooksDenyBypass: settings.hooksDenyBypass });
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1849,7 +1966,7 @@ function installWebServer(ctx, cfg) {
|
||||
const saved = core.setHooksDenySettings({ hooksDenyBypass: body.hooksDenyBypass });
|
||||
sendJson(response, 200, { ok: true, hooksDenyBypass: saved.hooksDenyBypass });
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1857,6 +1974,51 @@ function installWebServer(ctx, cfg) {
|
||||
response.end();
|
||||
},
|
||||
}, "dsh-purge: hooks-deny");
|
||||
registerPluginRoute({
|
||||
kind: "exact",
|
||||
path: "/dsh-purge/approval-mode",
|
||||
handler: async (request, response) => {
|
||||
const home = core.findDshHome();
|
||||
if (request.method === "GET") {
|
||||
sendJson(response, 200, { ok: true, mode: cfg.approvalMode, gated: ["purge_apply", "purge_revert", "purge_uninstall"] });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
const mode = String(body?.mode || "").toLowerCase();
|
||||
if (mode !== "audit" && mode !== "enforce") {
|
||||
sendJson(response, 400, { ok: false, error: "mode must be audit or enforce" });
|
||||
return;
|
||||
}
|
||||
const saved = core.savePurgeSetting(home, "approvalMode", mode);
|
||||
cfg.approvalMode = mode;
|
||||
purgeLog("approval-mode", { mode, saved: saved.ok });
|
||||
sendJson(response, 200, { ok: true, mode, note: "重启 dsh 后对工具调用生效" });
|
||||
} catch (e) {
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
response.writeHead(405, { allow: "GET, POST" });
|
||||
response.end();
|
||||
},
|
||||
}, "dsh-purge: approval-mode");
|
||||
registerPluginRoute({
|
||||
kind: "exact",
|
||||
path: "/dsh-purge/audit",
|
||||
handler: async (request, response) => {
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405, { allow: "GET" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const url = new URL(String(request.url || "/dsh-purge/audit"), "http://local");
|
||||
const limit = Number(url.searchParams.get("limit"));
|
||||
const report = readAuditTail(core.findDshHome(), Number.isFinite(limit) && limit > 0 ? Math.min(200, Math.floor(limit)) : 40);
|
||||
sendJson(response, 200, { ok: true, text: report.text, total: report.total, truncated: report.truncated });
|
||||
},
|
||||
}, "dsh-purge: audit");
|
||||
registerPluginRoute({
|
||||
kind: "exact",
|
||||
path: "/dsh-purge/last-user",
|
||||
@@ -1871,7 +2033,7 @@ function installWebServer(ctx, cfg) {
|
||||
const sessionId = String(url.searchParams.get("sessionId") || "").trim();
|
||||
sendJson(response, 200, await rewind.readLastUserText(ctx, sessionId));
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
},
|
||||
}, "dsh-purge: last user");
|
||||
@@ -1889,7 +2051,7 @@ function installWebServer(ctx, cfg) {
|
||||
const sessionId = String(url.searchParams.get("sessionId") || "").trim();
|
||||
sendJson(response, 200, await rewind.analyzeRewind(ctx, sessionId));
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
},
|
||||
}, "dsh-purge: rewind options");
|
||||
@@ -1909,7 +2071,7 @@ function installWebServer(ctx, cfg) {
|
||||
const pending = rewind.getPending();
|
||||
sendJson(response, 200, { ok: true, ...(pending || {}) });
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1925,7 +2087,7 @@ function installWebServer(ctx, cfg) {
|
||||
const result = await rewind.applyRewind(ctx, sessionId, mode);
|
||||
sendJson(response, result.ok ? 200 : 409, result);
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1941,7 +2103,7 @@ function installWebServer(ctx, cfg) {
|
||||
try {
|
||||
sendJson(response, 200, await updater.checkUpdate());
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1957,7 +2119,7 @@ function installWebServer(ctx, cfg) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1976,6 +2138,13 @@ function installWebServer(ctx, cfg) {
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
/* 修订号闸门:两个窗口(Web + 桌面)同时开着时,后点的那个不该覆盖先点的结果。
|
||||
* 客户端拉状态时拿到 rev,动作时带回来;不一致就 409 让界面重新拉,而不是盲写。 */
|
||||
const revCheck = checkRev(body?.rev);
|
||||
if (!revCheck.ok) {
|
||||
sendJson(response, 409, { ok: false, rev: revCheck.rev, error: revCheck.error, last: revCheck.last, stale: revCheck.stale });
|
||||
return;
|
||||
}
|
||||
const incoming = typeof body?.content === "string" ? body.content : null;
|
||||
core.applyRuntimeEnv();
|
||||
core.clearDesktopLocateCache();
|
||||
@@ -1987,7 +2156,7 @@ function installWebServer(ctx, cfg) {
|
||||
try {
|
||||
located = await core.resolveOfficialAiBase(state.ai_base, { forApply: true });
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
|
||||
return;
|
||||
}
|
||||
if (!located.aiBase) {
|
||||
@@ -2090,9 +2259,10 @@ function installWebServer(ctx, cfg) {
|
||||
desktop_scrubbed: scrubbed,
|
||||
cli_repair: cliRepair,
|
||||
cmd_flash: flash,
|
||||
rev: bumpRev("apply"),
|
||||
});
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
},
|
||||
}, "dsh-purge: apply");
|
||||
@@ -2106,6 +2276,12 @@ function installWebServer(ctx, cfg) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(request);
|
||||
const revCheck = checkRev(body?.rev);
|
||||
if (!revCheck.ok) {
|
||||
sendJson(response, 409, { ok: false, rev: revCheck.rev, error: revCheck.error, last: revCheck.last, stale: revCheck.stale });
|
||||
return;
|
||||
}
|
||||
const state = await core.gatherState();
|
||||
const result = {};
|
||||
if (state.ai_base) {
|
||||
@@ -2114,9 +2290,9 @@ function installWebServer(ctx, cfg) {
|
||||
result.errors = errors.length;
|
||||
}
|
||||
result.shim = await core.revertAllShims();
|
||||
sendJson(response, 200, { ok: true, ...result, note: "重启 dsh 恢复; prompt-inject.md 保留" });
|
||||
sendJson(response, 200, { ok: true, ...result, rev: bumpRev("revert"), note: "重启 dsh 恢复; prompt-inject.md 保留" });
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
},
|
||||
}, "dsh-purge: revert");
|
||||
@@ -2145,7 +2321,7 @@ function installWebServer(ctx, cfg) {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
},
|
||||
}, "dsh-purge: uninstall");
|
||||
@@ -2179,7 +2355,7 @@ function installWebServer(ctx, cfg) {
|
||||
const ui = await core.readOverrideForUi(home);
|
||||
sendJson(response, 200, { ok: true, ...ui, ...injectSnapshot(home) });
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2214,7 +2390,7 @@ function installWebServer(ctx, cfg) {
|
||||
...injectSnapshot(home),
|
||||
});
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2242,7 +2418,7 @@ function installWebServer(ctx, cfg) {
|
||||
const saved = await writeAllowText(home, content);
|
||||
sendJson(response, 200, { ok: true, content: saved.text, dropped: saved.dropped });
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2305,7 +2481,7 @@ function installWebServer(ctx, cfg) {
|
||||
note: restarting ? "正在重启。" : restartError,
|
||||
});
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
},
|
||||
}, "dsh-purge: restart");
|
||||
@@ -2432,7 +2608,7 @@ function rulesHttpHandler(forcedOp) {
|
||||
const result = await handleRulesOp(op, body, url.searchParams.get("id"), url.searchParams.get("cwd"));
|
||||
sendJson(response, result.status || 200, result);
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2501,7 +2677,7 @@ function skillsHttpHandler(forcedOp) {
|
||||
const result = await handleSkillsOp(op, body, url.searchParams.get("id"));
|
||||
sendJson(response, result.status || 200, result);
|
||||
} catch (e) {
|
||||
sendJson(response, 500, { ok: false, error: String(e && e.message ? e.message : e) });
|
||||
sendJson(response, bodyErrorStatus(e), { ok: false, error: String(e && e.message ? e.message : e), code: e && e.code ? String(e.code) : undefined });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2543,12 +2719,22 @@ function installRulesHttp(webServer) {
|
||||
};
|
||||
}
|
||||
|
||||
/* 统一出口:先做无损投影再序列化。
|
||||
* 旧实现直接 JSON.stringify —— 遇到 BigInt 抛、循环引用抛、undefined 静默丢字段,
|
||||
* 结果是一个本该成功的操作变成 500,或者 200 里少了关键字段而界面以为「没数据」。
|
||||
* safeProject 永不抛,坏字段会变成带 __invalid 标记的对象,调用方仍能诊断。 */
|
||||
function sendJson(response, status, payload) {
|
||||
let body;
|
||||
try {
|
||||
body = stringifySafe(payload);
|
||||
} catch (error) {
|
||||
body = JSON.stringify({ ok: false, error: `encode failed: ${String(error && error.message ? error.message : error)}` });
|
||||
}
|
||||
response.writeHead(status, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendOptions(response) {
|
||||
@@ -2572,6 +2758,9 @@ function registerRoute(webServer, spec) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 读请求体并给出**可分类**的失败:调用方据此回 413 / 400,而不是把
|
||||
* 所有异常都压成 500「请求失败」。旧实现只抛一句 Error,界面无法区分
|
||||
* 「内容太大」和「JSON 写错了」,用户看到的永远是同一个红字。 */
|
||||
async function readJsonBody(request, maxBytes = 512 * 1024) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
@@ -2579,12 +2768,39 @@ async function readJsonBody(request, maxBytes = 512 * 1024) {
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.length;
|
||||
if (size > limit) throw new Error("request body too large");
|
||||
if (size > limit) {
|
||||
const error = new Error(`request body too large: ${size} > ${limit}`);
|
||||
error.code = "BODY_TOO_LARGE";
|
||||
error.status = 413;
|
||||
error.limit = limit;
|
||||
throw error;
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw);
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
const error = new Error("request body must be a JSON object");
|
||||
error.code = "BODY_NOT_OBJECT";
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error.code === "BODY_NOT_OBJECT") throw error;
|
||||
const wrapped = new Error(`invalid JSON body: ${String(error && error.message ? error.message : error)}`);
|
||||
wrapped.code = "BODY_INVALID_JSON";
|
||||
wrapped.status = 400;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 readJsonBody 的分类错误翻成 HTTP 状态;其余异常仍是 500。 */
|
||||
function bodyErrorStatus(error) {
|
||||
const status = Number(error && error.status);
|
||||
return Number.isFinite(status) && status >= 400 && status < 600 ? status : 500;
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 严格 JSON 输出边界。
|
||||
*
|
||||
* 为什么要有这层:宿主要在渲染前校验工具结果,而 `JSON.stringify` 遇到
|
||||
* BigInt 直接抛、遇到 undefined 静默丢字段、遇到循环引用抛、遇到 accessor
|
||||
* 触发取值副作用。任何一条都可能让一个本来成功的操作变成 500,或者更糟——
|
||||
* 让「看起来成功」的响应其实丢了关键字段。
|
||||
*
|
||||
* 规则(与 IG5 的 source/json_output.js 同一套判据):
|
||||
* - 可选对象的 undefined 字段省略;null 保留
|
||||
* - 数字必须有限,且不能是 -0
|
||||
* - 拒绝:BigInt / Symbol / Function / 循环引用 / accessor / 非普通对象 /
|
||||
* 数组空洞 / 带额外属性的数组 / 非可枚举字段
|
||||
* - 深度与节点数有上限,超限抛错而不是截断
|
||||
*
|
||||
* 对外两个入口:
|
||||
* projectJson(value, opts) —— 严格投影,非 JSON 就抛
|
||||
* safeProject(value, opts) —— 投影失败时降级成带标记的对象,永不抛
|
||||
*/
|
||||
|
||||
const MAX_DEPTH_DEFAULT = 64;
|
||||
const MAX_NODES_DEFAULT = 200000;
|
||||
|
||||
function fail(at, reason) {
|
||||
const error = new TypeError(`dsh-purge JSON at ${at} is not lossless: ${reason}`);
|
||||
error.code = "INVALID_PLUGIN_OUTPUT";
|
||||
error.at = at;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {{ maxDepth?: number, maxNodes?: number }} [opts]
|
||||
*/
|
||||
export function projectJson(value, opts = {}) {
|
||||
const maxDepth = Number.isFinite(opts.maxDepth) ? opts.maxDepth : MAX_DEPTH_DEFAULT;
|
||||
const maxNodes = Number.isFinite(opts.maxNodes) ? opts.maxNodes : MAX_NODES_DEFAULT;
|
||||
const ancestors = new WeakSet();
|
||||
let nodes = 0;
|
||||
|
||||
function visit(item, at, depth) {
|
||||
if (depth > maxDepth) throw fail(at, `maximum nesting ${maxDepth} exceeded`);
|
||||
if (++nodes > maxNodes) throw fail(at, `maximum node count ${maxNodes} exceeded`);
|
||||
if (item === null) return null;
|
||||
const type = typeof item;
|
||||
if (type === "string" || type === "boolean") return item;
|
||||
if (type === "number") {
|
||||
if (!Number.isFinite(item) || Object.is(item, -0)) throw fail(at, "non-lossless number");
|
||||
return item;
|
||||
}
|
||||
if (type !== "object") throw fail(at, `unsupported ${type}`);
|
||||
|
||||
if (ancestors.has(item)) throw fail(at, "circular reference");
|
||||
ancestors.add(item);
|
||||
try {
|
||||
if (Array.isArray(item)) {
|
||||
if (Object.getPrototypeOf(item) !== Array.prototype) throw fail(at, "non-plain array");
|
||||
const own = Reflect.ownKeys(item);
|
||||
if (own.length !== item.length + 1) throw fail(at, "sparse or decorated array");
|
||||
const out = new Array(item.length);
|
||||
for (let index = 0; index < item.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
|
||||
if (!descriptor || !("value" in descriptor)) throw fail(`${at}[${index}]`, "missing or accessor item");
|
||||
out[index] = visit(descriptor.value, `${at}[${index}]`, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(item);
|
||||
if (prototype !== null && prototype !== Object.prototype) throw fail(at, "non-plain object");
|
||||
const out = {};
|
||||
for (const key of Reflect.ownKeys(item)) {
|
||||
if (typeof key !== "string") throw fail(at, "symbol key");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
||||
if (!descriptor.enumerable) throw fail(`${at}.${key}`, "non-enumerable field");
|
||||
if (!("value" in descriptor)) throw fail(`${at}.${key}`, "accessor field");
|
||||
if (descriptor.value === undefined) continue;
|
||||
Object.defineProperty(out, key, {
|
||||
value: visit(descriptor.value, `${at}.${key}`, depth + 1),
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
ancestors.delete(item);
|
||||
}
|
||||
}
|
||||
|
||||
return visit(value, "$", 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 永不抛的版本:投影失败时返回 `{ __invalid: true, __reason, __at }`,
|
||||
* 让 HTTP 层仍能给出可诊断的 200/500,而不是把异常冒到宿主。
|
||||
*/
|
||||
export function safeProject(value, opts = {}) {
|
||||
try {
|
||||
return { ok: true, value: projectJson(value, opts) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
value: {
|
||||
__invalid: true,
|
||||
__reason: String(error && error.message ? error.message : error),
|
||||
__at: String(error && error.at ? error.at : "$"),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 把任意值编码成可安全写出的 JSON 文本;绝不抛。 */
|
||||
export function stringifySafe(value, opts = {}) {
|
||||
const projected = safeProject(value, opts);
|
||||
try {
|
||||
return JSON.stringify(projected.value);
|
||||
} catch (error) {
|
||||
return JSON.stringify({ __invalid: true, __reason: String(error && error.message ? error.message : error) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 状态修订号(CAS)。
|
||||
*
|
||||
* 场景:设置页每 5 秒轮询一次 `/dsh-purge/status`。两次轮询之间用户可能点了
|
||||
* 「应用」,另一个窗口(Web + 桌面同时开着)也可能点了。旧代码里 apply / revert
|
||||
* 是无条件执行的——后到的那个会覆盖先到的结果,而界面以为自己看到的是最新状态。
|
||||
*
|
||||
* 这里给状态机一个单调递增的 `rev`:读状态时带出、写动作时带上。服务端比对
|
||||
* 不一致就返回 409 和当前 rev,由调用方决定重新拉一次还是放弃,而不是盲写。
|
||||
*
|
||||
* 不持久化:rev 只描述「本进程内的状态演进」,重启后归零是正确语义——
|
||||
* 重启本来就会让所有客户端重新拉状态。
|
||||
*/
|
||||
|
||||
let rev = 0;
|
||||
let lastAction = "";
|
||||
let lastActionAt = 0;
|
||||
|
||||
export function currentRev() {
|
||||
return rev;
|
||||
}
|
||||
|
||||
export function bumpRev(action) {
|
||||
rev += 1;
|
||||
lastAction = String(action || "");
|
||||
lastActionAt = Date.now();
|
||||
return rev;
|
||||
}
|
||||
|
||||
export function lastActionInfo() {
|
||||
return { action: lastAction, at: lastActionAt, rev };
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验调用方期望的修订号。
|
||||
*
|
||||
* - 不传(undefined / null / ""):放行,并把当前 rev 一起返回,兼容旧客户端。
|
||||
* - 传了且相等:放行。
|
||||
* - 传了但不相等:拒绝,附上当前 rev 与最近一次动作,便于界面提示。
|
||||
*
|
||||
* 解析失败按「没传」处理:宁可放行也不要把一个手误的参数变成硬错误,
|
||||
* 真正的保护来自相等判断本身,而不是参数格式校验。
|
||||
*/
|
||||
export function checkRev(expected) {
|
||||
if (expected === undefined || expected === null || expected === "") {
|
||||
return { ok: true, rev, expected: null };
|
||||
}
|
||||
const wanted = Number(expected);
|
||||
if (!Number.isFinite(wanted)) return { ok: true, rev, expected: null };
|
||||
if (wanted === rev) return { ok: true, rev, expected: wanted };
|
||||
return {
|
||||
ok: false,
|
||||
rev,
|
||||
expected: wanted,
|
||||
stale: wanted < rev,
|
||||
last: lastActionInfo(),
|
||||
error: wanted < rev
|
||||
? `stale revision: client had rev ${wanted}, current is ${rev}`
|
||||
: `unknown revision: client had rev ${wanted}, current is ${rev}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 测试用:把状态复位。生产代码不要调用。 */
|
||||
export function resetRevForTests() {
|
||||
rev = 0;
|
||||
lastAction = "";
|
||||
lastActionAt = 0;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 破坏性操作的 审计流 + 可选审批门。
|
||||
*
|
||||
* 两个诉求不能混在一起:
|
||||
* 1. **审计**永远要有。谁在什么时候调了 apply / revert / uninstall、批没批、
|
||||
* 结果如何 —— 这些必须可回溯,否则长任务里出了问题只能靠记忆。
|
||||
* 2. **审批**不能默认强制。dsh-purge 的主入口是人自己点「应用」——人就是
|
||||
* 审批者,再弹一次只是多一次点击;而且本机策略可能把 approval 一律自动
|
||||
* 拒绝,那么强制审批等于把插件锁死。
|
||||
*
|
||||
* 所以这里有两档:
|
||||
* mode: "audit" (默认)记流水,放行。行为与旧版一致,只多一份记录。
|
||||
* mode: "enforce" 记流水 + 必须拿到 allowed-once,否则 deny 不派发。
|
||||
*
|
||||
* enforce 只对**模型发起的工具调用**有意义:人能直接点的按钮走 HTTP 路由,
|
||||
* 那条路径不该二次审批。
|
||||
*
|
||||
* 失败方向统一是「拒绝」:缺 agent、缺 approval 服务、抛错、未知结果、
|
||||
* signal 已 abort —— enforce 下一律不派发。audit 下这些只写进流水。
|
||||
*/
|
||||
|
||||
const DEFAULT_GATED_TOOLS = ["purge_apply", "purge_revert", "purge_uninstall"];
|
||||
|
||||
/** 只有明确的 allowed-once 才算批准;其余一律不批准。 */
|
||||
export function decisionFromOutcome(outcome) {
|
||||
if (outcome === "allowed-once") return { allow: true, why: "allowed-once" };
|
||||
if (outcome === "cancelled") return { allow: false, kind: "cancel", why: "cancelled" };
|
||||
if (outcome === "rejected") return { allow: false, kind: "deny", why: "rejected" };
|
||||
return { allow: false, kind: "deny", why: "unavailable" };
|
||||
}
|
||||
|
||||
function normalizeMode(mode) {
|
||||
return String(mode || "").toLowerCase() === "enforce" ? "enforce" : "audit";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} ctx cordis 上下文
|
||||
* @param {object} options
|
||||
* @param {"audit"|"enforce"} [options.mode="audit"]
|
||||
* @param {string[]} [options.tools]
|
||||
* @param {() => string} [options.auditPath] 惰性求值,避免过早绑定 DSH_HOME
|
||||
* @param {object} [options.fs] 注入便于测试
|
||||
* @param {object} [options.path]
|
||||
* @param {(tag: string, kv?: object) => void} [options.log]
|
||||
*/
|
||||
export function installToolGate(ctx, options = {}) {
|
||||
const mode = normalizeMode(options.mode);
|
||||
const gated = new Set(Array.isArray(options.tools) && options.tools.length ? options.tools : DEFAULT_GATED_TOOLS);
|
||||
const auditPath = typeof options.auditPath === "function" ? options.auditPath : () => "";
|
||||
const log = typeof options.log === "function" ? options.log : () => {};
|
||||
const fs = options.fs;
|
||||
const path = options.path;
|
||||
const disposers = [];
|
||||
|
||||
const appendAudit = (record) => {
|
||||
let fp = "";
|
||||
try {
|
||||
fp = auditPath();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!fp || !fs || !path) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
||||
fs.appendFileSync(fp, JSON.stringify(record) + "\n", "utf8");
|
||||
} catch {
|
||||
/* 审计写不进去,绝不影响结果路径 */
|
||||
}
|
||||
};
|
||||
|
||||
const resolveApproval = () => {
|
||||
try {
|
||||
return ctx.get?.("approval", false) ?? ctx.get?.("approval") ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const dispose = ctx.on("tools/pre-execute", async (exec, next) => {
|
||||
const name = String(exec?.name || "");
|
||||
if (!gated.has(name)) return next();
|
||||
if (exec?.signal?.aborted) return { kind: "cancel" };
|
||||
|
||||
const args = exec?.arguments ?? {};
|
||||
const what = String(args?.action ?? args?.id ?? args?.path ?? args?.mode ?? "").slice(0, 200);
|
||||
|
||||
if (mode === "audit") {
|
||||
appendAudit({ ts: new Date().toISOString(), tool: name, what, phase: "intent", mode });
|
||||
return next();
|
||||
}
|
||||
|
||||
const approval = resolveApproval();
|
||||
const agent = exec?.agent;
|
||||
if (!agent || typeof approval?.request !== "function") {
|
||||
appendAudit({ ts: new Date().toISOString(), tool: name, what, decision: "deny", why: "no-approval-service", mode });
|
||||
return { kind: "deny", reason: `dsh-purge: ${name} requires an active agent and an available approval service.` };
|
||||
}
|
||||
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await approval.request({
|
||||
agent,
|
||||
toolName: name,
|
||||
callId: exec?.callId,
|
||||
reason: `dsh-purge 写操作待批准: ${name}${what ? ` @ ${what}` : ""}`,
|
||||
displayReason: {
|
||||
en: `dsh-purge operation "${name}" changes this install's host files and needs one-time approval.`,
|
||||
zh: `dsh-purge 操作「${name}」会修改本机宿主文件,需要你批准一次。`,
|
||||
},
|
||||
signal: exec.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (exec?.signal?.aborted) return { kind: "cancel" };
|
||||
appendAudit({
|
||||
ts: new Date().toISOString(),
|
||||
tool: name,
|
||||
what,
|
||||
decision: "deny",
|
||||
why: "approval-threw",
|
||||
error: String(error && error.message ? error.message : error).slice(0, 300),
|
||||
mode,
|
||||
});
|
||||
return { kind: "deny", reason: `dsh-purge: ${name} approval could not be confirmed; nothing was dispatched.` };
|
||||
}
|
||||
|
||||
if (exec?.signal?.aborted) return { kind: "cancel" };
|
||||
const verdict = decisionFromOutcome(outcome);
|
||||
appendAudit({
|
||||
ts: new Date().toISOString(),
|
||||
tool: name,
|
||||
what,
|
||||
decision: verdict.allow ? "allow" : verdict.kind === "cancel" ? "cancel" : "deny",
|
||||
why: verdict.why,
|
||||
mode,
|
||||
});
|
||||
if (!verdict.allow) {
|
||||
if (verdict.kind === "cancel") return { kind: "cancel" };
|
||||
return { kind: "deny", reason: `dsh-purge: ${name} approval was ${verdict.why}; nothing was dispatched.` };
|
||||
}
|
||||
return next();
|
||||
});
|
||||
if (typeof dispose === "function") disposers.push(dispose);
|
||||
} catch (error) {
|
||||
log("gate", { status: "pre-unavailable", error: String(error && error.message ? error.message : error) });
|
||||
}
|
||||
|
||||
try {
|
||||
const dispose = ctx.on("tools/post-execute", async (exec, result, next) => {
|
||||
const name = String(exec?.name || "");
|
||||
if (gated.has(name)) {
|
||||
appendAudit({
|
||||
ts: new Date().toISOString(),
|
||||
tool: name,
|
||||
phase: "result",
|
||||
mode,
|
||||
isError: result?.isError === true,
|
||||
detail: result?.isError
|
||||
? String(result?.error?.message ?? "").slice(0, 500)
|
||||
: String(result?.value?.text ?? "").slice(0, 500),
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
if (typeof dispose === "function") disposers.push(dispose);
|
||||
} catch (error) {
|
||||
log("gate", { status: "post-unavailable", error: String(error && error.message ? error.message : error) });
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
gated: [...gated],
|
||||
installed: disposers.length,
|
||||
dispose() {
|
||||
for (const dispose of disposers.splice(0)) {
|
||||
try { dispose(); } catch { /* 单个 disposer 失败不影响其余 */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由侧的显式确认。**不做强制审批**——点按钮的人就是审批者。
|
||||
*
|
||||
* 只提供一个可选的「二次确认」通道:请求头带 `x-dsh-purge-confirm: once`
|
||||
* 时放行。这是给自动化脚本/CI 用的显式意图标记,不是安全边界;
|
||||
* 真正的边界是 `allowPluginRequest` 的同源校验。
|
||||
*/
|
||||
export function requestRouteApproval(request) {
|
||||
const header = String(request?.headers?.["x-dsh-purge-confirm"] ?? "").trim().toLowerCase();
|
||||
if (header === "once") return { ok: true, via: "header" };
|
||||
return { ok: true, via: "human", implicit: true };
|
||||
}
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-purge",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"description": "DeepSeek Harness 破甲:让所有模型都能破甲,不同模型可换不同提示词;默认提示词面向国模「小码酱」。Jailbreak for every model — swap prompts per model. 求 Star 收藏 ⭐ 如要二开,请查看署名要求部分",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
@@ -34,11 +34,13 @@
|
||||
"LICENSE",
|
||||
"NOTICE.md",
|
||||
"DISCLAIMER.md",
|
||||
"CHANGELOG.md"
|
||||
"CHANGELOG.md",
|
||||
"test/"
|
||||
],
|
||||
"scripts": {
|
||||
"embed-prompt": "node scripts/embed-default-prompt.mjs",
|
||||
"build:client": "node scripts/build-client.mjs"
|
||||
"build:client": "node scripts/build-client.mjs",
|
||||
"test:gate": "node test/gate.mjs"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
@@ -86,7 +88,8 @@
|
||||
"tarball": "https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.tar.gz",
|
||||
"zip": "https://github.com/YuJunZhiXue/dsh-purge/archive/refs/heads/master.zip",
|
||||
"git": "https://github.com/YuJunZhiXue/dsh-purge.git"
|
||||
}
|
||||
},
|
||||
"version": "1.4.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
// 针对 1.4.0 新增四个模块的单元测试。不启动宿主、不碰真实工程。
|
||||
//
|
||||
// usage: node work/tools/test-1.4.0.mjs [pluginDir]
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pluginDir = process.argv[2] || path.resolve(here, "..");
|
||||
const load = (name) => import(pathToFileURL(path.join(pluginDir, "lib", name)).href);
|
||||
|
||||
const jsonSafe = await load("json-safe.js");
|
||||
const lease = await load("host-lease.js");
|
||||
const revision = await load("revision.js");
|
||||
const gateModule = await load("tool-gate.js");
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function check(label, condition, detail = "") {
|
||||
if (condition) {
|
||||
pass += 1;
|
||||
console.log(` ok ${label}`);
|
||||
} else {
|
||||
fail += 1;
|
||||
console.log(` FAIL ${label}${detail ? " <- " + detail : ""}`);
|
||||
}
|
||||
}
|
||||
function throws(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
check(label, false, "expected a throw");
|
||||
} catch {
|
||||
check(label, true);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n== json-safe ==");
|
||||
{
|
||||
const { projectJson, safeProject, stringifySafe } = jsonSafe;
|
||||
|
||||
check("plain round-trip", JSON.stringify(projectJson({ a: 1, b: "x", c: true, d: null })) === '{"a":1,"b":"x","c":true,"d":null}');
|
||||
check("undefined field omitted", !("a" in projectJson({ a: undefined, b: 1 })));
|
||||
check("null preserved", projectJson({ a: null }).a === null);
|
||||
check("nested arrays ok", JSON.stringify(projectJson({ a: [1, [2, 3]] })) === '{"a":[1,[2,3]]}');
|
||||
|
||||
throws("-0 rejected", () => projectJson({ a: -0 }));
|
||||
throws("NaN rejected", () => projectJson({ a: NaN }));
|
||||
throws("Infinity rejected", () => projectJson({ a: Infinity }));
|
||||
throws("BigInt rejected", () => projectJson({ a: 1n }));
|
||||
throws("function rejected", () => projectJson({ a: () => 1 }));
|
||||
throws("symbol rejected", () => projectJson({ a: Symbol("s") }));
|
||||
|
||||
const circular = { a: 1 };
|
||||
circular.self = circular;
|
||||
throws("circular rejected", () => projectJson(circular));
|
||||
|
||||
const sparse = [1, , 3];
|
||||
throws("sparse array rejected", () => projectJson({ a: sparse }));
|
||||
|
||||
const decorated = [1, 2];
|
||||
decorated.extra = "x";
|
||||
throws("decorated array rejected", () => projectJson({ a: decorated }));
|
||||
|
||||
const withAccessor = {};
|
||||
Object.defineProperty(withAccessor, "a", { enumerable: true, get: () => 1 });
|
||||
throws("accessor rejected", () => projectJson(withAccessor));
|
||||
|
||||
class Custom { constructor() { this.a = 1; } }
|
||||
throws("class instance rejected", () => projectJson({ a: new Custom() }));
|
||||
|
||||
const deep = {};
|
||||
let cursor = deep;
|
||||
for (let i = 0; i < 80; i += 1) { cursor.next = {}; cursor = cursor.next; }
|
||||
throws("depth cap enforced", () => projectJson(deep));
|
||||
|
||||
const bad = safeProject({ a: 1n });
|
||||
check("safeProject never throws", bad.ok === false && bad.value.__invalid === true);
|
||||
check("safeProject keeps reason", String(bad.value.__reason).length > 0);
|
||||
check("stringifySafe on bad value returns JSON", (() => {
|
||||
const text = stringifySafe({ a: 1n });
|
||||
return typeof text === "string" && JSON.parse(text).__invalid === true;
|
||||
})());
|
||||
|
||||
// 64 位地址必须保持字符串精度(IG5 的同一条约束)。
|
||||
const big = "0x7FF6A1B2C3D4E5F6";
|
||||
check("64-bit address survives as string", projectJson({ ea: big }).ea === big);
|
||||
}
|
||||
|
||||
console.log("\n== host-lease ==");
|
||||
{
|
||||
const { fileIdentity, sameIdentity, acquireLease, writeFileAtomic, normalizeKey, samePath } = lease;
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-purge-lease-"));
|
||||
const fp = path.join(dir, "target.txt");
|
||||
|
||||
const missing = fileIdentity(fp);
|
||||
check("missing file reports exists=false", missing.exists === false && missing.missing === true);
|
||||
|
||||
fs.writeFileSync(fp, "hello", "utf8");
|
||||
const first = fileIdentity(fp);
|
||||
check("existing file has size/mtime/head", first.exists && first.size === 5 && first.head.length === 64);
|
||||
|
||||
check("same content same identity", sameIdentity(first, fileIdentity(fp)));
|
||||
|
||||
fs.writeFileSync(fp, "hellp", "utf8");
|
||||
const changed = fileIdentity(fp);
|
||||
check("content change breaks identity", !sameIdentity(first, changed), `${first.head} vs ${changed.head}`);
|
||||
|
||||
const sameMtime = { ...first, mtimeMs: 0 };
|
||||
fs.utimesSync(fp, 1, 1);
|
||||
check("mtime-only change breaks identity", !sameIdentity(sameMtime, fileIdentity(fp)));
|
||||
|
||||
const l1 = acquireLease(fp);
|
||||
fs.writeFileSync(fp, "modified after lease", "utf8");
|
||||
const v1 = l1.verify(true);
|
||||
check("lease verify reports changed", v1.ok === true && v1.changed === true);
|
||||
|
||||
const l2 = acquireLease(fp);
|
||||
fs.unlinkSync(fp);
|
||||
const v2 = l2.verify(true);
|
||||
check("lease catches vanish", v2.ok === false && v2.reason === "missing-after-write");
|
||||
|
||||
const fresh = path.join(dir, "new.txt");
|
||||
const l3 = acquireLease(fresh);
|
||||
writeFileAtomic(fresh, "atomic", {});
|
||||
const v3 = l3.verify(true);
|
||||
check("atomic write on new file verifies", v3.ok === true && fs.readFileSync(fresh, "utf8") === "atomic");
|
||||
check("lease knows it created the file", l3.created === true);
|
||||
|
||||
writeFileAtomic(fresh, "second", {});
|
||||
check("atomic write overwrites", fs.readFileSync(fresh, "utf8") === "second");
|
||||
check("no temp files left behind", fs.readdirSync(dir).filter((n) => n.endsWith(".tmp")).length === 0);
|
||||
|
||||
check("normalizeKey lowercases on win32", process.platform !== "win32" || normalizeKey("C:\\A\\B") === normalizeKey("c:/a/b"));
|
||||
check("samePath matches separators", process.platform !== "win32" || samePath("C:/A", "C:\\a"));
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n== revision ==");
|
||||
{
|
||||
const { currentRev, bumpRev, checkRev, lastActionInfo, resetRevForTests } = revision;
|
||||
resetRevForTests();
|
||||
check("starts at 0", currentRev() === 0);
|
||||
|
||||
const first = checkRev(undefined);
|
||||
check("omitted rev passes", first.ok === true && first.rev === 0);
|
||||
|
||||
checkRev("not-a-number");
|
||||
check("garbage rev passes leniently", checkRev("abc").ok === true);
|
||||
|
||||
bumpRev("apply");
|
||||
check("bump increments", currentRev() === 1);
|
||||
check("last action recorded", lastActionInfo().action === "apply");
|
||||
|
||||
check("matching rev passes", checkRev(1).ok === true);
|
||||
const stale = checkRev(0);
|
||||
check("stale rev rejected", stale.ok === false && stale.stale === true);
|
||||
check("stale rev carries current", stale.rev === 1 && stale.last.action === "apply");
|
||||
|
||||
const ahead = checkRev(99);
|
||||
check("future rev rejected but not stale", ahead.ok === false && ahead.stale === false);
|
||||
|
||||
bumpRev("revert");
|
||||
check("second bump", currentRev() === 2 && lastActionInfo().action === "revert");
|
||||
resetRevForTests();
|
||||
check("reset for tests", currentRev() === 0);
|
||||
}
|
||||
|
||||
console.log("\n== tool-gate ==");
|
||||
{
|
||||
const { installToolGate, requestRouteApproval, decisionFromOutcome } = gateModule;
|
||||
const auditDir = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-purge-audit-"));
|
||||
const auditFile = path.join(auditDir, "audit.jsonl");
|
||||
|
||||
function makeCtx(outcome, opts = {}) {
|
||||
const { withService = true, throwOnRequest = false } = opts;
|
||||
const handlers = { "tools/pre-execute": [], "tools/post-execute": [] };
|
||||
let requests = 0;
|
||||
return {
|
||||
handlers,
|
||||
requests: () => requests,
|
||||
ctx: {
|
||||
get(name) {
|
||||
if (name !== "approval") return undefined;
|
||||
if (!withService) return undefined;
|
||||
return {
|
||||
async request() {
|
||||
requests += 1;
|
||||
if (throwOnRequest) throw new Error("approval backend down");
|
||||
return outcome;
|
||||
},
|
||||
};
|
||||
},
|
||||
on(event, handler) {
|
||||
handlers[event].push(handler);
|
||||
return () => {
|
||||
const at = handlers[event].indexOf(handler);
|
||||
if (at >= 0) handlers[event].splice(at, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const readAudit = () => (fs.existsSync(auditFile)
|
||||
? fs.readFileSync(auditFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
||||
: []);
|
||||
const runPre = (harness, exec) => harness.handlers["tools/pre-execute"][0](exec, async () => "next");
|
||||
|
||||
// ---- audit 模式(默认):记意图、放行、绝不请求审批 ----
|
||||
{
|
||||
fs.rmSync(auditFile, { force: true });
|
||||
const h = makeCtx("rejected", { withService: false });
|
||||
const gate = installToolGate(h.ctx, { auditPath: () => auditFile, fs, path });
|
||||
check("default mode is audit", gate.mode === "audit");
|
||||
const out = await runPre(h, { name: "purge_apply", agent: {}, signal: { aborted: false } });
|
||||
check("audit mode passes through", out === "next");
|
||||
check("audit mode never asks approval", h.requests() === 0);
|
||||
check("audit mode records intent", readAudit().some((r) => r.phase === "intent" && r.tool === "purge_apply"));
|
||||
gate.dispose();
|
||||
}
|
||||
|
||||
// ---- enforce 模式:allowed-once 放行 ----
|
||||
{
|
||||
fs.rmSync(auditFile, { force: true });
|
||||
const h = makeCtx("allowed-once");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
let nextCalled = false;
|
||||
const out = await h.handlers["tools/pre-execute"][0](
|
||||
{ name: "purge_apply", agent: {}, signal: { aborted: false } },
|
||||
async () => { nextCalled = true; return "next"; },
|
||||
);
|
||||
check("enforce + allowed-once calls next", nextCalled && out === "next");
|
||||
check("allow audited", readAudit().some((r) => r.decision === "allow" && r.tool === "purge_apply"));
|
||||
}
|
||||
|
||||
// ---- enforce 模式:拒绝不派发 ----
|
||||
{
|
||||
fs.rmSync(auditFile, { force: true });
|
||||
const h = makeCtx("rejected");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
let nextCalled = false;
|
||||
const out = await h.handlers["tools/pre-execute"][0](
|
||||
{ name: "purge_apply", agent: {}, signal: { aborted: false } },
|
||||
async () => { nextCalled = true; return "next"; },
|
||||
);
|
||||
check("enforce + rejected does not dispatch", nextCalled === false && out.kind === "deny");
|
||||
check("deny audited with reason", readAudit().some((r) => r.decision === "deny" && r.why === "rejected"));
|
||||
}
|
||||
|
||||
// ---- enforce 模式:各种失败都 fail closed ----
|
||||
{
|
||||
const h = makeCtx("cancelled");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
const out = await runPre(h, { name: "purge_revert", agent: {}, signal: { aborted: false } });
|
||||
check("enforce + cancelled maps to cancel", out.kind === "cancel");
|
||||
}
|
||||
{
|
||||
const h = makeCtx("something-else");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
const out = await runPre(h, { name: "purge_apply", agent: {}, signal: { aborted: false } });
|
||||
check("enforce + unknown outcome denies", out.kind === "deny" && /unavailable/.test(out.reason));
|
||||
}
|
||||
{
|
||||
const h = makeCtx("allowed-once", { withService: false });
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
const out = await runPre(h, { name: "purge_apply", agent: {}, signal: { aborted: false } });
|
||||
check("enforce + missing service denies", out.kind === "deny" && /approval service/.test(out.reason));
|
||||
}
|
||||
{
|
||||
const h = makeCtx("allowed-once");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
const out = await runPre(h, { name: "purge_apply", signal: { aborted: false } });
|
||||
check("enforce + missing agent denies", out.kind === "deny");
|
||||
}
|
||||
{
|
||||
const h = makeCtx("allowed-once", { throwOnRequest: true });
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
const out = await runPre(h, { name: "purge_apply", agent: {}, signal: { aborted: false } });
|
||||
check("enforce + throwing approval denies", out.kind === "deny" && /could not be confirmed/.test(out.reason));
|
||||
}
|
||||
{
|
||||
const h = makeCtx("allowed-once");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
const out = await runPre(h, { name: "purge_apply", agent: {}, signal: { aborted: true } });
|
||||
check("aborted signal cancels before requesting", out.kind === "cancel" && h.requests() === 0);
|
||||
}
|
||||
|
||||
// ---- 未受管工具两种模式都直接放行 ----
|
||||
{
|
||||
const h = makeCtx("rejected");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
let nextCalled = false;
|
||||
await h.handlers["tools/pre-execute"][0](
|
||||
{ name: "purge_status", signal: { aborted: false } },
|
||||
async () => { nextCalled = true; return "next"; },
|
||||
);
|
||||
check("ungated tool passes through even in enforce", nextCalled === true && h.requests() === 0);
|
||||
}
|
||||
|
||||
// ---- 一次批准不缓存 ----
|
||||
{
|
||||
const h = makeCtx("allowed-once");
|
||||
installToolGate(h.ctx, { mode: "enforce", auditPath: () => auditFile, fs, path });
|
||||
const exec = () => ({ name: "purge_apply", agent: {}, signal: { aborted: false } });
|
||||
await runPre(h, exec());
|
||||
await runPre(h, exec());
|
||||
check("each call requests approval again", h.requests() === 2, "requests=" + h.requests());
|
||||
}
|
||||
|
||||
// ---- post-execute 记结果,且不影响 next ----
|
||||
{
|
||||
fs.rmSync(auditFile, { force: true });
|
||||
const h = makeCtx("allowed-once");
|
||||
installToolGate(h.ctx, { auditPath: () => auditFile, fs, path });
|
||||
const out = await h.handlers["tools/post-execute"][0](
|
||||
{ name: "purge_apply" },
|
||||
{ isError: false, value: { text: "done" } },
|
||||
async () => "next",
|
||||
);
|
||||
check("post-execute passes result through", out === "next");
|
||||
check("result audited", readAudit().some((r) => r.phase === "result" && r.detail === "done"));
|
||||
}
|
||||
{
|
||||
const h = makeCtx("allowed-once");
|
||||
installToolGate(h.ctx, { auditPath: () => path.join("Z:\\nope\\nested", "audit.jsonl"), fs, path });
|
||||
const out = await h.handlers["tools/post-execute"][0]({ name: "purge_apply" }, { value: {} }, async () => "next");
|
||||
check("unwritable audit path does not break result", out === "next");
|
||||
}
|
||||
|
||||
// ---- dispose 撤钩子 ----
|
||||
{
|
||||
const h = makeCtx("allowed-once");
|
||||
const gate = installToolGate(h.ctx, { auditPath: () => auditFile, fs, path });
|
||||
check("two hooks installed", gate.installed === 2);
|
||||
gate.dispose();
|
||||
check("dispose removes hooks", h.handlers["tools/pre-execute"].length === 0 && h.handlers["tools/post-execute"].length === 0);
|
||||
}
|
||||
|
||||
// ---- 路由确认:不强制审批,只认显式意图头 ----
|
||||
{
|
||||
const once = requestRouteApproval({ headers: { "x-dsh-purge-confirm": "once" } });
|
||||
check("route header once is explicit", once.ok === true && once.via === "header");
|
||||
const human = requestRouteApproval({ headers: {} });
|
||||
check("route without header is implicit human", human.ok === true && human.via === "human");
|
||||
}
|
||||
|
||||
// ---- 结果映射 ----
|
||||
{
|
||||
check("allowed-once maps to allow", decisionFromOutcome("allowed-once").allow === true);
|
||||
check("cancelled maps to cancel", decisionFromOutcome("cancelled").kind === "cancel");
|
||||
check("rejected maps to deny", decisionFromOutcome("rejected").kind === "deny");
|
||||
check("unavailable maps to deny", decisionFromOutcome("unavailable").kind === "deny");
|
||||
}
|
||||
|
||||
fs.rmSync(auditDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\n${fail === 0 ? "ALL PASS" : `${fail} FAILED`} (${pass} passed)`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user