Skip to content

feat(cli): os secret orphans — reap pre-fix sys_secret orphans, deleting only over the COMPLETE reference union - #12990

Merged
os-litant merged 5 commits into
mainfrom
claude/issue-8103-sys-secret-orphan-reap
Aug 28, 2026
Merged

feat(cli): os secret orphans — reap pre-fix sys_secret orphans, deleting only over the COMPLETE reference union#12990
os-litant merged 5 commits into
mainfrom
claude/issue-8103-sys-secret-orphan-reap

Conversation

@os-litant

@os-litant os-litant commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8103

The operator-run half of the pre-fix sys_secret residue: a new os secret orphans
command that reports the rows no producer references any more and, only behind
--delete, removes the ones the maintainer ruling permits.

Ruled shape, executed rather than re-litigated: B (2026-08-26) — an explicit
operator command, dry-run report as the default, deletion behind an explicit flag, a
mandatory pre-delete export, and the unattributable class never deleted — and B'
(2026-08-27) — sequenced behind the cross-producer reference union, so the predicate is
"attributable AND unreferenced by the COMPLETE union". Report-only remains the recorded
fallback and this PR does not take it. There is no boot-time or upgrade-time sweep;
nothing on any boot path reaches this command.

The reproduction, on real code

The card turns on one measurement, and it is reproduced against the real producers
rather than argued (sys-secret-orphan-sweep.test.ts, first describe block):

  • a live, engine-owned credential — a secret-typed field's handle, still named by a
    business row's own column — is classified orphaned by the shipped settings-scoped
    classifier
    , i.e. it lands in the old predicate's deletable bucket;
  • the same row under the complete union is referenced, with its holder coordinates
    (object-field: smtp.password#rec_1), and never reaches the deletable list.

Both halves are asserted in one test so neither can drift from the other. The fixture
mints all three handles through the producers that really write them — the real
ObjectQL insert path, the real LocalCryptoProvider, the real datasource credential
binder — so the ref spellings under test come from the producers, not from the test file.
Its positive control is that the same planner over the same fixture does return a
non-empty deletable list (a genuine settings orphan), so every "empty" assertion in the
file is falsifiable.

The safety property, and how it fails loudly

sys_secret has three producers and no producer column, so "unreferenced by
sys_setting" is not "unreferenced". The command therefore decides on the union of all
three holder families and refuses to delete whenever any family could not be
enumerated, naming that family
. The union's three independent gap sources are respected
and none is read as "no references":

gap source how it is reached pinned
the host did not declare its code-defined datasources no --declared-datasources and no --no-declared-datasources yes
the engine exposes no listDatasourceDefs() a narrowed engine slice yes
a family's read failed an unreadable sys_setting yes

Per-family status and reason are carried through to the operator, to --json and into the
export — never flattened to a boolean. A row that is unreferenced by what enumerated is
withheld under its own class, union_incomplete, distinct from the other withholds.

Three further refusals are mechanical rather than advisory, because a delete over a
secrets table cannot delegate to an operator's attention:

  • unattributable is never deletable — the ruling's hard line.
  • A legacy inline-crypto sibling is withheld, which is the sec_ guard from fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063
    turned in the opposite direction: an inline value_enc names no handle, so a
    sys_secret row sharing its (namespace, key) looks unreferenced because of the
    legacy value. The shipped report flags it and tells an operator to look; here it is
    excluded from the deletable set outright.
  • A re-wrap is not a retirementrotateKey() keeps the handle stable, so version
    and rotated_at are reported and are never verdict inputs. Pinned in both directions:
    re-wrap evidence on a referenced row does not make it deletable, and the same evidence
    stamped on a genuine orphan does not change its decision either.

The mandatory export

The settings audit trail records content digests, never handles, so a row deleted in
error can neither be named afterwards nor recovered. --delete without --export is
refused; the file carries the cipher material (an export that named the row without it
would make the mistake describable and still permanent), is written owner-only, refuses
to overwrite, and is read back and checked against the plan before a single row is
removed
. Building an export that is short by even one deletable row is a hard error.

The operator-facing text states the measured, inverted framing

The tempting sentence — "this cleans up leaked old credentials" — is false for this
population, and the command never says it. On the pre-fix rotation path the handle was
never repointed, so the value still in force is the oldest one — the credential the
administrator believed they had replaced — while each orphan holds a value that never
took effect. Deleting orphans therefore retires nothing that is exposed, and if the
administrator also rotated at the provider the newest orphan may be a credential that is
currently valid there. That paragraph is data (SWEEP_EXPOSURE_NOTES) and is asserted by
test, including a negative assertion with its own positive control.

Reachability facts, re-measured on the day

Re-measured against origin/main@b1a987e4a on 2026-08-28, not cited:

  1. Handle ids are minted per encrypt call — RE-ESTABLISHED.
    local-crypto-provider.ts:459 mints 'sec_' + randomBytes(16).toString('hex') inside
    encrypt(). The id carries no producer marker: a prefix plus 32 hex characters of
    entropy and nothing else. rotateKey at :494 returns id: handle.id with
    version + 1, so a re-wrap keeps the handle and produces no orphan.
  2. sys_setting.value_enc is the sole holder — FALSIFIED, sustained. Three producers
    with three holder columns, and sys_secret declares nine fields (id, created_at,
    rotated_at, namespace, key, kms_key_id, alg, version, ciphertext) — no
    producer column among them. This falsification is the premise this PR is built on: the
    union is the repair for it, which is exactly why B' sequenced the union first.
  3. The audit trail records digests, not handles — RE-ESTABLISHED. The only values
    reaching an audit payload field are crypto.digest(plain) ('sha256:' + sha256),
    null, or that digest wrapped in an encrypted marker. Positive controls on the same
    file: sec_ appears twice and value_enc 25 times, so the scan is alive over it; the
    zero is about the audit payload fields alone. This is what makes the export
    load-bearing rather than belt-and-braces.

Verification

Gate union derived after the last commit, from the real changed set
(node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reads the
merge base itself), and every result below was taken at ba99af32e, the head this PR
carries. Exit codes captured before any pipe.

  • pnpm --filter @objectstack/cli typecheck — green. tsc --listFiles confirms all three
    new files are in the program, the test file included, so the green is not a phantom over
    sources nothing read.
  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over the two new files —
    Test Files 2 passed (2) / Tests 25 passed (25).
  • The package's source-scanning pins, which read every production module and therefore see
    the new command: lazy-driver-import, invocation, ADR-0048 app split, doctor deprecation
    hints — 6 files, Tests 61 passed (61).
  • pnpm lint (eslint . --no-inline-config, the whole repo, not narrowed) — green, 98s.
  • Derived gate families, all green: check:nul-bytes, check:engine-double-contract,
    check:where-matcher, check:objectql-double-limit,
    check:dispatcher-error-vocabulary, check:cross-package-test-inputs,
    check:query-options-erasure, check:type-check-coverage,
    check:type-check-debt --re-measure, check:i18n, check:i18n-coverage,
    check:empty-changeset, check:objectui-changeset, check:changeset-no-major,
    check:adr-0087-registration, check:cli-command-ids, check:test-source-alias,
    check:type-source-resolution, check:published-files, check:page-declaration-shape,
    check:slot-lookup, check:comment-mask-adoption, check:plugin-teardown-shape,
    check:ci-filter-parity, and the docs family (doc-authoring, doc-anchors,
    docs-single-h1, docs-audit-scope, docs-redirects, role-word, doc-frontmatter,
    doc-route-spelling, docs-section-name, section-landing-index).
  • check:i18n and check:i18n-coverage first returned PREREQUISITE NOT MET and
    COULD NOT MEASURE on a fresh worktree — an unbuilt CLI and unbuilt example
    dependencies, not a red. Both were re-run green after building the workspace closure the
    way lint.yml does; the not-measured readings are recorded rather than reported as passes.

Ablation — two runs, each isolated to its own guard

Both mutate the planner, prove the mutation landed on disk (anchor count 1 to 0, injected
count 0 to 1, and a changed blob hash) before reading any verdict, and restore with
git checkout HEAD -- against an absolute path under a trap on EXIT/INT/TERM. Restoration
is proven by state, never by exit code: the on-disk blob hash equals the HEAD blob hash
and git diff HEAD is zero lines. No rebuild leg is needed and none is claimed — the test
imports the module by an intra-package relative specifier, so vitest resolves it to source;
packages/cli/dist did not exist at all during either run, which is the positive control
for that claim.

  • Remove the union-incompleteness refusal — 4 of 25 red, and exactly the incompleteness
    class: all three gap-source pins plus the export's empty-when-incomplete pin. The other
    21, including every other guard and the reproduction itself, stayed green.
  • Remove the unattributable guard — exactly 1 red, the pin that names it.

Declared narrowing

The full @objectstack/cli vitest suite was not run to completion locally. It is a
long integration suite (it boots real stacks per test); two in-round blocking waits of
570s each did not reach its summary, so it was stopped and left to CI, which runs it
regardless. What the greens above therefore do not cover: any CLI test outside the six
files named. Everything the change itself is judged by — the new pins, the package
typecheck, the package's whole-source scanning pins, repo-wide ESLint and the derived gate
families — was run.

Files

  • packages/cli/src/utils/sys-secret-orphan-sweep.ts — the pure planner and the export
    builder. No I/O, no decryption; every refusal is a returned value, which is what makes
    them testable.
  • packages/cli/src/utils/sys-secret-orphan-sweep.test.ts — the reproduction and the pins.
  • packages/cli/src/commands/secret/orphans.ts — the command.
  • packages/cli/src/commands/secret/orphans.guards.test.ts — the two command-level guards:
    an unreadable declared-datasources file never degrades into [], and a driver without a
    delete is refused rather than cast onto the union's read-only port.
  • content/docs/deployment/cli.mdx, .changeset/secret-orphans-command.md.

Nothing under packages/spec and no new published error code — the refusal reuses the
standard catalog's PRECONDITION_REQUIRED, the same pair the union's own error carries.

Generated by Claude Code


Amendment (2026-08-28) — the pre-delete export is now CREATED, never written onto

Narrow revision after the review ruling on this PR. One production line changes.

The window. --export refuses a path that already exists, but that check sits near the
top of run() — before the boot, before the scan, before the interactive confirmation —
while the write happens after all three. Between them is an arbitrarily long window. The
write used the default flag, which truncates an existing file; and mode is applied by the
OS only when a file is CREATED, so a write onto a path some other process won inside that
window would have inherited that file's owner and permissions and put cipher material
inside them.

The change. writeFileSync(exportPath, doc, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
— create-or-fail. EEXIST lands in the export_failed refusal that already existed (no new
branch, no new error code), so nothing is deleted, and 0600 is guaranteed at the instant of
creation. The early existsSync pre-check is deliberately kept: it still produces the better
operator message, and the two are defence in depth rather than alternatives. The pre-check was
never the guarantee; wx is.

The new pin (orphans.guards.test.ts) drives the real --delete --json run with only two
seams faked — bootSchemaStack and collectSecretReferenceUnion. Everything that decides
WHICH rows are deletable runs for real: planSysSecretOrphanSweep, buildPreDeleteExport,
collectEncryptedSpecifierRefs, isSecretHandle. The subject test creates the export file
inside the mocked boot — i.e. inside the window itself — and asserts the harm first (no row
removed, and the squatter's bytes byte-identical), then the envelope (error: 'export_failed',
message naming the path). Its positive control runs the same path over an unclaimed
destination and asserts a row IS removed and the export IS written 0600, so the empty
deletion list above cannot be satisfied by a command that can never delete anything.

Verification — every figure taken at ab927b16, after the last commit

  • pnpm --filter @objectstack/cli typecheck (tsc --noEmit) — exit 0, captured before any
    pipe. tsc --noEmit --listFiles | grep commands/secret/orphans returns 2 lines, naming
    both edited files, so the green is measured over them and not a phantom.
  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/secret/orphans.guards.test.ts
    Test Files 1 passed (1) / Tests 11 passed (11).
  • pnpm lint (eslint . --no-inline-config, the whole repo, not narrowed) — exit 0, 99s.
  • Gate union re-derived after the last commit
    (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack) and run, each
    quoting its own verdict line: check:nul-bytes (OK, 7193 text files, no raw ASCII control
    bytes), check:test-source-alias, check:cross-package-test-inputs,
    check:type-source-resolution, check:objectql-double-limit,
    check:engine-double-contract, check:where-matcher, check:query-options-erasure,
    check:type-check-coverage, check:type-check-debt --re-measure (31 ledger entries
    re-measured, none above its recorded number), check:i18n, check:i18n-coverage
    (12 configs, 602 baselined, none new), check:page-declaration-shape, check:slot-lookup,
    check:published-files, check:changeset-gate-self-tests, check:objectui-changeset,
    check:pm-half-states, check:dispatcher-error-vocabulary, check-adr-0087-registration,
    check-changeset-no-major, check-empty-changeset, check-comment-mask-adoption,
    check-plugin-teardown-shape, check-ci-filter-parity — all green.
  • check:i18n-coverage first read COULD NOT MEASURE (an unbuilt connector-mcp /
    connector-openapi in this partial worktree, not a red) and is reported green only after the
    workspace closure was built the way lint.yml does. check:type-check-debt likewise refused
    outright on an unbuilt closure and was re-run after
    turbo run build --filter='./packages/*' --filter='./packages/*/*'.
  • Declared narrowing: the docs family (doc-authoring, doc-anchors, docs-single-h1,
    docs-audit-scope, docs-redirects, role-word, doc-frontmatter, doc-route-spelling,
    docs-section-name, section-landing-index) is matched only via
    content/docs/deployment/cli.mdx, which this amendment does not touch. It was green in the
    previous round at ba99af32e and CI runs it regardless.

Ablation — the flag itself, one leg

Delete , flag: 'wx' from that one call and nothing else. Proven on disk before any verdict
was read: anchor flag: 'wx' 1 → 0, replacement mode: 0o600 } 0 → 1, and the blob hash moved
57283a48…5882dea2…. Restored with git checkout HEAD -- against an absolute path
under a trap … EXIT INT TERM, and the restoration proven by state rather than by exit code:
the on-disk blob hash is 57283a48… again — equal to git rev-parse HEAD:packages/cli/src/commands/secret/orphans.ts
— and git diff HEAD for that path is zero lines.

  • ablated: Test Files 1 failed (1) / Tests 1 failed | 10 passed (11), and it fails naming
    the harm rather than the envelope: expected [ 'sec_orphan_1' ] to deeply equal []. The
    run truncated the squatted file and then deleted the row — exactly the defect.
  • restored: Test Files 1 passed (1) / Tests 11 passed (11).
  • The positive control stays green in both legs, which is what makes it a control rather
    than a second copy of the subject.

No rebuild leg is needed and none is claimed: the test imports the command by an intra-package
relative specifier, so vitest resolves it to orphans.ts source and no dist/ artefact is
on the resolution path.

Not a new changeset

.changeset/secret-orphans-command.md was amended in place rather than a second file added: it
already describes this export, and its "refuses to overwrite" sentence now states the atomic
guarantee instead of only the pre-check.

Generated by Claude Code

claude added 2 commits August 28, 2026 11:30
… over the COMPLETE reference union

The operator-run half of the pre-fix `sys_secret` residue. Report-only by
default; `--delete` removes ONLY rows that are attributable to a declared
encrypted settings specifier AND named by no family of the cross-producer
reference union, and it refuses whenever that union is incomplete, naming the
family that could not be enumerated.

Three guards are mechanical rather than advisory, because a delete over a
secrets table cannot delegate to an operator's attention:

  - `unattributable` rows are never deletable;
  - a row whose (namespace, key) currently resolves through a LEGACY INLINE
    sys_setting value is withheld — the #8063 prefix guard in the opposite
    direction, since an inline value names no handle and so explains the
    absence;
  - re-wrap evidence (version / rotated_at) is reported and is never a verdict
    input: a re-wrap keeps the handle stable and is not a retirement.

The pre-delete export is mandatory and carries the cipher material, because the
audit trail records content digests rather than handles: without it an
erroneous delete can neither be named nor undone. It is written owner-only and
read back and checked against the plan before any row is removed.

The operator-facing text states the measured, inverted exposure framing: the
value still in force is the OLDEST one, the orphans hold values that never took
effect, so this retires nothing that is exposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
The docs entry states the same two things the command's own output does: the
delete is refused unless every producer family enumerated, and the exposure
framing is inverted — the value still in force is the oldest one, so the sweep
retires nothing that is exposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Aug 28, 2026
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 49 documentable anchor(s).

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via sys_secret (literal))
  • content/docs/concepts/metadata-lifecycle.mdx (via refKey (symbol))
  • content/docs/data-modeling/drivers.mdx (via sys_secret (literal), sys_setting (literal))
  • content/docs/data-modeling/external-datasources.mdx (via sys_secret (literal))
  • content/docs/data-modeling/objects.mdx (via sys_secret (literal))
  • content/docs/data-modeling/validation-rules.mdx (via sys_secret (literal))
  • content/docs/deployment/backup-restore.mdx (via sys_secret (literal))
  • content/docs/deployment/cli.mdx (via sys_secret (literal), sys_setting (literal), os secret orphans (command))
  • content/docs/deployment/environment-variables.mdx (via sys_secret (literal), sys_setting (literal))
  • content/docs/permissions/authorization.mdx (via sys_secret (literal))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_secret (literal), sys_setting (literal))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_secret (literal))
  • content/docs/releases/v16.mdx (via sys_secret (literal))
  • content/docs/releases/v17.mdx (via sys_secret (literal), sys_setting (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (symbol, 34 pages)
  • 23 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json bf10debd587f6ba891be9eadc2b76c91e15bd82bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 4eba2b6d41420f379752012b009a3450248237c9 — the merge of head e9347402b0e73ce590a8ac253be111369926a73d into base bf10debd587f6ba891be9eadc2b76c91e15bd82b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4eba2b6d41420f379752012b009a3450248237c9 && git checkout 4eba2b6d41420f379752012b009a3450248237c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bf10debd587f6ba891be9eadc2b76c91e15bd82b e9347402b0e73ce590a8ac253be111369926a73d && git checkout -B drift-repro bf10debd587f6ba891be9eadc2b76c91e15bd82b && git merge --no-ff e9347402b0e73ce590a8ac253be111369926a73d

node scripts/docs-audit/affected-docs.mjs --json bf10debd587f6ba891be9eadc2b76c91e15bd82b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs bf10debd587f6ba891be9eadc2b76c91e15bd82b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

PM 复核 — #8103 / domain:cli 席位

复核对象是树,不是报告。以下每条都在 refs/pull/12990/head 上重新量过。

已验证通过

判据一:联合体不完整时拒绝删除,并点名缺哪一族 — 成立,而且是结构性的,不是文档性的。
planSysSecretOrphanSweep 里 refusal 分支在 deletable 分支之前 continue,所以 refusal 存在时 deletable 集合根本无法累积。全文件 deletable.push 只有一处(第 433 行),在 refusal 之后不可达。命令层第 294 行再拦一次。两道独立闸门,任一单独成立即安全。

判据二:三个 gap 来源不得在消费侧拆平成布尔 — 成立。
complete推导值(union 模块 gaps.length === 0),不是调用方传入的布尔;而 union.gaps 的每族 reason 一路带到 refusal、带到每一行 withheld 的 reason 文本、带到 families 记录。布尔和明细并存,明细没有被布尔取代。

其余:unattributable 永不可删(第三分支,先于任何删除);legacy inline 同名行整类扣留;导出为强制项(无 export 即拒绝删除),写入后读回,并逐一核对 plan.deletable 每个 id 都在导出里才进删除循环;删除端口与只读端口分离,驱动无 delete 则在循环前拒绝。

围栏干净:6 个文件,未触碰 secret-reference-union.ts(只消费),无 content/docs/releases/ 夹带。

两个待裁问题 — 裁定

问题一:导出应携带密文材料(已实现)还是仅元数据?
维持已实现:携带密文材料。 理由是导出的存在目的:审计线索记的是内容摘要而非 handle,误删的行事后无法指名、也无法恢复。仅元数据的导出只能告诉运维"丢了什么",不能把它放回去 —— 那样这个文件就不再是它自称的那个东西。既然裁定携带密文,文件创建的防护就必须无懈可击,这直接引出下面唯一一条必须修改项。

问题二:legacy inline 类整类扣留(已实现)还是给 override 开关?
维持已实现:整类扣留,不加开关。 扣留的理由不是"证据弱",而是"证据已被证伪" —— 该行缺席联合体这件事已经被 inline 值解释掉了,它不再构成孤儿的证据。override 开关等于让运维在工具自己已证明不具证明力的证据上执行凭据删除,而正确出路已经写在 reason 里(先把该 setting 迁出 inline 路径)。若实践中确实频繁撞上,正确的应对是一条迁移命令,不是一个开关。

必须修改(一处,落地前)

导出文件的写入用的是默认写入标志(截断/创建),而"拒绝覆盖"的检查在引导、扫描和交互确认之前。两者之间隔着任意长的时间窗:若该路径上的文件在这段窗口内被他人抢先创建,写入会沿用既有文件的属主与权限,mode 在文件已存在时根本不会被应用 —— 于是密文材料落进一个不由运维独占的文件。这正是问题一裁定所依赖的前提被击穿的那一种方式。

改法:把写入改成排他创建(exclusive create),即在写入选项里显式指定排他创建标志。这样文件已存在时写入直接失败并进已有的 export_failed 拒绝路径,权限位保证在创建瞬间生效,上游那次检查也从"预检"变成"原子保证"。

阳性对照已做:排他标志在该文件中出现 0 次,而 writeFileSync 出现 2 次 —— 扫描是活的,这个 0 不是死扫描的假零。

补一条守卫测试:导出路径已存在时,--delete 不得删除任何行。

选择现在改而不是另开卡的理由是同文件硬串行 —— orphans.ts 的后续 PR 必须排在本次合并之后,一个选项键的改动不值一个完整串行周期。

其余部分我不要求改动。自查出的两项(测试里默认参数吞掉显式传入的 undefined、复合键分隔符里混入裸 NUL 控制字节)是本席位鼓励的那一类自我证伪,已计入。约束三里事实 2 再测仍为 FALSIFIED 属预期读数,不是停止条件 —— 未因此停工是正确的。

CI 当前 31 项无红,3 项仍在跑。修改推上后重新拉取完整清单(最后一个分片结束后会新增汇总检查),全绿再撤草稿。


Generated by Claude Code

os-litant and others added 2 commits August 28, 2026 12:46
… it lost the race for

`os secret orphans --delete` refuses an export path that already exists, but
that check runs before the boot, the scan and the confirmation — an arbitrarily
long window before the write. The write itself used the default flag, which
truncates an existing file and, because `mode` is applied only at creation,
inherits that file's owner and permissions. Cipher material could land in a
file the operator does not exclusively own.

`flag: 'wx'` makes it a create-or-fail: EEXIST lands in the existing
`export_failed` refusal, so nothing is deleted, and 0600 is guaranteed at the
instant of creation. The pre-check stays — it still gives the better early
message; the two are defence in depth, not alternatives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…sitive control

Drives the real `--delete --json` run against two fake seams only — the boot
and the reference-union collector. Everything that decides which rows are
deletable (`planSysSecretOrphanSweep`, `buildPreDeleteExport`,
`collectEncryptedSpecifierRefs`, `isSecretHandle`) runs for real.

The subject test creates the export file *inside the mocked boot*, which is
exactly the window between the early `existsSync` refusal and the write: the
run must remove no row and must leave the squatter's bytes intact. The positive
control runs the same path over a free destination and asserts a row IS
removed, so the empty list above cannot be satisfied by a command that can
never delete anything.

Also amends the existing changeset to state the atomic guarantee rather than
just the pre-check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
The family in json-stdout-purity.e2e.test.ts is DISCOVERED from the source
tree — every command that calls bootSchemaStack and declares a `--json` flag —
and reconciled against FAMILY. `os secret orphans` satisfies both halves, so it
joined the discovered set the moment it landed and the reconciliation went red,
exactly as that file's docblock promises it should.

Listed and actually driven, not just named. Its report-only default is the form
driven here: without `--delete` it boots, reports and writes nothing, so the
family gains a member without the fixture gaining a destructive run.

Measured on the uncompiled fixture before listing it, in case the red was
hiding a real leak — it was not: stdout is a single 2836-byte line that a bare
JSON.parse accepts (keys `mode,plan`), with zero kernel-logger records and zero
`[StandaloneStack]` lines, while all three boot diagnostics stay on stderr. No
change to the command was needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sys_secret: rotations that happened BEFORE #8030's fix left decryptable orphans on deployed instances, and nothing reaps them

2 participants