dsh-plan-completion 0.1.0 → 0.1.2

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.
@@ -0,0 +1,64 @@
1
+ # 安全与隐私边界
2
+
3
+ 本页描述 **v0.1.2** 的安全与隐私边界。历史版本(npm `0.1.0`/`0.1.1`)不包含本页描述的全部加固,请以 registry 最新版为准。
4
+
5
+ ## 本地计划存储(v0.1.2)
6
+
7
+ - 计划与最近一条建议按会话写入 `storageDir`(默认 `~/.dsh/plan-completion-data`)下的明文 JSON 文件,用于 DSH 重启恢复;
8
+ - 文件包含计划步骤、更新原因、建议文本等**由会话内容派生的数据**;不加密、不自动过期/清理,无文件锁(单进程假设);
9
+ - 同一机器多 profile 共用默认目录时可能互相看到计划;多用户共享机器不应使用默认目录;
10
+ - `persistPlans: false` 完全关闭磁盘持久化;`storageDir` 可指向受控目录。
11
+
12
+ ## 请求访问
13
+
14
+ 所有 `/plan-completion/*` 路由在读取请求体或执行操作前调用 DSH 的
15
+ `connection.requestRejection(req)`。宿主负责 Host/Origin 检查及 cookie/API peer 鉴权;
16
+ 无凭据、来源不受信任时返回 401/403,服务不可用或契约异常时返回 503,不退回“仅检查 loopback”。
17
+ 插件不反射任意 Origin、不开放跨来源凭据 CORS。
18
+
19
+ 默认只有 `state`、`replan`、`warm`。只有显式开启 `enableDebugRoutes: true` 才注册
20
+ `debug`、`diag`、`theme`、`experiment`、`autojob`、`steps`、`reset`,这些端点仍须鉴权。
21
+ DSH 是当前用户的可信宿主;本插件没有额外的多租户/逐会话 ACL。获得合法 DSH peer
22
+ 权限的客户端仍能访问该宿主内的计划,不应把这一机制当作多人共享服务的隔离边界。
23
+
24
+ 已核对本机安装版宿主源码:WebServer 不替插件鉴权;Electron 的 dsh-app HTTP 代理会
25
+ 去掉 renderer 的 Origin 并注入宿主 cookie,因此客户端继续使用 same-origin fetch。
26
+ 回归测试使用真实插件入口和模拟宿主服务;没有替换运行中的 DSH 或开展真实浏览器攻击测试。
27
+
28
+ ## 哪些内容会发送给模型
29
+
30
+ 默认 `modelCalls: true`。计划生成会把目标、近期用户/助手消息、工具错误及已有步骤证据
31
+ 组成提示词,经 DSH llm 服务发送到**宿主当前默认 provider/model**。它不一定是历史会话
32
+ 原先使用的模型,也不保证是本地模型。长度裁剪不是敏感信息脱敏;不要假定正文里的密码、
33
+ 个人信息或业务数据会被自动识别和移除。
34
+
35
+ 修复后不再遍历其他供应商挑选更快模型,也不缓存已过时的默认选择;无可用默认路由时使用启发式。
36
+ 调试实验的评审也使用同一已选路线,结果不再声称来自独立跨模型评审。
37
+ `modelCalls: false` 可关闭**本插件**的全部模型调用并使用启发式,不会关闭 DSH 主 agent 的模型调用。
38
+
39
+ `warmBackfill: true` 会在打开旧会话时读取持久化历史以生成计划,可能触发模型请求。
40
+ 设为 false 可关闭历史回填;若希望插件不向模型传输任何会话内容,同时设置 modelCalls: false。
41
+
42
+ ## 本地日志、诊断及截图
43
+
44
+ - debugDir 默认为空,不写运行日志;设置目录或 DSH_PLAN_COMPLETION_DEBUG_DIR 后才启用。
45
+ - 运行日志仅保留允许列表中的操作元数据;会话标识使用截短 SHA-256。
46
+ 不保留模型正文、原始错误、配置内容、请求 URL/查询串、UA 或客户端请求体。
47
+ 会话哈希仍是可关联的假名标识,日志不是匿名统计。
48
+ - 客户端不再发送 DOM 正文、补全建议文本或完整页面 URL。
49
+ 诊断端点再次按白名单保留布局尺寸和布尔值,忽略额外正文。
50
+ - 显式运行调试实验时,内存结果和 debugDir 下的实验 JSON 仍可能含计划、建议、
51
+ 模型评审原文等内容。应按会话内容保护,不要把 research/debug 文件提交或公开。
52
+ 这些文件不加密,也没有自动保留期限。
53
+ - 仓库现有四张截图未见密钥/cookie 等凭据,但包含昵称头像、费用用量、模型与开发任务内容。
54
+ 这类演示信息已经公开;后续公开演示宜换为虚构样本或仅截取插件区域。
55
+
56
+ ## 本次验证范围
57
+
58
+ 原代码的未鉴权访问和隐式跨 provider 发送,均用合成数据、离线 mock 复现。
59
+ 修复后的测试覆盖未授权/错误来源/鉴权服务缺失、诊断默认关闭、日志内容排除、
60
+ 默认模型切换、关闭模型调用,以及计划状态和前端生命周期。
61
+
62
+ 当前文本文件扫描未匹配常见私钥、GitHub/API/AWS 密钥模式;这不是完整 Git 历史扫描,
63
+ 也不能证明不存在所有形式的秘密。没有在真实模型端点、生产 DSH/Electron 或 npm 发布环境
64
+ 上执行端到端验收;本次运行结果不可替代部署后的兼容性验证。
@@ -0,0 +1,66 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // DSH owns Host/Origin checking and cookie/API-peer authentication.
4
+ // Fail closed during service startup/shutdown and with incompatible hosts.
5
+ export function requestRejection(connection, req) {
6
+ try {
7
+ if (typeof connection?.requestRejection !== 'function') return 503;
8
+ const rejection = connection.requestRejection(req);
9
+ if (rejection === undefined) return undefined;
10
+ return rejection === 401 || rejection === 403 ? rejection : 503;
11
+ } catch {
12
+ return 503;
13
+ }
14
+ }
15
+
16
+ const numberKeys = new Set(['turn', 'seq', 'elapsed', 'elapsedMs', 'events', 'maxTokens',
17
+ 'textChars', 'total', 'index', 'stepCount', 'at', 'fullEvidenceChars']);
18
+ const booleanKeys = new Set(['force', 'ok', 'hasSuggestion', 'suggestion',
19
+ 'withinSuggestionDeadline', 'existed', 'cookie']);
20
+ const labelKeys = new Set(['phase', 'outcome', 'step', 'source', 'method', 'mode', 'type', 'code']);
21
+
22
+ // Keep operational metadata only. Raw errors, model text, config, URLs, user agent,
23
+ // request bodies and model/provider details may contain conversation data or credentials.
24
+ export function safeDebugRecord(record = {}) {
25
+ const safe = {};
26
+ for (const [key, value] of Object.entries(record)) {
27
+ if (numberKeys.has(key) && Number.isFinite(value)) safe[key] = value;
28
+ else if (booleanKeys.has(key) && typeof value === 'boolean') safe[key] = value;
29
+ else if (labelKeys.has(key) && typeof value === 'string' && /^[a-zA-Z0-9_./:-]{1,80}$/.test(value)) safe[key] = value;
30
+ }
31
+ if (typeof record.sessionId === 'string' && record.sessionId) {
32
+ safe.sessionHash = createHash('sha256').update(record.sessionId).digest('hex').slice(0, 16);
33
+ }
34
+ if (record.error !== undefined) safe.error = true;
35
+ if (record.usage && typeof record.usage === 'object') {
36
+ safe.usage = Object.fromEntries(Object.entries(record.usage)
37
+ .filter(([key, value]) => /^(input|output|total|prompt|completion|cached|reasoning)(Tokens|_tokens)$/.test(key)
38
+ && Number.isFinite(value) && value >= 0));
39
+ }
40
+ return safe;
41
+ }
42
+
43
+ // Browser diagnostics are untrusted input, including with a valid DSH cookie.
44
+ // Retain only layout dimensions/booleans, never arbitrary DOM text or URLs.
45
+ export function safeDiagnostics(input) {
46
+ const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
47
+ const result = {};
48
+ for (const key of ['hasPill', 'hasEntry', 'suggestVisible', 'dockPresent', 'heroVisible']) {
49
+ if (typeof source[key] === 'boolean') result[key] = source[key];
50
+ }
51
+ if (['module-load', 'dom-check'].includes(source.phase)) result.phase = source.phase;
52
+ if (typeof source.build === 'string' && /^[a-zA-Z0-9._:-]{1,80}$/.test(source.build)) result.build = source.build;
53
+ if (Number.isFinite(source.rowHeight)) result.rowHeight = source.rowHeight;
54
+ for (const key of ['heroStyle', 'rowStyle', 'suggestStyle', 'entryStyle', 'composerStyle']) {
55
+ const style = source[key];
56
+ if (!style || typeof style !== 'object') continue;
57
+ result[key] = {};
58
+ for (const dimension of ['left', 'top', 'w', 'h']) {
59
+ if (Number.isFinite(style[dimension])) result[key][dimension] = style[dimension];
60
+ }
61
+ if (['none', 'block', 'inline', 'inline-block', 'flex', 'inline-flex', 'grid', 'contents'].includes(style.display)) {
62
+ result[key].display = style.display;
63
+ }
64
+ }
65
+ return result;
66
+ }
package/host/store.js CHANGED
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * 语义(两个 store 一致):
7
7
  * get(sessionId) → plan | null(返回副本,改它不影响存储)
