v0.8.0 — audit remediation: one install identity, a self-verifying harvest, bounded artifacts
LatestEnglish — 中文公告见本页下半部分
The remediation release for the 2026-09-03 defect audit. Eight independent passes went
over the tree at 49db942 — the 0.7.4 release commit — and produced 86 findings
across five subsystems. v0.8.0 closes them, as five independently shippable plans. Both
halves of the repository moved: the plugin the harness loads, and the pipeline that builds
the catalog it reads.
| npm | dsh-plugin-shop@0.8.0 → latest |
| Previous | 0.7.4 (2026-09-03) |
| Proven on | beta tag, 0.8.0-beta.0 … 0.8.0-beta.4 |
| Tag | v0.8.0 @ 0b92017 |
| Catalog | dsh-plugin-shop-catalog + Pages /v1/ — rebuilt daily, so its half has been live since it landed |
| Audit | 86 findings, 5 plans |
Upgrade
# dsh on PATH
dsh plugin --profile web add dsh-plugin-shop@0.8.0
# or straight through npx, nothing installed
npx -y @deepseek-ai/dsh plugin --profile web add dsh-plugin-shop@0.8.0Pin the version — pnpm 11 holds back very recent releases, so a bare
add dsh-plugin-shop can hand you an older build for a while. Restart dsh once
afterwards: a newly added bundle is not applied to a running process.
Upgrade if you install plugins whose bundle name is not unique (177 names in the live
catalog are claimed by more than one entry), you are on Windows, or you have ever read an
install notice and not believed it.
Breaking changes: none for users. The installStart RPC and the done reducer state
each gained a field; both are internal to the plugin and move together with it.
Highlights
- Two same-named plugins no longer overwrite each other. The second install used to
replace the first in the profile manifest, silently. - The install notices report evidence, not inference. The confirm diffs the profile
manifest across the spawn; the log survives success; a failure names the cause. - Windows installs monorepo subpackages instead of silently landing the repository
root — and catalog fields no longer reachcmd.exeunvalidated. - The harvest can prove it enumerated everything, 156 names before the npm search
window would have broken every daily build. - Nothing unbounded reaches a published artifact. One hostile package could produce a
203 MBplugins.jsonfor every reader.
The shop — dsh-plugin-shop@0.8.0
Added
- One install identity,
(source, name, repo, subdir), insrc/shared/identity.ts—
used byvalidateInstall,install(),installed(), the incompatibility map, the pins
file and every client lookup map. name-takenrefusal: an install that would replace a different plugin holding the
same bundle name is refused, naming the repository that holds it. Updating the same
plugin is unaffected. (#19)- Name-conflict badge on the card, before the click, with the holder named — and a
card with two problems at once states both instead of hiding one. (#19) - Post-install loader-collision check: two differently-named bundles may declare one
loader entry id, which makes dsh refuse the whole tree at next boot. The failure names
the id, the package holding it, and the command that undoes it. (#19) - A load-time peer-range warning, once, across the five packages the shop declares
(three harness packages at^0.1.1-rc.2,@deepseek-ai/cordis^4.0.1,
@deepseek-ai/cordis-plugin-include^1.0.6). Prereleases are included on purpose —
the harness ships nothing but-rc. - The install log outlives success:
donecarries its log likefailed, and the panel
renders it.
Changed
- The post-install confirm reads the profile manifest before and after the spawn and
reports only the difference, instead of inferring a cause from a predicate that does not
entail it. (#18, #20) installedNoRestartNoticefires only when the hot mount succeeded.- A failure detail appends the last link of pnpm's causal chain, not just its
ERR_
code. - Catalog
name,version,repoandtarball.urlare validated against a boundary
grammar; an unsafe operand is refused rather than quoted around. - The Incompatible badge sits beside the button it qualifies and carries an accessible
name. - The shelf sorts once per load, through one shared collator.
Fixed
installed()returned two rows for one plugin, one markedoutdated, whose Update
button spawned another repository's commit — gated on that entry's tier.- Re-installing your own plugin failed with a false published detail:
version-mismatch: dsh-foo@bbbb… is not the cataloged version (aaaa…). - A second same-named install silently overwrote the first in the profile manifest.
- A GitHub pin written as a release tag was dropped on every read, so the 162
release-rescued entries could never reportoutdated. - Windows: monorepo-subpackage entries installed the repository root. dsh spawns pnpm
throughcmd.exe, where the&ingithub:owner/repo#sha&path:subdiris a command
separator. Verified end to end on Windows 11 / dsh 0.1.2-rc.1 / pnpm 11.25.0. Upstream
cause reported asdeepseek-ai/deepseek-harnessdiscussion #5815. - Windows: the live-harness e2e could not run at all. Its gate used
spawnSync('dsh')
and npm installs the CLI asdsh.cmdwith no.exe, so the only test that walks the
real shop UI skipped itself on the one platform CI cannot see. 5/5 there now. (#24) - A restricted export counted as a missing module in peer checks.
- An unreadable or half-written profile manifest turned
denied,not-in-catalogand
version-mismatchinto "please retry";undefinednow means "cannot say", never
"nothing is installed". - The boot catalog stash never expired; mutation outcomes were lost through an installed
refresh; the user layer's!!jsspelling was rewritten away.
Security & hardening
- A spawned install is bounded and its process tree is killed. It had no deadline, no
kill, and completion waited on inherited pipes — one stalledpnpmblocked the profile's
whole queue until dsh restarted. - npm tarballs are capped on the wire and at inflate. A 260 KB gzip bomb from a raced
mirror became 268 MB in memory; 32 MB of accepted gzip can demand ~33 GB. - Catalog reads are capped and wrapped as transport failures; bulk reads are budgeted and
abortable; concurrent loads share one request. - A restart is refused while an install is running; a failed helper startup is reported.
- The self-update check is bounded and asks the user's registry first.
- Every name-keyed read is an own-property read — a plugin named
constructoris not
reported as holding a name, and a star entry named__proto__cannot poison a lookup. - Log lines survive stream-chunk boundaries; hot-mount rows are serialised as YAML and
their patch paths confined.
The catalog pipeline
Added
release-asset.ts— the release-asset rescue channel moved out of
github-client.tsand gained the checks it lacked: the asset root is anchored, the
inflate is bounded at every call site, a rescued entry must be the package it claims,
and the check applies retroactively to the 162 entries already rescued. (#21, #22)identity.ts— one module owning(source, name, repo, subdir)in the four shapes
the pipeline needs.http-body.ts— the one place a body cap is enforced. A guard test scans every
module that reads a body and fails on one that bypasses it; a sibling guard scans for an
unboundedgunzipSync/inflateSync.pages-artifacts.ts— the publishable set as policy in the pure core.- Recorded market verdicts in
registry/markets.yml, a build input like
verified.yml; the build report names everyby: llmwithholding for spot-checking. - A report of registry rows that matched no harvested candidate, so a typo in a review
file is visible instead of silent. - 5,924 backfilled GitHub first-seen dates, recovered from the 21 committed snapshots
spanning 2026-08-18 → 2026-09-04.
Changed
- A review pins one exact artifact. A GitHub review binds
(repo, commit), an npm
review the reviewed version and no other, a release-rescued entryrepo+sha256. All
three compare by equality. - Entries, lock lines and rejections sort by the whole identity, so output cannot
depend on the order the harvest returned candidates in; shadowed subpackages are named
by theirowner/slug#subdirunit. addedis stamped from accepted entries, keyed by identity, so a package rejected
for weeks and then listed no longer carries its first-harvest date.- The name heuristic for competing markets is now a candidate selector that decides
nothing. It caught 73 live entries and 20 were innocent — 存茶指南 and 腌菜保存, an
A-share quant plugin whose "market" is the stock market, a session-log plugin whose
"store" is a verb. - The
by: humanmarket gate was reverted the same day it shipped.notAShopis the
CLEARED list, so requiring a signature advertised the 16 shop-like names the heuristic
had been hiding. A mechanism whose only exit is a human is a permanent no-op. The verdict
decides;byrecords who judged it.
(design §4) - Both build reports are published beside the catalog. A rejection's
detailis
written for the author of the package it names and had been reachable only as a zipped CI
artifact. Upstream discussion #5867 is the demonstration: an author searched the build
metadata for their own scope, found nothing, and concluded the harvest could not see
them — one of their six was harvested and rejected asno-repository. (#23) - The stars sidecar is keyed by the catalog it belongs to and serialised by a tested pure
function, so daily data still cannot churn the plugin data hash.
Fixed
- The harvest can prove it enumerated everything. npm search caps
fromat 5,000 and
silently returns page 0 beyond it;keywords:deepseek-harnessstood at 5,094 names when
the audit ran, 156 short of the point where every daily build would throw with the
wrong diagnosis. An over-window keyword is partitioned onkeywords:a,bintersections —
the only filtering qualifier the API honors, and it has no negation, so no covering
partition exists. The harvest is safe by check: it measures what it enumerated
against the answeredtotaland throws on a shortfall. - It stops paging on the answered
total, never on a short page; a response with no
totalthrows; the coverage probe refuses a partial answer and retries a timeout once. - The GitHub half pages on its answered total and splits each topic into stars/date/size
windows under the 1,000-result cap, throwing when a window still exceeds it. - A shortfall too small to be anything but registry noise is tolerated.
- One transport failure no longer aborts the build. A dead packument among ~5,600 is a
fetch-failedrow; a null packument projects to a rejection instead of aTypeError;
the per-repo isolation is bounded so the search path cannot end the harvest. no-manifestnow means the manifest was read, or a 404 answered for it. The old rule
wrote it with one detail string for 404, 403, 451 and 503 alike, and because a failed
repo is neither absent nor moved,diffRepoStatenever re-fetched it — so a blocked
raw.githubusercontent.comwrote "No package.json at the repository root" into the
durable record of every repository it could not reach. 1,929 records of 14,863 were
invalidated once for re-fetch under the corrected rule.- Rejection reasons a plugin author can act on:
repo-goneomitted its likeliest cause; a
probed monorepo root's no-bundle detail named the wrong one; a size-refused subpackage
went unreported; the workspace-deps rejection wrongly applied to release rescues; the
patch target, not thescriptsfield, is what "prebuilt" means; a shipped patch file
is not the modules it names; the loader-selected entry is resolved rather than the
first scanned; npm's un-deprecate spelling and the legacy license forms are read;
workspacesglobs are anchored at both ends. first-seen.ymlwas written every build and never committed, so about half the shelf
was re-stampedadded: todayand the "stable" content hash churned daily. It is
committed now, with thegit addline held by a test.- Eight ways trust leaked across identities, latent only because
verified.ymlis still
empty: a fork inheriting a review through a shared bundle name (83 live names are claimed
by both a fork and an original); alatestolder thanreviewedVersionrendering
verified; a repository reviewed by commit rejected as an impersonator of itself;
an npm publish of a verified GitHub entry's name displacing it and inheriting itsadded
date; a repo denial undone by publishing to npm from it; two verified packages within two
edits rejecting each other; an unvalidatedreviewedVersion; a name both reviewed and
denied. @dsheval/dsh-top100-plugin— a competing dsh plugin market — is withheld from the
shelf. The heuristic never caught it: the name splits to
['@dsheval/dsh', 'top100', 'plugin'], and the dsh-prefix rule needs the second
segment to be a store keyword exactly.- A publish can no longer ship a stale catalog. At 2026-09-03T16:46Z a run against a
dist/v1last built on 09-02 shipped it as2026.903.6; npm movedlatestonto it and
readers were served a catalog two days old and 818 entries short until the dist-tag
was moved back by hand. Content hashes could not tell them apart, and
nextCatalogVersionreads the wall clock.publish-catalog.tsnow refuses a build older
than the publishedlatest. - The npm token is kept off the backup mirror, an empty-string disable is honored, and when
a backup also fails the primary's own failure is reported.
Security & hardening
- One hostile package could put a 203 MB
plugins.jsonin front of every reader.
capabilitiescapped the count at 20 and never the item length;license,repository
and each of 200 peer names were taken verbatim; the GitHub manifest body had no cap at
all. Measured, not estimated, through the realtoCandidate → gate → assignTier → emit
path. Every free-text field is bounded now, in the pure layer, so an over-long value
becomes a rejection with an author-readable reason rather than a silently nulled
field. - The five identity fields and the whole per-entry payload are bounded; the second manifest
read is capped; the reason string itself is bounded. - What Pages serves is policy, not whatever landed in
dist/.upload-pages-artifact
was pointed atdist/, so/v1/harvest.json(4,037,180 bytes — every candidate
verbatim, rejected ones included, carrying unvalidateddsh.catalogvalues) and
/v1/report.md(1,722,904 bytes) both answered 200 on the live site. The build now
stagesdist/pages/from scratch and copies in exactly whatpagesArtifactNames
returns. - Bot-written names are JSON-escaped and a repository manifest name is validated at the
boundary — anamecontaining"or\used to break the YAML the bot writes, which
every later build then threw on at load. - Report table cells neutralise control characters and bidi formatting, and escape each
markdown cell exactly once. - Every GitHub, LLM and stars request has a deadline that covers the body; a gateway
timeout is retried; advisory steps are bounded in aggregate, not only per request; the
build job itself is bounded. Three deadlines that lied about their own wiring are now
tested against production. - A malformed
REPO_BACKFILL_BUDGETstops the build instead of silently harvesting
nothing.
CI and release
Changed
- The build job no longer holds a credential that can write to the repository. It
processes hostile input; it runs atcontents: read, and the pushes authenticate through
a fine-grained token scoped to this repository, held only by the steps that push. Every
secret is scoped to the step that consumes it. - Every action is pinned to a commit SHA, with Dependabot configured to move them and
the action pairs it would otherwise split grouped, so a bump cannot land half a pair.
(actions/checkout→ 7.0.1,actions/setup-node→ 7.0.0,pnpm/action-setup→ 6.0.10,
plus the pages-deploy and artifact-transfer groups.) - The catalog harvest serialises across refs rather than per ref; the catalog's own commits
no longer re-trigger the catalog. plugin.ymlruns the root suite, watches the root READMEs and its own file, and pins the
e2e harness to0.1.2-rc.1— the contract its selectors match.- The build job round-trips an artifact, where the publish job cannot.
Fixed
- Bot pushes fetch and rebase, retry once, and report a rejection. Both scheduled runs
on 2026-09-02 and 2026-09-03 had both pushes rejected and reported success. - A push is refused outright when the rebase's autostash did not reapply — the one failure
mode that can corrupt a registry file onmain.
Tests
The standard in this release is mutation, not coverage: a test earns its place by
failing under the one-line bug it claims to catch. Three of the audit's findings exist
because exactly that check was run and the suite stayed green.
- 823 registry tests across 29 files (22 files at 0.7.4); 25 package test files (21 at
0.7.4). - Seven new registry suites: identity, release-asset, pages-artifacts, repo-guards,
workflow, strip-types, cwd-independence. The workflow suite reads
.github/workflows/daily.ymlas data, so the next forgottengit addor missing
rebase fails the suite instead of the publish. - Two assertions that could not fail were replaced; three guards that survived their own
removal were made to discriminate; a dead throw was dropped; the determinism test gained
a repeated run and lost three partial cases that established nothing. - A skipped exit criterion now fails CI instead of passing quietly; each host test file
owns one temp root and removes it (index.test.tsalone had 41 creations against 2
removals). - Every registry entry point is importable without exiting its host, and under
--experimental-strip-types.
中文 — the English announcement is in the first half of this page
dsh-plugin-shop 0.8.0 更新公告
本版是 2026-09-03 那次缺陷审计的收尾版本。八条互不相干的审计线索走完了 49db942
(0.7.4 的发布提交),产出 86 条 finding,分布在五个子系统上;v0.8.0 以五份可独立发布的
计划把它们关掉。仓库的两半都动了:harness 加载的那个插件,以及构建它所读目录的那条流水线。
| npm | dsh-plugin-shop@0.8.0 → latest |
| 上一版 | 0.7.4(2026-09-03) |
| 验证渠道 | beta 标签,0.8.0-beta.0 … 0.8.0-beta.4 |
| Tag | v0.8.0 @ 0b92017 |
| 目录 | dsh-plugin-shop-catalog + Pages /v1/ —— 每日重建,这一半在落地时就已上线 |
| 审计 | 86 条 finding,5 份计划 |
升级
# dsh 已在 PATH 上
dsh plugin --profile web add dsh-plugin-shop@0.8.0
# 或者直接走 npx,什么都不装
npx -y @deepseek-ai/dsh plugin --profile web add dsh-plugin-shop@0.8.0要显式钉版本 —— pnpm 11 会压住刚发布的版本,光写 add dsh-plugin-shop 有一段时间里拿到的
是旧构建。装完重启一次 dsh:新加的 bundle 不会应用到已经在跑的进程上。
建议升级的情形:你会安装 bundle 名字不唯一的插件(线上目录里有 177 个名字被不止一个条目
占用)、你在 Windows 上、或者你曾经读到过一条安装提示但不敢相信它。
破坏性变更:对使用者没有。 installStart 这个 RPC 与 done 的 reducer 状态各多了一个
字段,两者都是插件内部的形状,随插件一起动。
亮点
- 同名插件不再互相覆盖。 以前装第二个会在 profile manifest 里静默替换掉第一个。
- 安装提示报告证据,不再靠推断。 确认读取 spawn 前后的 manifest 之差;日志在成功后留存;
失败会说出原因。 - Windows 能装 monorepo 子包,不再静默把仓库根目录装进去 —— 而且目录里的字段不再未经
校验就走到cmd.exe。 - harvest 能证明自己枚举完了,距离 npm 搜索窗口压垮每日构建只剩 156 个名字。
- 没有无界的东西能走到已发布的产物里。 一个恶意包就能给每个读者产出 203 MB 的
plugins.json。
商店 —— dsh-plugin-shop@0.8.0
新增
- 统一的安装身份
(source, name, repo, subdir),定义在src/shared/identity.ts,
由validateInstall、install()、installed()、不兼容表、pin 文件与 client 的每一张
查表共用。 name-taken拒绝:当一次安装会替换掉另一个占着同名 bundle 的插件时直接拒绝,
并说出是哪个仓库占着这个名字。更新同一个插件不受影响。(#19)- 卡片上的重名徽标,在点击之前就给出,并点名占用者 —— 同时有两个问题的卡片会把两个都
说出来,而不是让一个盖住另一个。(#19) - 装后的 loader 冲突检查:两个不同名的 bundle 可能声明同一个 loader entry id,
那会让 dsh 在下次启动时拒绝加载整棵树。失败信息里给出 id、占用它的包,以及撤销用的命令。(#19) - 加载时的 peer 范围告警,只告一次,覆盖商店声明的五个包(三个 harness 包
^0.1.1-rc.2、@deepseek-ai/cordis^4.0.1、@deepseek-ai/cordis-plugin-include
^1.0.6)。刻意把预发布纳入比较 —— harness 只发-rc。 - 安装日志在成功之后仍然留着:
done像failed一样带着自己的日志,面板会渲染它。
变更
- 安装后的确认读取 spawn 前后的 profile manifest,只报告两者之差,不再从一个推不出结论
的前提里推断原因。(#18, #20) installedNoRestartNotice只在热挂载成功时才出现。- 失败详情会追加 pnpm 因果链的最后一环,不再只有
ERR_错误码。 - 目录里的
name、version、repo、tarball.url按边界语法校验;不安全的操作数是被拒绝,
而不是加个引号了事。 - Incompatible 徽标紧挨着它所限定的按钮,并带上无障碍名称。
- 货架每次加载只排序一次,共用同一个 collator。
修复
installed()会为一个插件返回两行,其中一行标成outdated,而那一行的"更新"按钮拉起的
是另一个仓库的 commit —— 而且按那个条目的 tier 放行。- 重装自己的插件会失败,报出一条已发布且为假的 detail:
version-mismatch: dsh-foo@bbbb… is not the cataloged version (aaaa…)。 - 装第二个同名插件会在 profile manifest 里静默覆盖第一个。
- 以 release tag 形式写入的 GitHub pin 每次读取都被丢弃,那 162 个靠 release 资产救回来的
条目因此永远报不出outdated。 - Windows:monorepo 子包条目装成了仓库根目录。 dsh 通过
cmd.exe拉起 pnpm,而
github:owner/repo#sha&path:subdir里的&在 cmd 里是命令分隔符。已在
Windows 11 / dsh 0.1.2-rc.1 / pnpm 11.25.0 上端到端验证。上游成因已报到
deepseek-ai/deepseek-harnessdiscussion #5815。 - Windows:真机 e2e 根本跑不起来。 它的前置判断用了
spawnSync('dsh'),而 npm 把 CLI 装成
dsh.cmd且没有.exe,于是这个唯一走真实商店 UI 的测试,在 CI 唯一看不到的平台上自我跳过。
现在那里 5/5 通过。(#24) - 受限(restricted)导出在 peer 检查里被算成缺模块。
- 一个读不出来或写了一半的 profile manifest,把
denied、not-in-catalog、
version-mismatch变成了"请重试";现在undefined表示"说不了",绝不表示"什么都没装"。 - 启动时的目录缓存从不过期;mutation 的结果会在刷新已安装状态时丢失;重写用户层时把它原本的
!!js写法改掉了。
安全与加固
- 拉起的安装有时限,并且会杀掉整棵进程树。 以前它既没有 deadline 也没有 kill,完成判定还要
等继承来的管道 —— 一个卡住的pnpm会堵住这个 profile 的整条队列,直到 dsh 重启。 - npm tarball 在传输层和解压时都设了上限。 一个从被抢到的镜像拿到的 260 KB gzip 炸弹在内存
里变成 268 MB;而 32 MB 已被接收的 gzip 可以要求约 33 GB。 - 目录读取设上限并包装成传输失败;批量读取有预算且可中止;并发加载共用同一个请求。
- 安装还在进行中时拒绝重启;helper 启动失败会被报出来。
- 自更新检查有时限,并优先问用户自己的 registry。
- 所有按名字索引的读取都是 own-property 读取 —— 叫
constructor的插件不会被报成占着某个名字,
叫__proto__的 star 条目也污染不了查表。 - 日志行跨流分片仍然完整;热挂载的行按 YAML 序列化,其 patch 路径被约束。
目录流水线
新增
release-asset.ts—— release 资产救援路径从github-client.ts搬出来,并补上它一直
缺的检查:资产根目录被锚定、每一处解压调用都有上限、救回来的条目必须是它自称的那个包,
而且这项检查追溯作用于已经救回来的 162 个条目。(#21, #22)identity.ts—— 由一个模块拥有(source, name, repo, subdir)在流水线里需要的四种形态。http-body.ts—— 唯一执行 body 上限的地方。一个守卫测试会扫过每个读 body 的模块,
绕过它就失败;同处的姊妹守卫会扫无界的gunzipSync/inflateSync。pages-artifacts.ts—— 把"可发布集合"变成纯核心里的策略。- 记录在案的市场判定,落在
registry/markets.yml,一个和verified.yml一样的构建输入;
构建报告会点出每一条by: llm的撤下以便抽查。 - 没匹配上任何 harvest 候选者的 registry 行会被报出来,于是评审文件里的拼写错误是可见的。
- 回填 5,924 个 GitHub first-seen 日期,取自横跨 2026-08-18 → 2026-09-04 的 21 份已提交快照。
变更
- 一条评审钉住一件确定的产物。 GitHub 评审绑
(repo, commit),npm 评审只认被评审的那个
版本,release 救援条目绑repo+sha256。三者都按相等比较。 - 条目、lock 行与 rejection 都按完整身份排序,输出因此不可能依赖 harvest 返回候选者的顺序;
被遮蔽的子包按owner/slug#subdir单元命名。 added从被接受的条目上取,按身份索引,于是一个被拒数周后才上架的包,不再带着它第一次
被 harvest 到的日期。- 识别竞争市场的名字启发式,现在只是不做任何决定的候选选择器。 它在线上抓到 73 个条目,
其中 20 个是无辜的 —— 存茶指南 与 腌菜保存、一个"market"指股市的 A 股量化插件、
一个"store"是动词的会话日志插件。 by: human那道市场闸门在发出去的当天就被回滚。notAShop是已放行名单,所以要求
人签字反而把启发式一直藏着的 16 个像商店的名字广告出去了。一个唯一出口是人的机制等于永久空转。
判定说了算;by只记录是谁判的。
(设计 §4)- 两份构建报告发布在目录旁边。 一条 rejection 的
detail是写给它点名那个包的作者看的,
而它此前只能在某次 workflow run 的 zip 产物里拿到。上游 discussion #5867 就是实证:一位作者
在构建元数据里搜自己的 scope,什么都没搜到,于是断定 harvest 看不见他们 —— 他们六个包里其实
有一个被 harvest 到了,并以no-repository被拒。(#23) - star 数的 sidecar 按它所属的目录索引,并由一个有测试的纯函数序列化,因此每日变动的数据依然
churn 不了插件数据的哈希。
修复
- harvest 能证明自己枚举完了。 npm search 把
from截在 5,000,超过之后静默返回第 0 页;
审计运行时keywords:deepseek-harness是 5,094 个名字,距离每天的构建以错误诊断抛错只差
156 个。超窗的关键词按keywords:a,b交集分区 —— 这是 API 唯一认的过滤限定符,而且没有
取反,因此不存在覆盖式分区。所以 harvest 的安全性来自校验:把枚举到的数量与回答的
total相比,少了就抛错。 - 翻页以回答的
total为准,绝不以短页为准;不带total的响应会抛错;覆盖率探针拒绝部分
答案,探针超时重试一次。 - GitHub 那一半也按回答的总数翻页,并把每个 topic 按 star/日期/体积切窗以绕过 1,000 条上限,
切到最后仍然超限就抛错。 - 小到只可能是 registry 噪声的缺口会被容忍。
- 一次传输失败不再中断整次构建。 ~5,600 个 packument 里死掉的那个成为一行
fetch-failed;
null packument 投影成一条 rejection 而不是TypeError;单仓隔离有了边界,搜索路径同样不能
终结整次 harvest。 no-manifest现在表示 manifest 真的读到了,或者 404 明确回答了它。 旧规则对 404、403、
451、503 一律写它,而且用同一条 detail;又因为失败的仓库既不算消失也不算搬迁,
diffRepoState永远不会重新拉它 —— 于是一次被拦住的raw.githubusercontent.com把
"No package.json at the repository root" 写进了每一个它连不上的仓库的持久记录里。
14,863 条里的 1,929 条被一次性作废,以便在修正后的规则下重新拉取。- 插件作者能据以行动的拒绝理由:
repo-gone漏掉了最可能的原因;被探测的 monorepo 根目录说错了
no-bundle 的原因;因体积被拒的子包没被报出来;workspace 依赖那条拒绝错误地套到了 release 救援
上;patch 目标而非scripts字段才是"prebuilt"的含义;随包发出的 patch 文件不等于它点名
的模块;解析的是 loader 会选中的条目而非扫到的第一个;正确读取 npm 的取消废弃写法与旧式
license 形式;workspacesglob 两端都锚定。 first-seen.yml每次构建都写,却从来没被提交,于是大约一半的货架每天被重新盖成
added: today,所谓"稳定"的内容哈希每天都在变。现在它被提交了,那行git add有测试守着。- 信任跨身份泄漏的八条通道(之所以只是潜在,是因为
verified.yml至今为空):fork 通过共用
bundle 名字继承评审(线上有 83 个名字同时被 fork 和原作占用);比reviewedVersion更旧的
latest渲染成verified;被 commit 评审过的仓库被当成自己的冒名者拒掉;把已评审 GitHub
条目的名字发到 npm 会顶掉它并继承其added日期;对仓库的拒绝被作者转发 npm 化解;两个编辑
距离两步以内的已评审包互相拒绝;reviewedVersion未校验;同时出现在评审与拒绝名单里的名字。 @dsheval/dsh-top100-plugin—— 一个竞争性的 dsh 插件市场 —— 从货架上撤下。启发式一直没抓到
它:名字切成['@dsheval/dsh', 'top100', 'plugin'],而 dsh 前缀规则要求第二段恰好是商店
关键词。- 一次发布不可能再发出过期的目录。 2026-09-03T16:46Z,一次针对最后构建于 09-02 的
dist/v1的运行把它当作2026.903.6发了出去;npm 把latest移到它上面,读者拿到的是一份
两天前、少 818 个条目的目录,直到 dist-tag 被手工移回。内容哈希分辨不出来,
nextCatalogVersion读的是墙上时钟。publish-catalog.ts现在拒绝比已发布latest更旧的构建。 - npm token 不再流到备用镜像,尊重空字符串这种禁用写法;当备用源也失败时,报的是主源自己
的失败。
安全与加固
- 一个恶意包就能把一份 203 MB 的
plugins.json摆到每个读者面前。capabilities只限了
条目数 20,从未限过单条长度;license、repository以及 200 个 peer 名字都是逐字照收;
GitHub manifest 的 body 根本没有上限。这是走真实的toCandidate → gate → assignTier → emit
路径实测而非估算。现在每个自由文本字段都在纯逻辑层设了界,于是超长的值变成一条带作者可读
理由的 rejection,而不是被悄悄置空的字段。 - 五个身份字段与每个条目的整体载荷都设了界;第二次 manifest 读取有上限;理由字符串自身也有界。
- Pages 上发什么是策略,不是"
dist/里恰好落了什么"。upload-pages-artifact以前指向
dist/,于是/v1/harvest.json(4,037,180 字节 —— 每个候选者逐字保留、包括被拒的,
还带着未经校验的dsh.catalog值)与/v1/report.md(1,722,904 字节)在线上都返回 200。
现在构建从零搭建dist/pages/,只拷入pagesArtifactNames返回的东西。 - 机器写入的名字做 JSON 转义,仓库 manifest 的名字在边界处校验 —— 一个含
"或\的name
以前会破坏机器写出的 YAML,之后每次构建都在加载时抛错。 - 报告表格单元会中和控制字符与 bidi 格式字符,且每个 markdown 单元恰好转义一次。
- 每个 GitHub、LLM、stars 请求的 deadline 都覆盖 body;网关超时会重试;advisory 步骤按总量
设界而不只是逐请求设界;构建作业本身也有界。三个此前对自己接线撒谎的 deadline 现在有针对生产
接线的测试。 - 一个格式错误的
REPO_BACKFILL_BUDGET会让构建停下,而不是静默地什么都不 harvest。
CI 与发布
变更
- 构建作业不再持有能写仓库的凭证。 它处理的是敌意输入;现在以
contents: read运行,
推送通过一个只授予本仓库的细粒度 token 完成,且只有推送的那几个步骤持有它。每一个 secret
都收敛到消费它的那一步。 - 每个 action 都钉到 commit SHA,并配置 Dependabot 来推进;Dependabot 本会拆开的 action
成对项被分组,于是一次 bump 不可能只落地一半。(actions/checkout→ 7.0.1、
actions/setup-node→ 7.0.0、pnpm/action-setup→ 6.0.10,以及 pages-deploy 与
artifact-transfer 两组。) - 目录 harvest 按 ref 之间串行,而不是每个 ref 各自串行;目录自己的提交不再重新触发目录构建。
plugin.yml会跑根套件、盯住根 README 与它自己这个文件,并把 e2e 的 harness 钉在
0.1.2-rc.1—— 也就是它的选择器所匹配的那份契约。- 构建作业会 round-trip 一次产物,这是 publish 作业做不到的。
修复
- 机器推送会先 fetch 再 rebase,重试一次,并报告被拒。 2026-09-02 和 2026-09-03 的两次定时
运行,两个推送都被拒绝,却都报成功。 - 当 rebase 的 autostash 没有重新应用时,推送被直接拒绝 —— 这是唯一一种能弄坏
main上
registry 文件的失败模式。
测试
这一版的标准是变异,不是覆盖率:一个测试要靠"注入它声称能抓住的那一行 bug 后会失败"来挣得
自己的位置。审计里有三条 finding,正是因为做了这个检查而套件依然全绿才被发现的。
- 823 个 registry 测试,29 个文件(0.7.4 时 22 个);25 个 package 测试文件(0.7.4 时 21 个)。
- 七个新的 registry 套件:identity、release-asset、pages-artifacts、repo-guards、workflow、
strip-types、cwd-independence。其中 workflow 套件把.github/workflows/daily.yml
当数据读,于是下一次忘掉的git add或缺失的 rebase 会让套件失败,而不是让发布失败。 - 两条不可能失败的断言被替换;三个在自己被删掉后依然通过的守卫被改成有分辨力;一处死代码
throw 被删掉;确定性测试加了重复运行,并去掉了三个什么都证明不了的部分用例。 - 一个被跳过的退出判据现在会让 CI 失败;每个 host 测试文件各自拥有一个临时根目录并负责删除
(仅index.test.ts就有 41 次创建对 2 次删除)。 - 每个 registry 入口点都能被导入而不会退出宿主进程,在
--experimental-strip-types下同样如此。
0.7.4 → 0.8.0 — full diff · npm · live catalog · the audit