cyrene-guard
Verified@lutisure/cyrene-guard · v0.1.1 · MIT
写完代码之后自动扫一遍——本地安全扫描,规则来自 DeepSec 的 Shield。
Install
dsh plugin add @lutisure/cyrene-guard Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
cyrene-guard · 写完代码之后自动扫一遍
一个 DeepSeek Harness 插件:挂在文件写入事件上,把刚写下的文件读回来,用一套本地正则规则扫一遍, 把发现当作提示词里的一块交给模型自己看。 除此之外它什么也不做。
规则来自 Unclecheng-li/DeepSec 的 Shield(MIT)。
⚠️ 先说清楚:它不是安全闸门
它拦不住任何东西。
宿主 dsh-tool-fs 的写入路径是:
const intent = await ctx.waterfall("fs/write-intent", target, exec, …);
outcome = await ctx.fs.writeText(target, input.content, intent, …);
ctx.emit("fs/observed", target, { kind: "present", version: outcome.version }, exec);
本插件挂在 fs/observed 上 —— 那是写完之后才触发的事件。文件已经落盘了,
它才把内容读回来扫。命中什么都不会阻止写入,也不会回滚、不会改文件、不会弹确认框。
上游那个看着像能拦的钩子也不能用:
fs/write-intent是 waterfall,但它只拿到(target, exec),拿不到要写的内容。想「写之前检查内容」这条路根本走不通。出处:
index.js:8-19。
真正的边界是权限范围和人工确认,不是这个包。它能让一个错误在同一轮对话里被看见、 被改掉 —— 这就是它的全部价值,也是它唯一声称的东西。
一个叫 guard 的插件最容易被误会成闸门,所以这一节放在最前面。
同样的说明也写在 index.js 文件头(index.js:27-30)和 cordis.patch.yml 末尾
(cordis.patch.yml:22-26)—— 三处都说,是因为它值得说三遍。
目录结构
cyrene-guard/
index.js 宿主半体:挂在 fs/observed 上,读回文件,记进 ledger,注册提示词 section
scanner.mjs 规则引擎:38 条规则 + 注释/字符串遮罩 + 严重度排序
packages.mjs 两张包名种子表(KNOWN 103 条 / HALLUCINATED 25 条,5 个 registry)
⚠️ 由 tools/gen-packages.mjs 生成,不要手改
packages-scan.mjs 从源码或依赖清单里抓出包名;seedLookup 离线判一次
cordis.patch.yml bundle 补丁:一个 insert(id/name),让 `dsh plugin add` 能挂上
dsh.plugin.json 插件元数据,声明 contributes.hooks = ["fs/observed"]
test/
scanner.test.mjs 19 项:规则命中、遮罩语义、语言门控、假阳性防线
packages.test.mjs 11 项:包名抽取、三态判定、Go module 转义
skip-report.test.mjs 5 项:「扫过没问题」和「根本没扫」在报告上必须分得开
tools/
gen-packages.mjs 从抽取出的规则文件生成 packages.mjs
preflight.mjs 把 apply() 在一个假宿主上真跑一遍(防「挂载时抛 → DSH 起不来」)
它做什么:三件事
1. 挂在 fs/observed 上,写完就看一眼
每一次写入 / 编辑之后触发一次。读回来、扫一遍、记进 ledger。
2. 把发现渲染成提示词里的一块
用 ctx.systemPrompt.section({ name: 'cyrene-guard/scan', order: 9500, text: () => ledger.render() })
注册 —— 同名即替换,永远只有一块。
order: 9500 落在工具说明之后、STRUCTURED_OUTPUT(9900)之前:报的是她刚做的事,
放在她准备继续做事的位置。(顺序必须自己给数:getSectionOrder(name) 只认内核注册过的名字,
而 section() 见到非有限数会直接抛 —— index.js:224-227。)
块长这样:
【安全扫描 —— 你刚写下的文件里有这些,请自己看一眼再往下走】
src/app.js
· 第 12 行「AKIA…CDEF」— 代码里有一个 AWS 访问密钥。
把密钥移到环境变量或凭据文件,并立刻轮换这一个 —— 它已经进过上下文了。
这是本地正则扫描,**不是安全边界**:它按公开样本里的写法匹配,换个说法就漏,
也分不出这段代码到底会不会被跑到。判断权在你。
没有发现时它返回空字符串,提示词里不会多出任何东西 —— 不每轮塞一段「一切正常」的废话
(index.js:118-119,tools/preflight.mjs:77 有断言)。
密钥本体不进提示词:命中 secret 类规则的证据会被截成首尾各 4 位(index.js:434-436)。
3. 读不回来不算错误
文件可能已经被删了,或者这个后端不支持读。扫描器出问题绝不该影响正常的写文件流程,
所以整个读回 + 扫描包在 try/catch 里,失败就安静返回(index.js:206-209)。
它扫什么
后缀白名单(index.js:55-61):
js mjs cjs ts tsx jsx py java kt kts go rb php cs rs
yml yaml json toml env sh ps1 txt
.txt 在里面是专门为 requirements.txt 留的——幻觉包名就落在那里,不白名单它就永远进不了清单检查。
依赖清单另外识别(index.js:67):
package.json package-lock.json requirements*.txt pyproject.toml
Cargo.toml go.mod pom.xml
它不扫什么
| 不扫 | 为什么 |
|---|---|
.md |
结构说明和文档里的示例代码会被规则误伤,而那不是需要修的东西。DeepSec 的扫描后缀表里也没有 .md,同样的理由(index.js:52-54) |
node_modules/ .git/ dist/ build/ out/ coverage/ .venv/ venv/ __pycache__/ |
它们不是这个人写的,报出来也没法改(index.js:77-79) |
| 超过 512 KB 的文件 | 源码不会到这个量级(index.js:42-43) |
| 后缀不在白名单里的文件 | —— |
| 路径为空的目标 | —— |
而且它不联网。 联网查询刻意不放在写文件这条路径上:写文件是高频动作,
不该顺带发起网络请求(index.js:179-180)。包名判定走的是离线种子表
(见下),未命中的一律是「不知道」。
规则从哪来
Unclecheng-li/DeepSec 的 Shield(MIT)。原版是 CI 里的扫描器:action.yml 22 KB、
要 npm ci && npm run build、要 GitHub token、报告打到 PR 评论和 SARIF 里。
这里只要一件事 —— 她写完代码之后,同一轮就知道自己写了什么。
所以只取规则本身,不取那套装置(scanner.mjs:8-12)。
没有取的部分:Spear(攻击侧)全部、CI 装置、SaaS 集成、Web 面板(NOTICE.md)。
三处刻意偏离上游,三处都是修
上游的规则库里有两个会导致误报的问题。照抄会把误报一起抄进来,所以这里改了:
exec(/eval(用 Python 侧的模式。(?<![.\w])而不是\b—— 后者在obj.eval(的点号后面也成立。 而且真的加了语言门控:eval(只报 JavaScript,exec(只报 Python。 上游的 TS 侧用的是\b且没有门控,它自己的源码注释把这一版叫做 "the single largest source of false positives in the whole ruleset"。 出处:scanner.mjs:243-252,NOTICE.md。声明了却没执行的语言门控,现在真的生效。 上游的
aiPatterns.ts在 24 条规则上填了languages字段,而detectAiPatterns从来没读过它。这里scanText会检查rule.langs(scanner.mjs:397)。种子表从抽取出的规则生成,不是手抄。 128 个包名手抄一遍,错一个字符就得到一条永远查不到的规则。 生成它的同一次抽取里,子代理在自检时抓出自己 4 处转写错误 —— 这正好证明了手抄不可靠。 生成器是
tools/gen-packages.mjs(packages.mjs:1-3,tools/gen-packages.mjs:4-7)。
假阳性是怎么压下去的
这些规则能用的前提是它们不乱报,而误报的规则会被关掉 —— 关掉之后它一条都拦不住。 四层防线:
| 层 | 做法 | 出处 |
|---|---|---|
| 语言门控 | eval( 只报 JS,exec( / pickle.loads / yaml.load 只报 Python,os.system 只报 Python |
scanner.mjs:40-43 |
| 注释与字符串遮罩 | 返回等长字符串,注释和字符串字面量的位置上是空格。两种模式:SAST 规则连字符串一起遮(字符串不是代码),密钥规则只遮注释(值就住在字符串里)。一个遮罩服务不了两个问题 | scanner.mjs:45-123 |
| 负向排除 | dangerouslySetInnerHTML 后面跟了 DOMPurify / sanitizeHtml 就不算;process.env 取值不算硬编码密钥;yaml.load 带了 SafeLoader 就不报 |
scanner.mjs:305, 138, 345 |
| 占位符过滤 | 值短于 6 字符、或含 changeme / example / your / placeholder / ${ 等词的,不算密钥。枚举这些词是必要的:your-password-here 又长又没有重复串,熵检测抓不到它 |
scanner.mjs:126-149 |
高熵判定的阈值逐字抄自 DeepSec 的 secrets.ts:有上下文时长度 ≥16 且熵 ≥3.8;
无上下文 ≥24 且熵 ≥4.5;并且无论哪种都必须同时含字母和数字、不同字符占比 ≥0.45、
最长重复串 <8(后面两条挡掉 aaaaaaaa… 这类假高熵)。UUID 和纯 hex 校验和被排除。
38 条规则
| 组 | 条数 | 例子 |
|---|---|---|
| 硬编码密钥 | 7 | AWS 访问密钥、GitHub 令牌、Slack 令牌、OpenAI / Anthropic 密钥、npm 令牌、私钥块 |
| 变量名 + 字面量 | 1 | 叫 api_key / password / token 的变量被赋了字面量(走原文,并查占位符与环境变量引用) |
| 配置 | 5 | DEBUG=true、CORS 通配来源、eval / exec、pickle.loads、不带安全 Loader 的 yaml.load |
| AI 错误模式 | 14 | 默认口令、写死的 JWT 密钥、前端前缀的密钥变量、algorithm: none、关掉 TLS 校验、Math.random() 生成令牌、shell: true、f-string 拼 SQL、dangerouslySetInnerHTML、公开读写 ACL、MD5/SHA1 哈希口令…… |
| SAST | 7 | os.system、shell=True、路径穿越、SSRF、SQL 字符串拼接、不安全反序列化、异常细节外泄 |
| Agent 自身 | 3 | 提示词注入句式、模型能影响的输入直达命令执行、带凭据的外发请求 |
规则表在 scanner.mjs:196-366。每条 pattern 逐字来自 DeepSec,
langs 是语言门控(null = 不限),skip 是额外的负向排除。
发现按严重度(critical → high → medium → low)再按行号排序(scanner.mjs:449)。
幻觉包检查:「不知道」不等于「不存在」
这是 DeepSec 里最有价值、也最难替代的一件:向量嵌入做不到,正则也做不到 —— 它需要一张「真实存在」的名单加上一个存在性查询。
AI 写的代码特别容易编出看起来合理的包名。这个名字一旦落到磁盘上,就是一个现成的抢注目标:
攻击者注册它、放上恶意代码,下次安装就跑起来了。DeepSec 管这个叫 slopsquatting
(packages.mjs:8-16)。
判定是三个值,不是一个
'exists' 查到了
'missing' 确定不存在 ← 只有这个会变成发现
'unknown' 没查到 / 没查
把 unknown 当成 missing,规则就变成一台误报机器。
一次断网、一个新包,就能让整份依赖清单报成幻觉包 —— 而误报的规则会被关掉,
关掉之后它一条都拦不住(index.js:174-180)。
所以种子表只记明确知道的两头:KNOWN(103 条,确定存在)和
HALLUCINATED(25 条,确定是编出来的,附最接近的真实替代)。表外的名字一律 unknown
(packages-scan.mjs:184-193)。
联网那一半写了,但没接在写文件路径上
verifyPackage() 实现了完整的三态查询(只有 404 才算不存在,429 / 5xx / 超时 / DNS 失败
都返回 unknown 并且不写缓存 —— 把瞬时网络失败写进缓存会让某个包在恢复连接后仍被错误地记成
不存在,packages-scan.mjs:116-173)。但它不在 fs/observed 这条路上被调用:
写文件是高频动作,不该顺带发起网络请求。
所以实际运行的判定是纯离线的:只有种子表里 25 条明确记着的名字会报出来。 这是有意的取舍,不是遗漏。
覆盖 5 个 registry
| registry | 已知 | 幻觉 | 查询地址(联网那一半用) |
|---|---|---|---|
| npm | 31 | 5 | registry.npmjs.org/{name} |
| pypi | 27 | 5 | pypi.org/pypi/{name}/json |
| cargo | 16 | 5 | crates.io/api/v1/crates/{name} |
| gomod | 14 | 5 | proxy.golang.org/{name}/@latest |
| maven | 15 | 5 | search.maven.org/solrsearch/select… |
合计 103 + 25 = 128 条。
Go module 路径按规范做大写转义(大写字母写成 ! 加小写,! 本身写成 !!)。
漏了这条,凡是名字里带大写的 module 都会查不到 —— 而查不到会被当成「不存在」,
于是报出一个假的幻觉包(packages.mjs:324-342)。
接入即接受的行为边界
这一节是「你把它装进来,就等于接受了下面这些」。逐条都能在上面的代码里核到。
- 它不阻止任何操作。 不拦写入、不回滚、不改文件、不弹确认。 它只往提示词里加一块文字。
- 判断权在模型和用户。 报告最后一行逐字写着「判断权在你」。 插件不仲裁、不自动修、不标为「必须处理」。
- 它读你写下的文件内容。 每次
fs/observed之后,命中的路径会被读回来 (≤512 KB)。内容不落盘、不外发。 - 提示词里的密钥是被脱敏的。
secret类规则的证据截成首尾各 4 位, 完整密钥不进上下文(index.js:434-436)。但命中的事实会进上下文。 - 它不联网。 运行路径上一次网络请求都没有。
- 它不覆盖任何权限机制。 权限范围与人工确认仍然是唯一的真边界(见开头)。
- 扫描器出错不影响你的写入。 读不回来、解析失败、规则抛错 —— 一律吞掉,只留一行 stderr。
- 它不知道代码会不会被跑到。 正则匹配的是写法,不是可达性。一句写着
eval(的死代码和一句真的会被执行的eval(长得一模一样。
Known Limitations
直说做不到的事。
- 事后检,不是事前拦。 文件已经落盘。要拦住它需要另一个钩子,而那个钩子拿不到写入内容。
- 换个说法就漏。 它按公开样本里的写法做正则匹配,挡不住改写过的写法, 更挡不住一句通顺的普通话。上游在同类分类器上写着的那句话同样适用于这里: "without an OS sandbox this classifier is the router that decides what runs unattended."
- 分不出可达性。 见上。死代码、注释里的示例(已被遮罩挡掉大部分)、
测试夹具都可能是命中的来源 —— 这个仓库自己的
tools/preflight.mjs每次跑都会 因为里面故意写着测试用的假密钥而被报 3 条,这是预期行为。 - 依赖检查不联网。 只有种子表里的 25 个名字会报。表外的包一律
unknown, 哪怕是刚被抢注的。verifyPackage()写好了但没接在写文件路径上。 - 不扫 Markdown、不扫依赖目录、不扫构建产物。 见「它不扫什么」。
- 一次只展开 12 条发现,一次会话只留 40 个文件。 超出的部分会以 「(另外还有 N 条没有列出来。)」收尾,但不展开。
- 规则覆盖率的上限就是那 38 条。 没有语义分析、没有数据流分析、没有依赖版本漏洞库。
- 不判断严重度以外的东西。 它不知道你这段代码是本地脚本还是对外服务。
- 上限之外,报告里会写出来。 文件超 512 KB、读回来不是文本、读的时候抛了 ——
这三种都会进报告里的一段「【有文件没能扫到 —— 不代表它们是干净的】」,
带上路径和原因。「扫过没发现问题」和「根本没扫」在报告上必须是两回事。
(2026-10-11 改的。原来这条写的是「都是安静跳过,不会有记录」——
那是改动前的实情,也确实是个缺口。)
仍然不报的只有一种:按设计跳过的。 白名单外的路径(
node_modules、构建产物) 属于刻意不看,每轮报一遍会把真正该看的挤掉。
安装
# 加进一个 profile(本机 DSH 桌面版)
$env:ELECTRON_RUN_AS_NODE="1"
& "F:\新建文件夹\DeepSeek Harness.exe" `
"F:\新建文件夹\resources\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js" `
plugin --profile desktop add "link:F:\DSH_Plugins\cyrene-guard"
或者用裸包名 ... plugin --profile desktop add cyrene-guard(发到 registry 之后)。
⚠️ 装完要重启 DSH。 插件源码是启动时读的 —— loader 用裸
import(),没有缓存击穿。宿主半体(index.js)的改动不重启不生效。 提示词那一块(section)每次装配时求值,但它得先被挂上,而挂载发生在启动。
它挂在预设外面。 见 index.js:21-25:记忆那几个插件挂在 preset-cyrene 里面,
因为它们的提示词要跟着人设走;这个插件的目的正相反 ——
不管挂哪个预设、开哪个会话,它都该在。
卸载
$env:ELECTRON_RUN_AS_NODE="1"
& "F:\新建文件夹\DeepSeek Harness.exe" `
"F:\新建文件夹\resources\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js" `
plugin --profile desktop remove cyrene-guard
卸载后重启一次 DSH。它不写任何持久状态 —— ledger 只在内存里, 40 条记录随进程消失,没有文件、没有目录、没有缓存要清。
默认值表
| 项 | 默认值 | 出处 |
|---|---|---|
| 装完是否生效 | 是,无需配置 | 没有配置项 |
| 挂在哪 | fs/observed(宿主侧,预设之外) |
index.js:213 |
| 注入的宿主服务 | ['fs', 'systemPrompt'] |
index.js:40 |
| 提示词块名 / 位置 | cyrene-guard/scan,order: 9500 |
index.js:229-231 |
| 没有发现时 | 贡献空字符串 | index.js:119 |
| 单文件扫描上限 | 512 KB(超出不读) | index.js:43 |
| 一次会话保留的扫描记录 | 40 个路径(超了丢最旧的;同路径写一次覆盖一次) | index.js:46, 102-105 |
| 报告里最多列几条发现 | 12 条 | index.js:49 |
| 扫描后缀 | 23 个(见上,含 .txt) |
index.js:55-61 |
| 识别的依赖清单 | 7 种文件名模式 | index.js:67 |
| 规则条数 | 38 条 | scanner.mjs:196-366 |
| 种子表条数 | 128 条(103 已知 + 25 幻觉)× 5 registry | packages.mjs |
| 是否联网 | 否 | index.js:179-180 |
| 数据落在哪 | 哪也不落 —— ledger 在内存,无持久化文件 | index.js:90-106 |
| 读不回来时 | 安静返回,不影响写入流程 | index.js:206-209 |
开发
cd cyrene-guard
node --test # 35 项(scanner 19 + packages 11 + skip-report 5)
node tools/preflight.mjs # 在假宿主上真跑一遍 apply()
tools/preflight.mjs 存在的理由:光看配置文件能解析是不够的 ——
崩的那次也可能是插件在挂载时抛,而 DSH 的启动路径是「读 profile → 挂载每个插件」,
任一插件抛就是整个进程起不来。假宿主照着 dsh-fs 和 dsh-system-prompt 的真实用法搭,
连「section() 见到非有限 order 会抛」这一点都复刻了 ——
一个只在宿主宽容时才工作的插件,不是一个能用的插件(tools/preflight.mjs:1-12, 49-53)。
packages.mjs 是生成物。改种子表要改源文件后重跑 node tools/gen-packages.mjs,
不要直接改它。
许可
本插件的引擎与适配部分是自己的代码,MIT(见 LICENSE)。
检测规则来自 Unclecheng-li/DeepSec 的 Shield(MIT)。
从 TypeScript 移植到 JavaScript,并修了原版两处会导致误报的问题(exec( 的语言门控、
以及声明了但没执行的 languages 字段)。完整署名见 NOTICE.md,
仓库根目录的 NOTICE.zh.md 里有更完整的三方说明。