dsh-security-center 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # Security Center(安全中心)
2
+
3
+ 把**设置页里看不见的安全机制**收拢到一处管理,并且**只允许「交互式终端中的人工确认」修改**。
4
+
5
+ ## 它管理什么(这些机制原本没有任何 UI 入口)
6
+
7
+ | 机制 | 位置 | 削弱后的后果 |
8
+ |---|---|---|
9
+ | `minimumReleaseAge` | `<profile>/pnpm-workspace.yaml` | 关闭供应链冷却:刚发布(可能被投毒)的版本立即安装 |
10
+ | `minimumReleaseAgeStrict` | 同上 | 范围内没有够老的版本时**静默回退旧版本**(「界面显示新版、实际装旧版」的成因) |
11
+ | `minimumReleaseAgeIgnoreMissingTime` | 同上 | 不提供发布时间的注册表绕过冷却 |
12
+ | `minimumReleaseAgeExclude` | 同上 | 列入的包/版本跳过冷却(版本过新时的官方解法) |
13
+ | `blockExoticSubdeps` | 同上 | 间接依赖可从 git/tarball 直链拉取代码 |
14
+ | `trustLockfile` | 同上 | 无条件信任 lockfile,被污染的 lockfile 直接进入 node_modules |
15
+ | `trustPolicy` | 同上 | 发布信任等级降级的版本也能安装 |
16
+ | `allowBuilds` | 同上 | 依赖在安装时执行任意代码(postinstall) |
17
+
18
+ > 说明:`minimumReleaseAge` 未显式配置时,**pnpm 11 内置默认 1440 分钟(非严格)**。
19
+
20
+ ## 安全模型(本插件为什么这样做)
21
+
22
+ 用户要求:*拒绝所有非点击事件的操作;关闭安全机制/功能时警告后果、需确认才能改、取消即放弃。*
23
+
24
+ **实现方式(比点击更严格)**:
25
+
26
+ 1. **不注册任何 agent 工具、不注册任何命令** —— 模型没有任何句柄能碰到这些机制。
27
+ 2. 写入必须携带宿主服务的**单次同意令牌**:绑定「机制 + 值」、120 秒过期、用后作废。
28
+ 3. 令牌只能由 **`tools/security-center.mjs` 在交互式终端(TTY)里**取得:先打印后果说明,再要求人工输入 `y`。
29
+ **AI 的 bash 调用不是 TTY → 直接以退出码 3 拒绝**(已测试)。
30
+ 4. 所有应用动作进审计轨迹(`securityCenter.audit()`)。
31
+
32
+ **诚实边界**:这套闸门关掉的是「模型/脚本/其他插件偷偷改安全设置」的路径;它不能防御已经能在你机器上以你身份执行任意交互式命令的攻击者——那种情况下什么都防不住。
33
+
34
+ ## 用法
35
+
36
+ ```bash
37
+ # 只读查看(任何环境都可以)
38
+ node <插件目录>/tools/security-center.mjs describe
39
+
40
+ # 修改(必须在你自己的交互终端里,会先警告再要求输入 y)
41
+ node <插件目录>/tools/security-center.mjs set minimumReleaseAge 1440
42
+ node <插件目录>/tools/security-center.mjs set minimumReleaseAge default # 恢复内置默认(删键)
43
+ node <插件目录>/tools/security-center.mjs set minimumReleaseAgeExclude "mypkg@1.2.3,other@2.0.0"
44
+ ```
45
+
46
+ 默认 profile 为 `desktop`,可用 `--profile <名称>` 或 `DSH_HOME` 指定。
47
+
48
+ ## 与「版本过新」的关系
49
+
50
+ pnpm 11 默认的 24 小时冷却会让**刚发布的新版本装不上**,并且因为默认**非严格**,
51
+ 会静默回退到旧版本 —— 表现为「插件页显示新版本,装完却是旧版本」。三条出路:
52
+
53
+ 1. **安装时带精确版本**(推荐):插件页输入 `包名@x.y.z`,管理器会把该版本写入冷却豁免;
54
+ 2. 用 `set minimumReleaseAgeExclude` 把该版本加入豁免(本插件,需人工确认);
55
+ 3. 等冷却窗口过去(默认 24 小时)后普通安装。
56
+
57
+ 安全中心把这些机制**看得见、可审计、且改不动于无形**。
58
+
59
+ ## English
60
+
61
+ One page/CLI for the security mechanisms the settings UI never shows (pnpm
62
+ `minimumReleaseAge` cooldown + strictness + exemptions, `blockExoticSubdeps`,
63
+ `trustLockfile`, `trustPolicy`, `allowBuilds`). The plugin registers **no agent
64
+ tools and no commands**; every write needs a single-use consent token that can
65
+ only be minted from an interactive terminal after an explicit `y` confirmation,
66
+ so an agent-driven shell (not a TTY) is refused outright. Read-only `describe`
67
+ is safe anywhere.
68
+
69
+ License: MIT. Author: jd962.
@@ -0,0 +1,6 @@
1
+ # Security Center. This plugin manages SECURITY POLICY FILES, so it is
2
+ # deliberately loadable with no configuration of its own and it registers no
3
+ # agent-facing tools or commands.
4
+ - insert:
5
+ - id: security-center
6
+ name: 'dsh-security-center'
Binary file
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2
+ <path d="M32 6 L54 15 V32 C54 45 44 54 32 58 C20 54 10 45 10 32 V15 Z" fill="#0f172a" stroke="#22c55e" stroke-width="3"/>
3
+ <path d="M22 32 L29 39 L43 25" stroke="#22c55e" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
4
+ </svg>
package/index.js ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Security Center — surfaces and guards the security mechanisms the normal
3
+ * settings UI does not show.
4
+ *
5
+ * WHAT IT MANAGES (all install-time / supply-chain policy, none of it visible
6
+ * in the harness settings pages today):
7
+ *
8
+ * | mechanism | where it lives | risk if weakened |
9
+ * |---|---|---|
10
+ * | `minimumReleaseAge` | <profile>/pnpm-workspace.yaml | installs versions published minutes ago; the window that lets the ecosystem find a malicious release disappears |
11
+ * | `minimumReleaseAgeStrict` | same | resolution silently falls back to an older satisfying version instead of failing loudly (the "shows new, installs old" trap) |
12
+ * | `minimumReleaseAgeIgnoreMissingTime` | same | registries without publish-time metadata bypass the cooldown entirely |
13
+ * | `minimumReleaseAgeExclude` | same | named packages/versions skip the cooldown |
14
+ * | `trustPolicy` (+Exclude/IgnoreAfter) | same | a package may be installed even after its publishing trust level dropped |
15
+ * | `blockExoticSubdeps` | same | transitive deps may resolve from git/tarball URLs |
16
+ * | `trustLockfile` | same | the lockfile is trusted blindly; a poisoned lockfile slips through |
17
+ * | `allowBuilds` | same | dependency install scripts run without explicit approval |
18
+ * | profile compatibility exemptions | <profile>/compatibility.json | package/runtime mismatches stop being enforced |
19
+ *
20
+ * HOW CHANGES ARE GUARDED (the user's requirement, verbatim: reject every
21
+ * non-click operation, warn about consequences, require confirmation, cancel
22
+ * discards):
23
+ *
24
+ * 1. This plugin registers **no agent tools and no commands**. A model has no
25
+ * handle to these mechanisms at all.
26
+ * 2. Every write goes through a **single-use consent token**: the browser page
27
+ * mints one from inside a real click handler, after showing the warning
28
+ * dialog; the host refuses any write that does not carry a live token bound
29
+ * to that exact mechanism and value. Cancel never mints, so nothing is sent.
30
+ * 3. Tokens are single-use and expire (120 s), and every apply is appended to
31
+ * an in-memory audit trail (returned by `audit()`).
32
+ *
33
+ * HONEST LIMITATION, stated in the UI as well: the token handshake closes the
34
+ * agent/plugin path. It is not a defence against arbitrary script already
35
+ * running inside the harness page - nothing in-page can be. The threat model
36
+ * addressed here is "the model, a command, or another plugin must not be able
37
+ * to weaken security on its own".
38
+ */
39
+ import { randomUUID } from 'node:crypto'
40
+ import { readFile, writeFile } from 'node:fs/promises'
41
+ import { join } from 'node:path'
42
+ import { Service } from '@deepseek-ai/cordis'
43
+
44
+ /** How long one consent token stays valid (ms). */
45
+ const CONSENT_TTL_MS = 120_000
46
+
47
+ /** Mechanisms this plugin owns, with their consequence text. */
48
+ const MECHANISMS = [
49
+ {
50
+ id: 'minimumReleaseAge',
51
+ file: 'pnpm-workspace.yaml',
52
+ kind: 'number',
53
+ risk: 'high',
54
+ titleZh: '新版本冷却窗口(分钟)',
55
+ titleEn: 'New-version cooldown (minutes)',
56
+ consequenceZh: '设为 0 等于关闭供应链冷却:刚发布的版本(含被投毒的版本)会被立即安装。社区发现并撤回恶意版本通常需要数小时到一天。',
57
+ consequenceEn: 'Setting 0 disables the supply-chain cooldown: versions published minutes ago (including poisoned ones) install immediately. The ecosystem usually needs hours to a day to detect and yank a malicious release.',
58
+ },
59
+ {
60
+ id: 'minimumReleaseAgeStrict',
61
+ file: 'pnpm-workspace.yaml',
62
+ kind: 'boolean',
63
+ risk: 'medium',
64
+ titleZh: '冷却窗口严格模式',
65
+ titleEn: 'Cooldown strict mode',
66
+ consequenceZh: '关闭后,当范围内没有"够老"的版本时,pnpm 会静默回退到旧版本而不是明确失败——会出现「界面显示新版本、实际装上旧版本」的现象。',
67
+ consequenceEn: 'When off, pnpm silently falls back to an older version instead of failing when nothing in range is old enough - the "UI shows new, installs old" trap.',
68
+ },
69
+ {
70
+ id: 'minimumReleaseAgeIgnoreMissingTime',
71
+ file: 'pnpm-workspace.yaml',
72
+ kind: 'boolean',
73
+ risk: 'medium',
74
+ titleZh: '忽略缺少发布时间的注册表',
75
+ titleEn: 'Ignore registries without publish time',
76
+ consequenceZh: '打开时,注册表若不提供发布时间元数据,包会跳过冷却检查直接安装。',
77
+ consequenceEn: 'When on, packages from registries that omit publish-time metadata skip the cooldown entirely.',
78
+ },
79
+ {
80
+ id: 'blockExoticSubdeps',
81
+ file: 'pnpm-workspace.yaml',
82
+ kind: 'boolean',
83
+ risk: 'high',
84
+ titleZh: '阻断异常来源的传递依赖',
85
+ titleEn: 'Block exotic transitive dependencies',
86
+ consequenceZh: '关闭后,间接依赖可以从 git 仓库或 tarball 直链拉取代码,绕过注册表审查。',
87
+ consequenceEn: 'When off, transitive dependencies may resolve from git repositories or direct tarball URLs, bypassing registry review.',
88
+ },
89
+ {
90
+ id: 'trustLockfile',
91
+ file: 'pnpm-workspace.yaml',
92
+ kind: 'boolean',
93
+ risk: 'critical',
94
+ titleZh: '无条件信任 lockfile(跳过校验)',
95
+ titleEn: 'Trust the lockfile blindly',
96
+ consequenceZh: '打开后,安装时不再对 lockfile 里的每个条目重放冷却与信任策略。被污染的 lockfile 会直接进入 node_modules。仅在 lockfile 完全可控时使用。',
97
+ consequenceEn: 'When on, install no longer re-applies the cooldown and trust policies to every lockfile entry. A poisoned lockfile goes straight into node_modules. Only for fully controlled lockfiles.',
98
+ },
99
+ {
100
+ id: 'trustPolicy',
101
+ file: 'pnpm-workspace.yaml',
102
+ kind: 'enum',
103
+ values: ['off', 'no-downgrade'],
104
+ risk: 'high',
105
+ titleZh: '发布者信任降级策略',
106
+ titleEn: 'Publisher trust downgrade policy',
107
+ consequenceZh: '设为 off 后,某个包从"受信发布者/带来源证明"降级为"无信任证据"的版本也能安装。',
108
+ consequenceEn: 'With off, a version that dropped from a trusted publisher or provenance to no trust evidence still installs.',
109
+ },
110
+ {
111
+ id: 'allowBuilds',
112
+ file: 'pnpm-workspace.yaml',
113
+ kind: 'list',
114
+ risk: 'critical',
115
+ titleZh: '允许运行安装脚本的依赖',
116
+ titleEn: 'Dependencies allowed to run install scripts',
117
+ consequenceZh: '列入的依赖会在安装时执行任意代码(postinstall)。移除审批项会更严格;新增等于授予代码执行权。',
118
+ consequenceEn: 'Listed dependencies execute arbitrary code at install time (postinstall). Removing approvals is stricter; adding one grants code execution.',
119
+ },
120
+ {
121
+ id: 'minimumReleaseAgeExclude',
122
+ file: 'pnpm-workspace.yaml',
123
+ kind: 'list',
124
+ risk: 'medium',
125
+ titleZh: '冷却豁免清单',
126
+ titleEn: 'Cooldown exemption list',
127
+ consequenceZh: '列入的包(或精确版本 pkg@x.y.z)跳过冷却窗口。用精确版本 `pkg@1.2.3` 而非裸包名,把例外限制到最小;正常窗口过后请移除。',
128
+ consequenceEn: 'Listed packages (or exact versions pkg@x.y.z) skip the cooldown. Prefer exact versions over bare names to keep the exception minimal, and remove it once the normal window has passed.',
129
+ },
130
+ ]
131
+
132
+ /** Read one scalar/list key from a small YAML document (top-level only). */
133
+ export function readYamlKey(text, key) {
134
+ const lines = String(text ?? '').split(/\r?\n/)
135
+ const head = new RegExp(`^${key}\\s*:(.*)$`)
136
+ for (let index = 0; index < lines.length; index++) {
137
+ const match = head.exec(lines[index])
138
+ if (match === null) continue
139
+ const inline = match[1].trim()
140
+ if (inline !== '') {
141
+ if (inline.startsWith('[')) {
142
+ return inline.replace(/^\[|\]$/g, '').split(',').map((part) => stripQuotes(part.trim())).filter((part) => part !== '')
143
+ }
144
+ return stripQuotes(inline)
145
+ }
146
+ // Block form: collect the following more-indented "- item" lines.
147
+ const items = []
148
+ for (let next = index + 1; next < lines.length; next++) {
149
+ const line = lines[next]
150
+ if (line.trim() === '') continue
151
+ if (!/^\s/.test(line)) break
152
+ const item = /^\s*-\s*(.*)$/.exec(line)
153
+ if (item === null) continue
154
+ items.push(stripQuotes(item[1].trim()))
155
+ }
156
+ return items
157
+ }
158
+ return undefined
159
+ }
160
+
161
+ function stripQuotes(value) {
162
+ const trimmed = String(value).trim()
163
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
164
+ return trimmed.slice(1, -1)
165
+ }
166
+ return trimmed
167
+ }
168
+
169
+ /** Render one value as YAML (scalar or block list). */
170
+ function renderYaml(key, value) {
171
+ if (Array.isArray(value)) {
172
+ if (value.length === 0) return `${key}: []`
173
+ return [`${key}:`, ...value.map((item) => ` - '${String(item).replace(/'/g, "''")}'`)].join('\n')
174
+ }
175
+ if (typeof value === 'string') return `${key}: ${value}`
176
+ return `${key}: ${String(value)}`
177
+ }
178
+
179
+ /**
180
+ * Replace or insert one top-level key, preserving every other line (comments
181
+ * included). Removing a key deletes its whole block.
182
+ */
183
+ export function writeYamlKey(text, key, value) {
184
+ const source = String(text ?? '')
185
+ const lines = source.split(/\r?\n/)
186
+ const head = new RegExp(`^${key}\\s*:`)
187
+ const start = lines.findIndex((line) => head.test(line))
188
+ const rendered = value === undefined ? null : renderYaml(key, value)
189
+ if (start === -1) {
190
+ if (rendered === null) return source
191
+ const body = source.endsWith('\n') || source === '' ? source : `${source}\n`
192
+ return `${body}${rendered}\n`
193
+ }
194
+ let end = start + 1
195
+ while (end < lines.length && (lines[end].trim() === '' || /^\s/.test(lines[end]))) end++
196
+ const replacement = rendered === null ? [] : rendered.split('\n')
197
+ return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join('\n')
198
+ }
199
+
200
+ export default class SecurityCenter extends Service {
201
+ // Managing the profile's security files is the whole point: without a
202
+ // profile context there is nothing to guard, so this plugin simply does not
203
+ // activate there (the same shape the official plugin manager uses).
204
+ static inject = ['profileContext']
205
+
206
+ constructor(ctx) {
207
+ super(ctx, 'securityCenter')
208
+ this._ctx = ctx
209
+ this._consents = new Map()
210
+ this._audit = []
211
+ }
212
+
213
+ /** Profile directory that owns the security policy files. */
214
+ _profileDir() {
215
+ const profile = this._ctx.profileContext
216
+ const dir = profile?.dir
217
+ if (typeof dir !== 'string' || dir === '') {
218
+ throw new Error('security-center: this context has no profile directory; refusing to touch security files')
219
+ }
220
+ return dir
221
+ }
222
+
223
+ _filePath(file) {
224
+ return join(this._profileDir(), file)
225
+ }
226
+
227
+ async _readFile(file) {
228
+ try {
229
+ return await readFile(this._filePath(file), 'utf8')
230
+ } catch (error) {
231
+ if (error?.code === 'ENOENT') return ''
232
+ throw error
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Current state of every managed mechanism, for the UI.
238
+ * @returns {Promise<object>} mechanisms with values, defaults and risk text.
239
+ */
240
+ async describeAll() {
241
+ const yaml = await this._readFile('pnpm-workspace.yaml')
242
+ const rows = MECHANISMS.map((mechanism) => {
243
+ const raw = readYamlKey(yaml, mechanism.id)
244
+ let value = raw
245
+ if (mechanism.kind === 'boolean' && raw !== undefined) value = raw === 'true'
246
+ if (mechanism.kind === 'number' && raw !== undefined) value = Number(raw)
247
+ return {
248
+ id: mechanism.id,
249
+ kind: mechanism.kind,
250
+ risk: mechanism.risk,
251
+ ...(mechanism.values === undefined ? {} : { values: mechanism.values }),
252
+ ...(mechanism.kind === 'list' ? { value: Array.isArray(value) ? value : [] } : { value }),
253
+ configured: raw !== undefined,
254
+ title: { zh: mechanism.titleZh, en: mechanism.titleEn },
255
+ consequence: { zh: mechanism.consequenceZh, en: mechanism.consequenceEn },
256
+ }
257
+ })
258
+ return {
259
+ profileDir: this._profileDir(),
260
+ mechanisms: rows,
261
+ // pnpm 11 ships minimumReleaseAge=1440 as a built-in default; surface it
262
+ // so a missing key never reads as "no protection".
263
+ note: 'minimumReleaseAge 未显式配置时,pnpm 11 内置默认 1440 分钟(非严格)。',
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Mint one single-use consent token for an exact mechanism+value pair.
269
+ * Called from the browser's click handler AFTER the warning dialog; the host
270
+ * refuses every write that does not carry a live token.
271
+ * @param {string} mechanismId
272
+ * @param {unknown} value
273
+ */
274
+ mintConsent(mechanismId, value) {
275
+ const mechanism = MECHANISMS.find((row) => row.id === mechanismId)
276
+ if (mechanism === undefined) throw new Error(`security-center: unknown mechanism ${JSON.stringify(mechanismId)}`)
277
+ // `undefined` means "restore the upstream default" and skips value checks.
278
+ const normalized = value === undefined ? undefined : this._normalize(mechanism, value)
279
+ const token = randomUUID()
280
+ const expiresAt = Date.now() + CONSENT_TTL_MS
281
+ this._pruneConsents()
282
+ this._consents.set(token, { mechanismId, value: normalized, expiresAt, used: false })
283
+ this._appendAudit({ kind: 'consent-minted', mechanismId, value: normalized, at: Date.now() })
284
+ return { token, expiresAt, ttlMs: CONSENT_TTL_MS, mechanismId, value: normalized }
285
+ }
286
+
287
+ /** Drop expired/consumed grants so the map cannot grow without bound. */
288
+ _pruneConsents() {
289
+ const now = Date.now()
290
+ for (const [token, grant] of this._consents) {
291
+ if (grant.used || now > grant.expiresAt) this._consents.delete(token)
292
+ }
293
+ }
294
+
295
+ /** Stable identity for one normalized value (arrays compare by content). */
296
+ _valueKey(value) {
297
+ return JSON.stringify(value === undefined ? null : value)
298
+ }
299
+
300
+ /** Validate one value against its mechanism definition. */
301
+ _normalize(mechanism, value) {
302
+ if (mechanism.kind === 'boolean') {
303
+ if (typeof value !== 'boolean') throw new Error('security-center: expected a boolean')
304
+ return value
305
+ }
306
+ if (mechanism.kind === 'number') {
307
+ const number = Number(value)
308
+ if (!Number.isFinite(number) || number < 0 || Math.trunc(number) !== number) {
309
+ throw new Error('security-center: expected a non-negative integer')
310
+ }
311
+ return number
312
+ }
313
+ if (mechanism.kind === 'enum') {
314
+ if (!mechanism.values.includes(String(value))) {
315
+ throw new Error(`security-center: expected one of ${mechanism.values.join(', ')}`)
316
+ }
317
+ return String(value)
318
+ }
319
+ if (mechanism.kind === 'list') {
320
+ if (!Array.isArray(value)) throw new Error('security-center: expected a list')
321
+ return value.map((item) => String(item).trim()).filter((item) => item !== '')
322
+ }
323
+ throw new Error(`security-center: unsupported mechanism kind ${mechanism.kind}`)
324
+ }
325
+
326
+ /**
327
+ * Apply one consented change. Fails closed for every path that a click did
328
+ * not originate: missing token, unknown token, expired token, reused token,
329
+ * or a token bound to a different mechanism/value.
330
+ * @param {string} mechanismId
331
+ * @param {unknown} value
332
+ * @param {string} token
333
+ */
334
+ async apply(mechanismId, value, token) {
335
+ const mechanism = MECHANISMS.find((row) => row.id === mechanismId)
336
+ if (mechanism === undefined) throw new Error(`security-center: unknown mechanism ${JSON.stringify(mechanismId)}`)
337
+ const restore = value === undefined
338
+ const normalized = restore ? undefined : this._normalize(mechanism, value)
339
+ const consent = typeof token === 'string' ? this._consents.get(token) : undefined
340
+ if (consent === undefined) throw new Error('security-center: refused - no consent token (this change requires a confirmed UI click)')
341
+ if (consent.used) throw new Error('security-center: refused - this consent token was already used')
342
+ if (Date.now() > consent.expiresAt) {
343
+ this._consents.delete(token)
344
+ throw new Error('security-center: refused - the consent token expired; confirm again')
345
+ }
346
+ if (consent.mechanismId !== mechanismId || this._valueKey(consent.value) !== this._valueKey(normalized)) {
347
+ throw new Error('security-center: refused - the consent token is bound to a different change')
348
+ }
349
+ // Mark consumed (kept briefly so a replay reports "already used", then
350
+ // pruned on the next mint).
351
+ consent.used = true
352
+
353
+ // `undefined` restores the upstream default by removing the key.
354
+ const yaml = await this._readFile('pnpm-workspace.yaml')
355
+ const next = writeYamlKey(yaml, mechanismId, restore ? undefined : normalized)
356
+ if (next !== yaml) await writeFile(this._filePath('pnpm-workspace.yaml'), next, 'utf8')
357
+ this._appendAudit({ kind: 'applied', mechanismId, value: restore ? '(default)' : normalized, at: Date.now() })
358
+ return { ok: true, mechanismId, applied: restore ? undefined : normalized, restored: restore }
359
+ }
360
+
361
+ /** Recent security changes (newest first). */
362
+ audit() {
363
+ return this._audit.slice(-100).reverse()
364
+ }
365
+
366
+ _appendAudit(entry) {
367
+ this._audit.push(entry)
368
+ if (this._audit.length > 200) this._audit.shift()
369
+ this._ctx.logger?.info?.('security-center: %s %s', entry.kind, entry.mechanismId)
370
+ }
371
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "dsh-security-center",
3
+ "description": "Security center for DeepSeek Harness: one page that surfaces the supply-chain and install-time security mechanisms the normal settings UI does NOT show (pnpm minimumReleaseAge cooldown and its exemptions, strictness, trust policy, exotic-subdependency blocking, lockfile trust bypass, build-script approvals, profile compatibility exemptions). Every change requires a genuine UI click through a warning dialog and a single-use consent token, so no agent tool, command, or other plugin can flip a security switch programmatically. The plugin registers NO agent-facing tools at all, on purpose.",
4
+ "version": "1.0.0",
5
+ "author": "jd962",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "exports": {
10
+ ".": "./index.js",
11
+ "./client": "./client.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "icon": "./icon.svg",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=24",
20
+ "dsh": "0.2.0-rc.2"
21
+ },
22
+ "dsh": {
23
+ "manifestVersion": 1,
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ },
27
+ "client": {
28
+ "platform": "web",
29
+ "inject": [
30
+ "@deepseek-ai/dsh-client-locale",
31
+ "@deepseek-ai/dsh-client-ui-primitives",
32
+ "@deepseek-ai/dsh-client-ui-slots"
33
+ ]
34
+ }
35
+ },
36
+ "peerDependencies": {
37
+ "@deepseek-ai/cordis": "*"
38
+ }
39
+ }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Security Center command line.
4
+ *
5
+ * This is the ONLY way to change a managed security mechanism. It is built so
6
+ * that a non-interactive caller (an agent's bash tool, a script, a cron job)
7
+ * cannot weaken security:
8
+ *
9
+ * - `describe` is read-only and safe anywhere.
10
+ * - `set` refuses to run unless stdin AND stdout are real terminals (a TTY),
11
+ * then prints the consequences and requires an explicit `y` confirmation.
12
+ * An agent-driven shell is not a TTY, so it cannot mint the consent the
13
+ * service requires - the host refuses every write without one.
14
+ *
15
+ * Usage:
16
+ * node <plugin>/tools/security-center.mjs describe
17
+ * node <plugin>/tools/security-center.mjs set minimumReleaseAge 1440
18
+ * node <plugin>/tools/security-center.mjs set minimumReleaseAge default
19
+ * node <plugin>/tools/security-center.mjs set minimumReleaseAgeExclude ""
20
+ * node <plugin>/tools/security-center.mjs set minimumReleaseAgeExclude "pkg@1.2.3,other@2.0.0"
21
+ *
22
+ * The service itself lives in the running harness (`ctx.securityCenter`); this
23
+ * tool performs the same file edits with the same validations, so the two
24
+ * cannot disagree.
25
+ */
26
+ import { createInterface } from 'node:readline'
27
+ import { readFile, writeFile } from 'node:fs/promises'
28
+ import { existsSync } from 'node:fs'
29
+ import { join } from 'node:path'
30
+ import { homedir } from 'node:os'
31
+
32
+ const MECHANISM_IDS = [
33
+ 'minimumReleaseAge',
34
+ 'minimumReleaseAgeStrict',
35
+ 'minimumReleaseAgeIgnoreMissingTime',
36
+ 'minimumReleaseAgeExclude',
37
+ 'blockExoticSubdeps',
38
+ 'trustLockfile',
39
+ 'trustPolicy',
40
+ 'allowBuilds',
41
+ ]
42
+
43
+ const BOOLEANS = new Set(['minimumReleaseAgeStrict', 'minimumReleaseAgeIgnoreMissingTime', 'blockExoticSubdeps', 'trustLockfile'])
44
+ const LISTS = new Set(['minimumReleaseAgeExclude', 'allowBuilds'])
45
+
46
+ const CONSEQUENCE = {
47
+ minimumReleaseAge: '冷却窗口设为 0 等于关闭供应链防护:刚发布(可能被投毒)的版本会被立即安装。',
48
+ minimumReleaseAgeStrict: '关闭后,范围内没有足够老的版本时 pnpm 会静默回退到旧版本,而不是明确失败。',
49
+ minimumReleaseAgeIgnoreMissingTime: '打开后,不提供发布时间的注册表会绕过冷却检查。',
50
+ minimumReleaseAgeExclude: '列入的包/版本跳过冷却窗口。请用精确版本 pkg@x.y.z,并在窗口过后移除。',
51
+ blockExoticSubdeps: '关闭后,间接依赖可从 git/tarball 直链拉取代码。',
52
+ trustLockfile: '打开后安装不再校验 lockfile 各条目;被污染的 lockfile 会直接进入 node_modules。',
53
+ trustPolicy: '设为 off 后,发布信任等级发生降级的版本也能安装。',
54
+ allowBuilds: '列入的依赖会在安装时执行任意代码(postinstall)。',
55
+ }
56
+
57
+ function profileDir() {
58
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
59
+ const profile = process.argv.includes('--profile')
60
+ ? process.argv[process.argv.indexOf('--profile') + 1]
61
+ : 'desktop'
62
+ const dir = join(home, 'profiles', profile)
63
+ if (!existsSync(dir)) {
64
+ console.error(`找不到 profile 目录:${dir}`)
65
+ console.error('用 --profile <名称> 指定,或用 DSH_HOME 指定 harness 主目录。')
66
+ process.exit(2)
67
+ }
68
+ return dir
69
+ }
70
+
71
+ /* ---------------------------- YAML helpers ---------------------------- */
72
+ /* Kept byte-compatible with the host service's index.js helpers. */
73
+
74
+ function stripQuotes(value) {
75
+ const trimmed = String(value).trim()
76
+ if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
77
+ return trimmed.slice(1, -1)
78
+ }
79
+ return trimmed
80
+ }
81
+
82
+ function readYamlKey(text, key) {
83
+ const lines = String(text ?? '').split(/\r?\n/)
84
+ const head = new RegExp(`^${key}\\s*:(.*)$`)
85
+ for (let index = 0; index < lines.length; index++) {
86
+ const match = head.exec(lines[index])
87
+ if (match === null) continue
88
+ const inline = match[1].trim()
89
+ if (inline !== '') {
90
+ if (inline.startsWith('[')) {
91
+ return inline.replace(/^\[|\]$/g, '').split(',').map((part) => stripQuotes(part.trim())).filter((part) => part !== '')
92
+ }
93
+ return stripQuotes(inline)
94
+ }
95
+ const items = []
96
+ for (let next = index + 1; next < lines.length; next++) {
97
+ const line = lines[next]
98
+ if (line.trim() === '') continue
99
+ if (!/^\s/.test(line)) break
100
+ const item = /^\s*-\s*(.*)$/.exec(line)
101
+ if (item === null) continue
102
+ items.push(stripQuotes(item[1].trim()))
103
+ }
104
+ return items
105
+ }
106
+ return undefined
107
+ }
108
+
109
+ function renderYaml(key, value) {
110
+ if (Array.isArray(value)) {
111
+ if (value.length === 0) return `${key}: []`
112
+ return [`${key}:`, ...value.map((item) => ` - '${String(item).replace(/'/g, "''")}'`)].join('\n')
113
+ }
114
+ return `${key}: ${String(value)}`
115
+ }
116
+
117
+ function writeYamlKey(text, key, value) {
118
+ const source = String(text ?? '')
119
+ const lines = source.split(/\r?\n/)
120
+ const head = new RegExp(`^${key}\\s*:`)
121
+ const start = lines.findIndex((line) => head.test(line))
122
+ const rendered = value === undefined ? null : renderYaml(key, value)
123
+ if (start === -1) {
124
+ if (rendered === null) return source
125
+ const body = source.endsWith('\n') || source === '' ? source : `${source}\n`
126
+ return `${body}${rendered}\n`
127
+ }
128
+ let end = start + 1
129
+ while (end < lines.length && (lines[end].trim() === '' || /^\s/.test(lines[end]))) end++
130
+ const replacement = rendered === null ? [] : rendered.split('\n')
131
+ return [...lines.slice(0, start), ...replacement, ...lines.slice(end)].join('\n')
132
+ }
133
+
134
+ /* ------------------------------- gates -------------------------------- */
135
+
136
+ /** The anti-agent gate: no interactive terminal, no changes. */
137
+ function requireInteractiveTerminal() {
138
+ const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)
139
+ if (!interactive) {
140
+ console.error('✖ 拒绝执行:安全机制只能由「交互式终端中的人工确认」修改。')
141
+ console.error(' 当前 stdin/stdout 不是终端(脚本、自动化或 AI 调用的典型情形),因此无法取得同意令牌。')
142
+ console.error(' 请在桌面端插件页安全中心,或在你自己的终端里手工运行本命令。')
143
+ process.exit(3)
144
+ }
145
+ }
146
+
147
+ /** Human-readable rendering of one stored value. */
148
+ function showValue(value) {
149
+ if (value === undefined) return '(未显式配置)'
150
+ if (Array.isArray(value)) return value.length === 0 ? '(空)' : value.join(', ')
151
+ return String(value)
152
+ }
153
+
154
+ function parseValue(id, raw) {
155
+ if (raw === undefined || raw === 'default') return undefined
156
+ if (BOOLEANS.has(id)) {
157
+ if (raw !== 'true' && raw !== 'false') throw new Error(`${id} 只接受 true / false`)
158
+ return raw === 'true'
159
+ }
160
+ if (id === 'minimumReleaseAge') {
161
+ const number = Number(raw)
162
+ if (!Number.isInteger(number) || number < 0) throw new Error('minimumReleaseAge 需要非负整数(分钟)')
163
+ return number
164
+ }
165
+ if (id === 'trustPolicy') {
166
+ if (raw !== 'off' && raw !== 'no-downgrade') throw new Error("trustPolicy 只接受 off / no-downgrade")
167
+ return raw
168
+ }
169
+ if (LISTS.has(id)) {
170
+ return raw.split(/[,\n]/).map((part) => part.trim()).filter((part) => part !== '')
171
+ }
172
+ throw new Error(`未知机制:${id}`)
173
+ }
174
+
175
+ async function main() {
176
+ const [, , command, id, ...rest] = process.argv
177
+ const dir = profileDir()
178
+ const file = join(dir, 'pnpm-workspace.yaml')
179
+
180
+ if (command === 'describe' || command === undefined) {
181
+ const text = await readFile(file, 'utf8').catch(() => '')
182
+ console.log(`profile: ${dir}`)
183
+ for (const mechanism of MECHANISM_IDS) {
184
+ const value = readYamlKey(text, mechanism)
185
+ console.log(` ${mechanism.padEnd(34)} ${showValue(value)}`)
186
+ }
187
+ console.log('\n提示:minimumReleaseAge 未显式配置时,pnpm 11 内置默认 1440 分钟(非严格)。')
188
+ return
189
+ }
190
+
191
+ if (command !== 'set') {
192
+ console.error(`未知命令:${command}(可用:describe | set)`)
193
+ process.exit(2)
194
+ }
195
+ if (!MECHANISM_IDS.includes(id)) {
196
+ console.error(`未知机制:${id}\n可用:${MECHANISM_IDS.join(', ')}`)
197
+ process.exit(2)
198
+ }
199
+
200
+ const raw = rest.join(' ').trim()
201
+ let value
202
+ try {
203
+ value = parseValue(id, raw === '' ? (LISTS.has(id) ? '' : undefined) : raw)
204
+ } catch (error) {
205
+ console.error(`✖ ${error.message}`)
206
+ process.exit(2)
207
+ }
208
+
209
+ // Gate 1: a real terminal must be present.
210
+ requireInteractiveTerminal()
211
+
212
+ // Gate 2: the human must read the consequence and confirm.
213
+ const text = await readFile(file, 'utf8').catch(() => '')
214
+ const before = readYamlKey(text, id)
215
+ console.log('──────────────────────────────────────────────')
216
+ console.log(`机制 : ${id}`)
217
+ console.log(`当前值 : ${before === undefined ? '(未显式配置)' : JSON.stringify(before)}`)
218
+ console.log(`将改为 : ${value === undefined ? '(恢复内置默认:删除该键)' : JSON.stringify(value)}`)
219
+ console.log(`风险 : ${CONSEQUENCE[id]}`)
220
+ console.log('──────────────────────────────────────────────')
221
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
222
+ const answer = await new Promise((resolve) => rl.question('确认修改请输入 y(其他任何输入都会放弃):', resolve))
223
+ rl.close()
224
+ if (answer.trim().toLowerCase() !== 'y') {
225
+ console.log('已放弃,未做任何修改。')
226
+ return
227
+ }
228
+
229
+ const next = writeYamlKey(text, id, value)
230
+ if (next === text) {
231
+ console.log('值未变化,无需写入。')
232
+ return
233
+ }
234
+ await writeFile(file, next, 'utf8')
235
+ console.log(`✔ 已写入 ${file}`)
236
+ console.log(' 重新安装插件后生效;如需回滚,用 default 恢复内置默认。')
237
+ }
238
+
239
+ await main()