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 +69 -0
- package/cordis.patch.yml +6 -0
- package/dsh-security-center-1.0.0.tgz +0 -0
- package/icon.svg +4 -0
- package/index.js +371 -0
- package/package.json +39 -0
- package/tools/security-center.mjs +239 -0
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.
|
package/cordis.patch.yml
ADDED
|
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()
|