8
- * put(plan, {cas}) → boolean;cas 省略=无条件写,null=仅新建,{gen,rev}=期望当前版本
8
+ * getState(sessionId) → { plan, suggestion } | null(持久化恢复用)
9
+ * put(plan, {cas,suggestion}) → boolean;cas 省略=无条件写,null=仅新建,{gen,rev}=期望当前版本;
10
+ * suggestion 存在时一并保存最近一次建议(undefined=保持不变)
9
11
  * history(sessionId) → plan[](旧 → 新,含当前版本)
10
12
  * prune(n = 5) → 每会话只保留最近 n 版
11
13
  * clear(sessionId?) → boolean | number
@@ -25,6 +27,10 @@ import { validVersion, versionEquals } from './plan.js';
25
27
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
26
28
  const isNonEmptyStr = (v) => typeof v === 'string' && v.trim().length > 0;
27
29
 
30
+ function hasOwn(obj, key) {
31
+ return isObj(obj) && Object.prototype.hasOwnProperty.call(obj, key);
32
+ }
33
+
28
34
  function clone(value) {
29
35
  if (value == null) return value;
30
36
  try {
@@ -69,7 +75,7 @@ function pushHistory(state, plan) {
69
75
  * @returns {object}
70
76
  */
71
77
  export function createMemoryStore() {
72
- /** @type {Map<string, {plan: object, history: object[]}>} */
78
+ /** @type {Map<string, {plan: object, suggestion: object|null, history: object[]}>} */
73
79
  const states = new Map();
74
80
 
75
81
  function get(sessionId) {
@@ -78,13 +84,21 @@ export function createMemoryStore() {
78
84
  return state && state.plan ? clone(state.plan) : null;
79
85
  }
80
86
 
87
+ function getState(sessionId) {
88
+ if (!isNonEmptyStr(sessionId)) return null;
89
+ const state = states.get(sessionId);
90
+ if (!state || !state.plan) return null;
91
+ return { plan: clone(state.plan), suggestion: state.suggestion ?? null };
92
+ }
93
+
81
94
  function put(plan, options = {}) {
82
95
  if (!lightValidate(plan)) return false;
83
- const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
96
+ const cas = hasOwn(options, 'cas') ? options.cas : undefined;
84
97
  const sessionId = plan.sessionId;
85
- const state = states.get(sessionId) ?? { plan: null, history: [] };
98
+ const state = states.get(sessionId) ?? { plan: null, suggestion: null, history: [] };
86
99
  if (!casMatches(cas, state)) return false;
87
100
  state.plan = clone(plan);
101
+ if (hasOwn(options, 'suggestion')) state.suggestion = clone(options.suggestion) ?? null;
88
102
  pushHistory(state, plan);
89
103
  states.set(sessionId, state);
90
104
  return true;
@@ -124,7 +138,7 @@ export function createMemoryStore() {
124
138
  return [...states.keys()];
125
139
  }
126
140
 
127
- return { get, put, history, prune, clear, listSessions, size: () => states.size };
141
+ return { get, getState, put, history, prune, clear, listSessions, size: () => states.size };
128
142
  }
129
143
 
130
144
  /* ──────────────────────────────────────────────────────────────
@@ -171,15 +185,22 @@ export function createFileStore(dir) {
171
185
  const data = await readState(sessionId);
172
186
  return data && isObj(data.plan) ? clone(data.plan) : null;
173
187
  },
188
+ async getState(sessionId) {
189
+ if (!enabled || !isNonEmptyStr(sessionId)) return null;
190
+ const data = await readState(sessionId);
191
+ if (!data || !isObj(data.plan)) return null;
192
+ return { plan: clone(data.plan), suggestion: isObj(data.suggestion) ? clone(data.suggestion) : null };
193
+ },
174
194
  async put(plan, options = {}) {
175
195
  if (!enabled || !lightValidate(plan)) return false;
176
196
  try {
177
- const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
197
+ const cas = hasOwn(options, 'cas') ? options.cas : undefined;
178
198
  const sessionId = plan.sessionId;
179
- const data = (await readState(sessionId)) ?? { sessionId, plan: null, history: [] };
199
+ const data = (await readState(sessionId)) ?? { sessionId, plan: null, suggestion: null, history: [] };
180
200
  if (!casMatches(cas, data)) return false;
181
201
  data.plan = clone(plan);
182
202
  data.sessionId = sessionId;
203
+ if (hasOwn(options, 'suggestion')) data.suggestion = clone(options.suggestion) ?? null;
183
204
  if (!Array.isArray(data.history)) data.history = [];
184
205
  const last = data.history[data.history.length - 1];
185
206
  if (last && versionEquals(last.version, plan.version)) data.history[data.history.length - 1] = clone(plan);