border-customs 0.3.1 → 0.4.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 CHANGED
@@ -214,6 +214,45 @@ restate. The empirical roundtrip (install → snapshot → uninstall → rc-delt
214
214
  valve. **border is NOT a malware sandbox**: it never executes, intercepts, or sandboxes the
215
215
  code it reads; it matches text and blocks the push.
216
216
 
217
+ #### Proof valve: `residue.requireProof` (0.4.0)
218
+
219
+ Static analysis stops at capability. `residue: { requireProof: true }` (strict; default **off**)
220
+ raises the bar for publish channels: when the residue scan flags a blocking-capable finding on a
221
+ staged artifact, `border check` can no longer PASS that channel on signatures alone — the
222
+ artifact's sha256 must also carry a **fresh empirical roundtrip verdict** in the ledger.
223
+
224
+ The check gate NEVER runs Docker itself (there is nothing to trust it to run); the proof is
225
+ pre-supplied out-of-band by `border roundtrip <spec>`, which installs the real bytes, diffs the
226
+ filesystem, and appends a `t:"roundtrip"` ledger record — `{artifactSha256, verdict:
227
+ clean|residue, rulesHash, ts, rows}` — keyed by the exact bytes it fetched. A `residue` verdict
228
+ still counts as a proof-of-fact: the valve demands *evidence someone looked*, and the finding
229
+ itself keeps blocking until it stops tripping the scan.
230
+
231
+ - Missing proof ⇒ new native CRITICAL `roundtrip-proof-missing`; proof whose `rulesHash` no
232
+ longer matches (classifier, engine, or config changed — including the flag flip itself) ⇒
233
+ `roundtrip-proof-stale`. Freshness is exactly the fingerprint pattern: rotating the policy
234
+ invalidates cached verdicts, PASSes included.
235
+ - `border roundtrip` records by default (`--record` is the default; opt out with `--no-record`).
236
+ - Both new rules are ordinary findings: the allow-list can waive them and every waiver is
237
+ enumerated in `allowHits` — no hidden channel.
238
+
239
+ ### Running `border roundtrip`
240
+
241
+ `border roundtrip <[ecosystem:]name@version>` fetches the real registry bytes, installs them in a
242
+ throwaway Docker container (per-ecosystem image, whole-filesystem content-hash manifest before
243
+ install and after the manager's own uninstall), prints the residue manifest, and records the
244
+ verdict. Known fidelity envelope, from the W2.0 spike and the W2.3 registrar-chain demo:
245
+
246
+ - Docker is required; absence or any step failure ⇒ exit 2, never a silent `clean`.
247
+ - npm/gem/crates lanes diff exactly; the pypi lane reports pip's left-behind **transitive
248
+ dependencies** as orphan rows (calibration to a target-only diff is a known follow-up —
249
+ the over-report direction is deliberately conservative).
250
+ - Only manager-lifecycle surfaces are observed: persistence performed by explicitly-invoked
251
+ bins (not npm hooks) is outside what the roundtrip watches — W2.3 measured exactly this
252
+ blind spot on our own registrar and documented its `{"plugin": []}` config residue.
253
+ - A `clean` verdict is proof-of-fact about one artifact version on one machine class, not a
254
+ security guarantee.
255
+
217
256
  ## Architecture: `border push`
218
257
 
219
258
  `border push` is a state machine over per-target states, recomputed from live git queries,
@@ -375,6 +414,39 @@ dropped file to stderr with the sha256 of its original bytes
375
414
  (`border scan: crates envelope normalized: <file> sha256=<hex>`); the rebuild regenerates
376
415
  them from the normalized manifest, so no scanned byte is silently lost.
377
416
 
417
+ ### Engines in consumer installs
418
+
419
+ `dist/index.js` is one self-contained esbuild bundle and every runtime asset (vendored
420
+ gitleaks rules, prompt template, the secretlint fingerprint snapshot) ships inside `dist/`,
421
+ so after `npm i -D border-customs` (or `-g`) the engine surface is:
422
+
423
+ - **publint** — installed automatically: it is an exact-pinned runtime dependency of the
424
+ package, invoked via bin resolution (package-local `node_modules/.bin`, the hoisted
425
+ project-level `node_modules/.bin`, then `PATH`, then `~/.local/bin`). The
426
+ `publint-fail` leg of the npm artifact stage works out of the box in a consumer install.
427
+ - **secretlint** — no consumer action, ever: the default lint runs **in-process** with the
428
+ `@secretlint/*` rule modules already declared as border's own runtime dependencies
429
+ (`src/engines/secretlint.ts` header). The `mode: "cli"` fallback that spawns a
430
+ `secretlint` binary is border-internal (test transport); it is not reachable from
431
+ `border.yaml` or any flag, so installing `secretlint` into your project changes nothing
432
+ for border. If the engine probe cannot read the bundled lock snapshot
433
+ (`dist/assets/package-lock.json`), the run fails closed as `DEGRADED-ENGINE` exit 2 —
434
+ never a silent pass.
435
+ - **gitleaks** — stays external, same posture as `git`: border vendors the 8.30.1 rule
436
+ config but the binary is yours (on `PATH` or `~/.local/bin/gitleaks`; see the
437
+ requirements paragraph at the top and `src/engines/ADAPTER-CONTRACT.md`). Missing
438
+ gitleaks means `border check` records a CRITICAL `DEGRADED-ENGINE` finding and exits 2,
439
+ and `border scan` exits 2 with `engine binary 'gitleaks' not found; border fails
440
+ closed`. Neither command degrades to a skip.
441
+ - **trufflehog** — optional third engine, external binary, same posture as gitleaks once
442
+ enabled: `engines.trufflehog: true` puts it in the required set, and an absent binary
443
+ degrades the run to exit 2 exactly like a missing gitleaks.
444
+
445
+ There is no "engine absent ⇒ leg silently skipped" state anywhere: missing required
446
+ engines are exit 2 (`2` = the gate could not answer, see
447
+ [Exit codes](#exit-codes-the-contract)), and the ledger refuses to write a PASS for a
448
+ degraded run.
449
+
378
450
  ## Configuration
379
451
 
380
452
  Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
@@ -541,6 +613,30 @@ same one that wrote the commit) how to run the five subcommands, how to produce
541
613
 
542
614
  ## Changelog
543
615
 
616
+ ### 0.4.0 (2026-09-10)
617
+ - Add: `border roundtrip <[ecosystem:]name@version>` — fact-proof valve. Installs the
618
+ package inside a throwaway Docker container, content-hashes the whole filesystem before
619
+ and after uninstall, and reports exactly what the uninstaller left behind. Persistence
620
+ surfaces (rc/profile mutations) => CRITICAL; orphan files => HIGH. Verdicts record to
621
+ the ledger by default (`--no-record` opts out); Docker absent => fail-closed, never clean.
622
+ - Add: `residue.requireProof` — when on, a residue-capable artifact only passes `border
623
+ check` with a fresh recorded roundtrip proof (bound to rulesHash; flipping the config
624
+ invalidates cached verdicts). Missing proof => CRITICAL `roundtrip-proof-missing`.
625
+ - Opt-in test legs: BORDER_ROUNDTRIP_DOCKER=1 (8 real container legs, 4 ecosystems).
626
+
627
+ ### 0.3.2 (2026-09-10)
628
+ - Fix: CLI invoked through the npm `.bin` shim silently exited 0 without running
629
+ (entrypoint detection now realpath-canonicalizes both sides). Regression-locked by a
630
+ pack -> install -> shim-invocation test (`BORDER_PACK_TEST=1`, wired into CI).
631
+ - Fix: `publint` is an exact-pinned runtime dependency, so `border scan`/npm checks work
632
+ out of the box in consumer installs; README documents the engine posture truthfully.
633
+ - Add: release-coherence seed test pinning the scan User-Agent version to package.json.
634
+
635
+ ### 0.3.1 (2026-09-10)
636
+ - Add: `border scan [ecosystem:]name@version` — static residue inspection of third-party
637
+ registry packages (npm/PyPI/crates.io/RubyGems), ledger-free by design.
638
+ - Docs: README scan section; crates envelope normalization now logs scrubbed-file hashes.
639
+
544
640
  ### 0.3.0 (2026-09-09)
545
641
 
546
642
  - **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
@@ -584,4 +680,14 @@ MIT, see [LICENSE](LICENSE).
584
680
 
585
681
  ## 中文概要
586
682
 
587
- border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install -g border-customs` 安装,在仓库里跑 `border check`。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI/crates/RubyGems 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 PASS 记录时才允许 `border push --yes` 放行。指纹是 sha256(head、porcelain 摘要、规则哈希、暴露面、ref 集合、有效目标)六元组,任何一处变动,旧的 PASS 立即失效,必须重查。流水线:gitleaks(历史+工作区+tag,内置 8.30.1 规则,仓库自带的 ignore 文件直接判 CRITICAL)+ secretlint(进程内,AWS Key 规则强制开启)+ 原生规则(AI 会话产物闭集、提交身份白名单含传输对象检查)+ 注册表预检(版本已存在=必须 bump,名称被外人占有=拒绝;空响应/超时/解析失败一律 exit 2,沉默绝不等于不存在)。构件只构建一次进 `.border/dist/`,扫描的就是发布的字节,发布时再哈希比对,不一致直接拒发。跳过台账让重复检查不到 1 秒,但回放前先重算指纹并重新 pack 验证新鲜度。报告只输出掩码片段(sha256 摘要 + 前4…后4)。push 是多目标状态机,多 remote 先做全有或全无的 fast-forward 预检,永不 force-push;npm/twine/cargo/gem 的凭据经 stdio 透传,border 从不触碰。0.2.0 新增 crates.io 与 RubyGems 通道(公开 crates.io 固定、rubygems 可用 host 覆盖私有镜像),既有配置行为不变。0.3.0 新增残留扫描(residue gate):发布字节里的安装期钩子按 T0-T4 闭集签名表分类(闭集 T1 树内钩子降为 MEDIUM,未知形态原样保留 CRITICAL),外加跨包管理器写入、配对标记缺失(`# BEGIN` 块只检测不豁免)与 gem 不可解析扩展共七条 `residue-*` 规则;`residue.enabled` 是唯一豁免开关且改动指纹使旧 PASS 失效,规则表与分类器源码进入 rulesHash,改一个签名即强制重查。静态分析只证明"能力"不证明"事实",border 不是恶意软件沙箱,从不执行被读代码。可选 LLM 层 border 自身从不调用模型 API:`llm-request` 导出掩码审阅包,`llm-ingest` 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
683
+ border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install -g border-customs` 安装,在仓库里跑 `border check`。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI/crates/RubyGems 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 PASS 记录时才允许 `border push --yes` 放行。指纹是 sha256(head、porcelain 摘要、规则哈希、暴露面、ref 集合、有效目标)六元组,任何一处变动,旧的 PASS 立即失效,必须重查。流水线:gitleaks(历史+工作区+tag,内置 8.30.1 规则,仓库自带的 ignore 文件直接判 CRITICAL)+ secretlint(进程内,AWS Key 规则强制开启)+ 原生规则(AI 会话产物闭集、提交身份白名单含传输对象检查)+ 注册表预检(版本已存在=必须 bump,名称被外人占有=拒绝;空响应/超时/解析失败一律 exit 2,沉默绝不等于不存在)。构件只构建一次进 `.border/dist/`,扫描的就是发布的字节,发布时再哈希比对,不一致直接拒发。跳过台账让重复检查不到 1 秒,但回放前先重算指纹并重新 pack 验证新鲜度。报告只输出掩码片段(sha256 摘要 + 前4…后4)。push 是多目标状态机,多 remote 先做全有或全无的 fast-forward 预检,永不 force-push;npm/twine/cargo/gem 的凭据经 stdio 透传,border 从不触碰。0.2.0 新增 crates.io 与 RubyGems 通道(公开 crates.io 固定、rubygems 可用 host 覆盖私有镜像),既有配置行为不变。0.3.0 新增残留扫描(residue gate):发布字节里的安装期钩子按 T0-T4 闭集签名表分类(闭集 T1 树内钩子降为 MEDIUM,未知形态原样保留 CRITICAL),外加跨包管理器写入、配对标记缺失(`# BEGIN` 块只检测不豁免)与 gem 不可解析扩展共七条 `residue-*` 规则;`residue.enabled` 是唯一豁免开关且改动指纹使旧 PASS 失效,规则表与分类器源码进入 rulesHash,改一个签名即强制重查。静态分析只证明"能力"不证明"事实",border 不是恶意软件沙箱,从不执行被读代码。0.4.0 新增证明阀
684
+ `residue.requireProof`(默认关,strict):开启后,发布构件若触发阻断级 `residue-*` 发现,通道 PASS
685
+ 还要求账本里存在按其 sha256 索引且 rulesHash 新鲜的 `border roundtrip` 实测记录——check 自身从不
686
+ 运行 Docker,证据由 `border roundtrip` 离线写入(默认记账,`--no-record` 关闭),clean 与 residue
687
+ 两种裁决都算"事实已在";缺记录判 `roundtrip-proof-missing`、rulesHash 过期判
688
+ `roundtrip-proof-stale`(均 CRITICAL/native,与普通发现同受白名单管辖并在 allowHits 枚举),翻转
689
+ 该配置即轮换 rulesHash,所有缓存 PASS 自动失效。`border roundtrip` 本身保真边界:Docker 必需、
690
+ 缺失即 exit 2 绝不假装干净;pypi 通道会把 pip 遗留的传递依赖如实报成孤儿行(收窄到目标包自身是
691
+ 已知后续项);只有包管理器生命周期触发的写入被观测,显式调用的 bin 自写配置不在射程——W2.3 在
692
+ 自家 registrar 链上实测到该盲区并留下了 `{"plugin": []}` 残留证据。clean 是"某一版本构件在某类
693
+ 机器上"的事实证明,不是安全担保。可选 LLM 层 border 自身从不调用模型 API:`llm-request` 导出掩码审阅包,`llm-ingest` 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
- "name": "border",
9
- "version": "0.1.0",
8
+ "name": "border-customs",
9
+ "version": "0.3.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@secretlint/core": "13.0.5",
@@ -14,6 +14,7 @@
14
14
  "@secretlint/secretlint-rule-no-homedir": "13.0.5",
15
15
  "@secretlint/secretlint-rule-pattern": "13.0.5",
16
16
  "@secretlint/secretlint-rule-preset-recommend": "13.0.5",
17
+ "publint": "0.3.24",
17
18
  "yaml": "2.9.0",
18
19
  "zod": "4.5.4"
19
20
  },
@@ -23,7 +24,6 @@
23
24
  "devDependencies": {
24
25
  "@types/node": "22.19.0",
25
26
  "esbuild": "0.25.12",
26
- "publint": "0.3.24",
27
27
  "secretlint": "13.0.5",
28
28
  "typescript": "5.9.3",
29
29
  "verdaccio": "6.10.2"
@@ -601,7 +601,6 @@
601
601
  "version": "0.1.7",
602
602
  "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.7.tgz",
603
603
  "integrity": "sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==",
604
- "dev": true,
605
604
  "license": "MIT",
606
605
  "dependencies": {
607
606
  "tinyexec": "^1.3.0"
@@ -3821,7 +3820,6 @@
3821
3820
  "version": "1.2.0",
3822
3821
  "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
3823
3822
  "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
3824
- "dev": true,
3825
3823
  "license": "MIT",
3826
3824
  "engines": {
3827
3825
  "node": ">=4"
@@ -3982,7 +3980,6 @@
3982
3980
  "version": "1.8.0",
3983
3981
  "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
3984
3982
  "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
3985
- "dev": true,
3986
3983
  "license": "MIT"
3987
3984
  },
3988
3985
  "node_modules/pako": {
@@ -4073,7 +4070,6 @@
4073
4070
  "version": "1.1.1",
4074
4071
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
4075
4072
  "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
4076
- "dev": true,
4077
4073
  "license": "ISC"
4078
4074
  },
4079
4075
  "node_modules/picomatch": {
@@ -4255,7 +4251,6 @@
4255
4251
  "version": "0.3.24",
4256
4252
  "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.24.tgz",
4257
4253
  "integrity": "sha512-9zS56KrKBoqi5Qt8h92uMP8TTM9AYZSgnmCo4u2priMqkOZvQnTsziZ2p5LJ2ywbYkAjoCDp2jda9u4cgFefIw==",
4258
- "dev": true,
4259
4254
  "license": "MIT",
4260
4255
  "dependencies": {
4261
4256
  "@publint/pack": "^0.1.7",
@@ -4551,7 +4546,6 @@
4551
4546
  "version": "1.8.1",
4552
4547
  "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
4553
4548
  "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
4554
- "dev": true,
4555
4549
  "license": "MIT",
4556
4550
  "dependencies": {
4557
4551
  "mri": "^1.1.0"
@@ -5231,7 +5225,6 @@
5231
5225
  "version": "1.3.1",
5232
5226
  "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
5233
5227
  "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
5234
- "dev": true,
5235
5228
  "license": "MIT",
5236
5229
  "engines": {
5237
5230
  "node": ">=18"
package/dist/index.js CHANGED
@@ -13028,7 +13028,8 @@ var require_micromatch = __commonJS({
13028
13028
  });
13029
13029
 
13030
13030
  // src/index.ts
13031
- import { pathToFileURL } from "node:url";
13031
+ import { realpathSync } from "node:fs";
13032
+ import { fileURLToPath as fileURLToPath5, pathToFileURL } from "node:url";
13032
13033
 
13033
13034
  // src/cli.ts
13034
13035
  import { parseArgs } from "node:util";
@@ -37896,10 +37897,14 @@ var NPM_TARGET_LABEL = "artifact";
37896
37897
  var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
37897
37898
  var MESSAGE_TAIL_CHARS = 600;
37898
37899
  var HERE = dirname3(fileURLToPath3(import.meta.url));
37899
- var PUBLINT_LOCAL_BINS = [
37900
- join6(HERE, "..", "node_modules", ".bin", "publint"),
37901
- join6(HERE, "..", "..", "node_modules", ".bin", "publint")
37902
- ];
37900
+ function publintLocalBins(here) {
37901
+ return [
37902
+ join6(here, "..", "node_modules", ".bin", "publint"),
37903
+ join6(here, "..", "..", "node_modules", ".bin", "publint"),
37904
+ join6(here, "..", "..", ".bin", "publint")
37905
+ ];
37906
+ }
37907
+ var PUBLINT_LOCAL_BINS = publintLocalBins(HERE);
37903
37908
  function tail(text) {
37904
37909
  const trimmed = text.trim();
37905
37910
  return trimmed.length > MESSAGE_TAIL_CHARS ? `${trimmed.slice(-MESSAGE_TAIL_CHARS)}\u2026` : trimmed;
@@ -39839,6 +39844,20 @@ function buildPushRecord(i) {
39839
39844
  ts: i.ts ?? (/* @__PURE__ */ new Date()).toISOString()
39840
39845
  };
39841
39846
  }
39847
+ function buildRoundtripRecord(i) {
39848
+ if (!HEX64.test(i.artifactSha256)) bad("artifactSha256 is not hex-shaped");
39849
+ if (!HEX64.test(i.rulesHash)) bad("rulesHash is not hex-shaped");
39850
+ if (i.verdict !== "clean" && i.verdict !== "residue") bad(`verdict must be clean or residue, got '${i.verdict}'`);
39851
+ if (!Number.isInteger(i.rows) || i.rows < 0) bad("rows must be a non-negative integer");
39852
+ return {
39853
+ t: "roundtrip",
39854
+ artifactSha256: i.artifactSha256,
39855
+ verdict: i.verdict,
39856
+ rulesHash: i.rulesHash,
39857
+ rows: i.rows,
39858
+ ts: i.ts ?? (/* @__PURE__ */ new Date()).toISOString()
39859
+ };
39860
+ }
39842
39861
  function bad(msg) {
39843
39862
  throw new Error(msg);
39844
39863
  }
@@ -39929,6 +39948,20 @@ function parseLedgerRecord(value) {
39929
39948
  ts: needString(o, "ts")
39930
39949
  };
39931
39950
  }
39951
+ if (t === "roundtrip") {
39952
+ const verdict = needString(o, "verdict");
39953
+ if (verdict !== "clean" && verdict !== "residue") bad(`verdict must be clean or residue, got '${verdict}'`);
39954
+ const rows = o.rows;
39955
+ if (typeof rows !== "number" || !Number.isInteger(rows) || rows < 0) bad("rows must be a non-negative integer");
39956
+ return {
39957
+ t: "roundtrip",
39958
+ artifactSha256: needHex(o, "artifactSha256", HEX64),
39959
+ verdict,
39960
+ rulesHash: needHex(o, "rulesHash", HEX64),
39961
+ rows,
39962
+ ts: needString(o, "ts")
39963
+ };
39964
+ }
39932
39965
  return bad(`unknown record type ${JSON.stringify(t)}`);
39933
39966
  }
39934
39967
  function codeOf2(err) {
@@ -39981,6 +40014,13 @@ function latestPassCoveringTargets(records, key, targets) {
39981
40014
  function pushRecords(records, key) {
39982
40015
  return records.filter((r) => r.t === "push" && (key === void 0 || r.key === key));
39983
40016
  }
40017
+ function latestRoundtripForSha(records, artifactSha256) {
40018
+ for (let i = records.length - 1; i >= 0; i -= 1) {
40019
+ const r = records[i];
40020
+ if (r !== void 0 && r.t === "roundtrip" && r.artifactSha256 === artifactSha256) return r;
40021
+ }
40022
+ return null;
40023
+ }
39984
40024
 
39985
40025
  // src/ledger/retention.ts
39986
40026
  import { existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync5, rmSync as rmSync6 } from "node:fs";
@@ -41950,8 +41990,17 @@ function buildBorderConfigSchema() {
41950
41990
  // of the residue scan is this toggle; a scan that cannot RUN is a FAIL
41951
41991
  // (fail-closed doctrine), and the digest seam keeps every cached PASS
41952
41992
  // honest about the residue sources it was certified against.
41993
+ // W2.2 (border-inspect-roadmap): requireProof arms the PROOF VALVE — a
41994
+ // blocking residue-* row on a staged artifact then additionally demands a
41995
+ // fresh `t:"roundtrip"` ledger record for that artifact's sha256 before
41996
+ // the channel may PASS. Default FALSE (0.3.x behavior byte-identical);
41997
+ // the valve reads `residue?.requireProof === true`, so absence IS the
41998
+ // false default — same construction contract as the outer key. The flag
41999
+ // rides the whole-object configDigest (stableStringify), so flipping it
42000
+ // rotates the check rulesHash and the ledger refuses every cached PASS.
41953
42001
  residue: external_exports.object({
41954
- enabled: external_exports.boolean().default(true)
42002
+ enabled: external_exports.boolean().default(true),
42003
+ requireProof: external_exports.boolean().default(false)
41955
42004
  }).strict().optional()
41956
42005
  }).strict();
41957
42006
  }
@@ -42402,6 +42451,44 @@ function applyAllowList(findings, allow, repoDir) {
42402
42451
  return { kept, allowHits: [...hits.values()] };
42403
42452
  }
42404
42453
 
42454
+ // src/check/proofValve.ts
42455
+ var ROUNDTRIP_PROOF_MISSING_RULE = "roundtrip-proof-missing";
42456
+ var ROUNDTRIP_PROOF_STALE_RULE = "roundtrip-proof-stale";
42457
+ var RESIDUE_RULE_IDS = new Set(Object.keys(RESIDUE_SEVERITIES));
42458
+ function hasBlockingResidueCapability(findings) {
42459
+ return findings.some((f) => RESIDUE_RULE_IDS.has(f.rule) && isBlocking(f.severity));
42460
+ }
42461
+ function proofFinding(rule, art, cause) {
42462
+ return {
42463
+ rule,
42464
+ severity: "CRITICAL",
42465
+ target: "artifact",
42466
+ path: art.file,
42467
+ engine: "native",
42468
+ message: `${art.file} (sha256 ${art.sha256.slice(0, 12)}\u2026) tripped the residue scan while residue.requireProof is on, but ${cause}. Run 'border roundtrip <spec>' for exactly these bytes to mint the ledger proof.`,
42469
+ ...redact(`${art.sha256}:${cause}`),
42470
+ snippet: `${art.file} ${art.sha256.slice(0, 12)}`
42471
+ };
42472
+ }
42473
+ function proofFindings(o) {
42474
+ const out = [];
42475
+ for (const art of o.artifacts) {
42476
+ const rec = latestRoundtripForSha(o.records, art.sha256);
42477
+ if (rec === null) {
42478
+ out.push(proofFinding(ROUNDTRIP_PROOF_MISSING_RULE, art, "no roundtrip proof record exists for its digest"));
42479
+ } else if (rec.rulesHash !== o.rulesHash) {
42480
+ out.push(
42481
+ proofFinding(
42482
+ ROUNDTRIP_PROOF_STALE_RULE,
42483
+ art,
42484
+ `its proof record predates the current rules policy (recorded ${rec.rulesHash.slice(0, 12)}\u2026, required ${o.rulesHash.slice(0, 12)}\u2026)`
42485
+ )
42486
+ );
42487
+ }
42488
+ }
42489
+ return out;
42490
+ }
42491
+
42405
42492
  // src/check/tagScan.ts
42406
42493
  import { spawnSync as spawnSync12 } from "node:child_process";
42407
42494
  import { mkdtempSync as mkdtempSync3, rmSync as rmSync10, writeFileSync as writeFileSync4 } from "node:fs";
@@ -42518,7 +42605,6 @@ function scanTagMessages(o) {
42518
42605
 
42519
42606
  // src/check.ts
42520
42607
  var TRACKED_BORDER_RULE = "repo-tracks-border-state";
42521
- var RESIDUE_RULE_IDS = new Set(Object.keys(RESIDUE_SEVERITIES));
42522
42608
  var GUARD_PATH_CAP = 50;
42523
42609
  function trackedBorderStateFinding(repoDir, headSha, env2) {
42524
42610
  const listing = runGitChecked(repoDir, ["ls-files", "--", `${BORDER_STATE_DIR}/`], { ...env2 !== void 0 ? { env: env2 } : {} }).split("\n").filter((p) => p !== "");
@@ -42607,13 +42693,16 @@ async function runPipeline(o, ctx, lockWarning) {
42607
42693
  if (f !== void 0 && RESIDUE_RULE_IDS.has(f.rule)) findings.splice(i, 1);
42608
42694
  }
42609
42695
  }
42610
- const exposure = [...exposureSet(o.cfg, { cwd: repoDir })];
42611
- const allow = applyAllowList(findings, o.cfg.allow, repoDir);
42612
42696
  const rulesHash = await computeCheckRulesHash({
42613
42697
  engineVersions: probe.engineVersions,
42614
42698
  configDigest: o.configDigest,
42615
42699
  ...o.env !== void 0 ? { env: o.env } : {}
42616
42700
  });
42701
+ if (o.cfg.residue?.requireProof === true && ledgerArtifacts.length > 0 && hasBlockingResidueCapability(findings)) {
42702
+ findings.push(...proofFindings({ artifacts: ledgerArtifacts, records: readLedger(repoDir).records, rulesHash }));
42703
+ }
42704
+ const exposure = [...exposureSet(o.cfg, { cwd: repoDir })];
42705
+ const allow = applyAllowList(findings, o.cfg.allow, repoDir);
42617
42706
  const report = {
42618
42707
  schemaVersion: 1,
42619
42708
  key: computeCheckKey({
@@ -43431,41 +43520,18 @@ async function executeYesPush(ctx, loaded) {
43431
43520
  return EXIT_PASS;
43432
43521
  }
43433
43522
 
43434
- // src/scan/scan.ts
43435
- import { mkdtempSync as mkdtempSync5 } from "node:fs";
43523
+ // src/roundtrip/orchestrate.ts
43524
+ import { mkdtempSync as mkdtempSync5, rmSync as rmSync12, writeFileSync as writeFileSync7 } from "node:fs";
43525
+ import { randomBytes } from "node:crypto";
43436
43526
  import { tmpdir as tmpdir3 } from "node:os";
43437
- import { join as join26 } from "node:path";
43438
-
43439
- // src/scan/config.ts
43440
- function buildScanConfigYaml(eco) {
43441
- return [
43442
- "version: 1",
43443
- "targets:",
43444
- " git:",
43445
- " remotes: []",
43446
- ` ${eco}: {}`,
43447
- "rules:",
43448
- " authors:",
43449
- " emails: []",
43450
- " names: []",
43451
- " hosts: []",
43452
- " ips: []",
43453
- " pathPatterns: []",
43454
- "residue:",
43455
- " enabled: true",
43456
- ""
43457
- ].join("\n");
43458
- }
43459
- function synthesizeScanConfig(eco) {
43460
- return parseConfig(buildScanConfigYaml(eco), "<border scan>");
43461
- }
43527
+ import { join as join25 } from "node:path";
43462
43528
 
43463
43529
  // src/scan/fetch.ts
43464
43530
  import { createHash as createHash15 } from "node:crypto";
43465
43531
  import { basename as basename4 } from "node:path";
43466
43532
  var SCAN_MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
43467
43533
  var SCAN_REQUEST_TIMEOUT_MS = 6e4;
43468
- var SCAN_USER_AGENT = "border-customs/0.3.0 (+https://github.com/sumteclab/border; border scan)";
43534
+ var SCAN_USER_AGENT = "border-customs/0.4.0 (+https://github.com/sumteclab/border; border scan)";
43469
43535
  var CRATES_DOWNLOAD_HOST = "https://static.crates.io";
43470
43536
  var defaultScanFetcher = async (url2, init) => {
43471
43537
  if (!/^https?:\/\//.test(url2)) throw new EngineRunError(`border scan refuses non-http(s) URL: ${url2}`, null);
@@ -43594,9 +43660,402 @@ function sha256Hex(data) {
43594
43660
  return createHash15("sha256").update(data).digest("hex");
43595
43661
  }
43596
43662
 
43663
+ // src/scan/spec.ts
43664
+ var ECOSYSTEMS = ["npm", "pypi", "crates", "rubygems"];
43665
+ var SCAN_SPEC_USAGE = "usage: border scan <[ecosystem:]name@version> \u2014 e.g. border scan puppeteer@23.11.1 | pypi:requests@2.32.3 | crates:serde@1.0.219 | rubygems:rails@7.1.1";
43666
+ var PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
43667
+ var SCOPED_NAME = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
43668
+ var VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
43669
+ var bad2 = (why) => {
43670
+ throw new UnknownArgError(`${why}; ${SCAN_SPEC_USAGE}`);
43671
+ };
43672
+ function parseScanSpec(raw) {
43673
+ const text = raw.trim();
43674
+ if (text.length === 0) return bad2("empty spec");
43675
+ let ecosystem = "npm";
43676
+ let rest = text;
43677
+ const colon = text.indexOf(":");
43678
+ if (colon >= 0) {
43679
+ const prefix = text.slice(0, colon);
43680
+ const found = ECOSYSTEMS.find((e) => e === prefix);
43681
+ if (found === void 0) {
43682
+ return bad2(`unknown ecosystem '${prefix}' \u2014 expected one of: ${ECOSYSTEMS.join(", ")}`);
43683
+ }
43684
+ ecosystem = found;
43685
+ rest = text.slice(colon + 1);
43686
+ if (rest.length === 0) return bad2("missing package name after the ecosystem prefix");
43687
+ }
43688
+ const scoped = rest.startsWith("@");
43689
+ const at = rest.lastIndexOf("@");
43690
+ if (at <= 0) return bad2(`spec '${raw}' is missing '@version'`);
43691
+ const name = rest.slice(0, at);
43692
+ const version2 = rest.slice(at + 1);
43693
+ if (!(scoped ? SCOPED_NAME : PLAIN_NAME).test(name)) {
43694
+ return bad2(`invalid package name '${name}'`);
43695
+ }
43696
+ if (!VERSION.test(version2)) {
43697
+ return bad2(`invalid version '${version2}' in spec '${raw}'`);
43698
+ }
43699
+ return { ecosystem, name, version: version2 };
43700
+ }
43701
+
43702
+ // src/roundtrip/docker.ts
43703
+ var DOCKER_PROBE_TIMEOUT_MS = 15e3;
43704
+ var DOCKER_RUN_TIMEOUT_MS = 12e4;
43705
+ var DOCKER_CP_TIMEOUT_MS = 12e4;
43706
+ var SNAPSHOT_TIMEOUT_MS = 12e4;
43707
+ var PHASE_TIMEOUT_MS = 6e5;
43708
+ var DOCKER_RM_TIMEOUT_MS = 6e4;
43709
+ var CONTAINER_TTL_SEC = 900;
43710
+ var BASE_EXCLUDES = [
43711
+ "/proc",
43712
+ "/sys",
43713
+ "/dev",
43714
+ "/root/.npm",
43715
+ "/tmp/node-compile-cache",
43716
+ "/etc/hostname",
43717
+ "/etc/resolv.conf",
43718
+ "/etc/hosts"
43719
+ ];
43720
+ var PYTHON_EXCLUDES = ["/root/.cache", "/var/log", "/var/lib/apt/lists", "*/__pycache__"];
43721
+ var CARGO_EXCLUDES = ["/root/.cargo", "/usr/local/cargo", "/usr/local/.crates2.json", "/usr/local/.crates.toml"];
43722
+ function snapshotScript(excludes) {
43723
+ const prune = `"(" ${excludes.map((p) => `-path "${p}"`).join(" -o ")} ")" -prune -o`;
43724
+ return [
43725
+ "set -e",
43726
+ 'T=/dev/shm/rt-snap.$$; mkdir -p "$T"',
43727
+ `find / -xdev ${prune} -type f -print0 > "$T/f0"`,
43728
+ // || true: a file can vanish between find passes (package managers churn
43729
+ // temp state); the missing row is consistent noise, not a false residue.
43730
+ `xargs -0 -r sha256sum < "$T/f0" 2>/dev/null > "$T/hashes" || true`,
43731
+ // sha256sum text mode = 64 hex + 2 spaces + path; substr keeps spaced paths whole.
43732
+ `awk '{ printf "F\\t%s\\t%s\\n", substr($0,67), substr($0,1,64) }' "$T/hashes" > "$T/files"`,
43733
+ `find / -xdev ${prune} -type l -printf 'L\\t%p\\t%l\\n' > "$T/links"`,
43734
+ `find / -xdev ${prune} -type d -printf 'D\\t%p\\t-\\n' > "$T/dirs"`,
43735
+ 'LC_ALL=C sort "$T/files" "$T/links" "$T/dirs"',
43736
+ 'rm -rf "$T"'
43737
+ ].join("\n");
43738
+ }
43739
+ function realDockerExec() {
43740
+ return (args, timeoutMs) => {
43741
+ const r = spawnEngine(binaryCandidates("docker", {}), [...args], { timeoutMs });
43742
+ return { status: r.status, stdout: r.stdout, stderr: r.stderr };
43743
+ };
43744
+ }
43745
+
43746
+ // src/roundtrip/manifest.ts
43747
+ import { createHash as createHash16 } from "node:crypto";
43748
+ var RT_ENGINE = "roundtrip";
43749
+ var RT_RESIDUE_RULE = "roundtrip-residue-file";
43750
+ var RT_MODIFY_RULE = "roundtrip-modified-file";
43751
+ var RT_PERSIST_RULE = "roundtrip-modified-persistence";
43752
+ var KEY_SEP = "";
43753
+ var keyOf = (kind, path4) => `${kind}${KEY_SEP}${path4}`;
43754
+ function parseManifest(text) {
43755
+ const out = /* @__PURE__ */ new Map();
43756
+ for (const line of text.split("\n")) {
43757
+ if (line.length === 0) continue;
43758
+ const parts = line.split(" ");
43759
+ if (parts.length < 3) continue;
43760
+ const kind = parts[0];
43761
+ const path4 = parts[1];
43762
+ const value = parts.slice(2).join(" ");
43763
+ if (kind !== "F" && kind !== "L" && kind !== "D") continue;
43764
+ out.set(keyOf(kind, path4), { kind, path: path4, value });
43765
+ }
43766
+ return out;
43767
+ }
43768
+ function diffManifests(before, after) {
43769
+ const added = [];
43770
+ const removed = [];
43771
+ const modified = [];
43772
+ for (const [key, entry] of after) {
43773
+ const old = before.get(key);
43774
+ if (old === void 0) added.push(entry);
43775
+ else if (old.value !== entry.value) modified.push(entry);
43776
+ }
43777
+ for (const [key, entry] of before) {
43778
+ if (!after.has(key)) removed.push(entry);
43779
+ }
43780
+ return { added, removed, modified };
43781
+ }
43782
+ var PERSISTENCE_PATTERNS = [
43783
+ /(^|\/)\.(bashrc|bash_profile|bash_login|profile|zshrc|zprofile|zshenv|kshrc|cshrc|login\.sh)$/,
43784
+ /^\/etc\/profile\.d\//,
43785
+ /^\/etc\/(bash\.bashrc|profile|environment|zsh\/zshrc|inputrc)$/,
43786
+ /^\/etc\/systemd\//,
43787
+ /(^|\/)\.config\/systemd\//,
43788
+ /(^|\/)systemd\/system\//,
43789
+ /(^|\/)(cron|cron\.d|cron\.[0-9dhmwy]+|spool\/cron)\//,
43790
+ /^\/etc\/crontab$/,
43791
+ /(^|\/)\.config\/fish\//,
43792
+ /^\/(usr\/)?(local\/)?(s?bin)\//,
43793
+ /(^|\/)\.local\/bin\//
43794
+ ];
43795
+ function isPersistenceSurface(path4) {
43796
+ return PERSISTENCE_PATTERNS.some((re) => re.test(path4));
43797
+ }
43798
+ var digestOf = (what) => createHash16("sha256").update(what).digest("hex");
43799
+ function classifyResidue(label, diff) {
43800
+ const findings = [];
43801
+ for (const e of diff.added) {
43802
+ findings.push({
43803
+ rule: RT_RESIDUE_RULE,
43804
+ severity: "HIGH",
43805
+ target: label,
43806
+ path: e.path,
43807
+ engine: RT_ENGINE,
43808
+ message: `installed file survived uninstall (ADDED in post-uninstall manifest)`,
43809
+ valueDigest: digestOf(`ADDED:${e.path}:${e.value}`),
43810
+ snippet: `residue: ${e.path}`
43811
+ });
43812
+ }
43813
+ for (const e of diff.modified) {
43814
+ const persist = isPersistenceSurface(e.path);
43815
+ findings.push({
43816
+ rule: persist ? RT_PERSIST_RULE : RT_MODIFY_RULE,
43817
+ severity: persist ? "CRITICAL" : "HIGH",
43818
+ target: label,
43819
+ path: e.path,
43820
+ engine: RT_ENGINE,
43821
+ message: persist ? `uninstall mutated a persistence surface (rc/systemd/cron/PATH) \u2014 content differs pre-install vs post-uninstall` : `file content differs pre-install vs post-uninstall (MODIFIED, not reverted by uninstall)`,
43822
+ valueDigest: digestOf(`MODIFIED:${e.path}:${e.value}`),
43823
+ snippet: persist ? `persistence: ${e.path}` : `modified: ${e.path}`
43824
+ });
43825
+ }
43826
+ return findings;
43827
+ }
43828
+
43829
+ // src/roundtrip/orchestrate.ts
43830
+ function containerNameFor(label, rand = randomBytes(3).toString("hex")) {
43831
+ return `rt-${sha256Hex(label).slice(0, 8)}-${rand}`;
43832
+ }
43833
+ var shArgv = (name) => {
43834
+ if (!/^[A-Za-z0-9@._+/-]+$/.test(name)) {
43835
+ throw new EngineRunError(`roundtrip: package name '${name}' rejected for shell composition`, null);
43836
+ }
43837
+ return [name];
43838
+ };
43839
+ var PROFILES = {
43840
+ npm: {
43841
+ image: "node:24-slim",
43842
+ artifactPath: "/root/p.tgz",
43843
+ excludes: BASE_EXCLUDES,
43844
+ install: (p) => ["npm", "install", "-g", "--foreground-scripts", "--no-audit", "--no-fund", p],
43845
+ uninstall: (s) => ["npm", "uninstall", "-g", shArgv(s.name)[0]]
43846
+ },
43847
+ pypi: {
43848
+ image: "python:3.12-slim",
43849
+ artifactPath: "/root/p.tar.gz",
43850
+ excludes: [...BASE_EXCLUDES, ...PYTHON_EXCLUDES],
43851
+ install: (p) => ["pip", "install", "--no-input", "--root-user-action=ignore", p],
43852
+ uninstall: (s) => ["pip", "uninstall", "-y", shArgv(s.name)[0]]
43853
+ },
43854
+ rubygems: {
43855
+ image: "ruby:3.4-slim",
43856
+ artifactPath: "/root/p.gem",
43857
+ excludes: BASE_EXCLUDES,
43858
+ install: (p) => ["gem", "install", "--no-document", p],
43859
+ uninstall: (s) => ["gem", "uninstall", shArgv(s.name)[0], "-x", "--force"]
43860
+ },
43861
+ crates: {
43862
+ image: "rust:1-slim",
43863
+ artifactPath: "/root/p.crate",
43864
+ excludes: [...BASE_EXCLUDES, ...CARGO_EXCLUDES],
43865
+ // W2.1 docker legs, verified on rust:1-slim / cargo 1.98: a bare positional
43866
+ // is parsed as a CRATE@VER spec (a directory 400s on "invalid character `/`"),
43867
+ // so the unpacked staging dir goes through --path; --no-track must NOT be
43868
+ // used because it suppresses the $ROOT/.crates2.json install record, which is
43869
+ // the only thing `cargo uninstall` reads (with it, every uninstall dies on
43870
+ // "package ID specification did not match"); and border's own /opt/<dir>
43871
+ // staging tree is never owned by cargo, so the chain that creates it removes
43872
+ // it — after `cargo install` succeeded, keeping install failure fail-closed.
43873
+ install: (p, s) => {
43874
+ const name = shArgv(s.name)[0];
43875
+ const dir = `${name}-${s.version}`;
43876
+ return ["sh", "-c", `tar xf ${p} -C /opt && cargo install --root /usr/local --path "/opt/${dir}" && rm -rf "/opt/${dir}"`];
43877
+ },
43878
+ uninstall: (s) => ["cargo", "uninstall", "--root", "/usr/local", shArgv(s.name)[0]]
43879
+ }
43880
+ };
43881
+ function profileFor(eco) {
43882
+ return PROFILES[eco];
43883
+ }
43884
+ function execChecked(exec, args, timeoutMs, what) {
43885
+ const r = exec(args, timeoutMs);
43886
+ if (r.status !== 0) {
43887
+ const tail3 = r.stderr.replace(/\s+/g, " ").slice(-240);
43888
+ throw new EngineRunError(`roundtrip: ${what} failed (exit ${String(r.status)})${tail3 ? `: ${tail3}` : ""} \u2014 cannot verify (fail-closed)`, r.status);
43889
+ }
43890
+ return r;
43891
+ }
43892
+ function runPipeline2(exec, profile, spec, label, hostPath, container) {
43893
+ execChecked(exec, ["run", "-d", "--name", container, profile.image, "sleep", String(CONTAINER_TTL_SEC)], DOCKER_RUN_TIMEOUT_MS, "container start");
43894
+ try {
43895
+ execChecked(exec, ["cp", hostPath, `${container}:${profile.artifactPath}`], DOCKER_CP_TIMEOUT_MS, "artifact copy");
43896
+ const m1 = parseManifest(takeSnapshot(exec, container, profile.excludes));
43897
+ execChecked(exec, ["exec", container, ...profile.install(profile.artifactPath, spec)], PHASE_TIMEOUT_MS, `install ${label}`);
43898
+ const m2 = parseManifest(takeSnapshot(exec, container, profile.excludes));
43899
+ const installDiff = diffManifests(m1, m2);
43900
+ const installDelta = installDiff.added.length + installDiff.removed.length + installDiff.modified.length;
43901
+ if (installDelta === 0) {
43902
+ throw new EngineRunError(
43903
+ `roundtrip: install of ${label} produced an empty filesystem delta \u2014 cannot verify it ran, refusing to grade a no-op`,
43904
+ null
43905
+ );
43906
+ }
43907
+ execChecked(exec, ["exec", container, ...profile.uninstall(spec)], PHASE_TIMEOUT_MS, `uninstall ${label}`);
43908
+ const m3 = parseManifest(takeSnapshot(exec, container, profile.excludes));
43909
+ return {
43910
+ findings: classifyResidue(label, diffManifests(m1, m3)),
43911
+ installDelta,
43912
+ baselineDigest: sha256Hex([...m1.keys()].join("\n"))
43913
+ };
43914
+ } finally {
43915
+ try {
43916
+ exec(["rm", "-f", container], DOCKER_RM_TIMEOUT_MS);
43917
+ } catch {
43918
+ }
43919
+ }
43920
+ }
43921
+ function takeSnapshot(exec, container, excludes) {
43922
+ return execChecked(
43923
+ exec,
43924
+ ["exec", container, "sh", "-c", snapshotScript(excludes)],
43925
+ SNAPSHOT_TIMEOUT_MS,
43926
+ "manifest snapshot"
43927
+ ).stdout;
43928
+ }
43929
+ async function runRoundtripCore(ctx, deps = {}) {
43930
+ const raw = ctx.positionals[0];
43931
+ if (ctx.positionals.length !== 1 || raw === void 0) {
43932
+ throw new UnknownArgError(
43933
+ `border roundtrip expects exactly one <spec> argument (got ${String(ctx.positionals.length)}); ${SCAN_SPEC_USAGE}`
43934
+ );
43935
+ }
43936
+ const spec = parseScanSpec(raw);
43937
+ const profile = profileFor(spec.ecosystem);
43938
+ const label = `${spec.ecosystem}:${spec.name}@${spec.version}`;
43939
+ const exec = deps.exec ?? realDockerExec();
43940
+ if (!dockerAvailableGuard(exec)) {
43941
+ ctx.stderr("roundtrip: docker unavailable \u2014 cannot verify");
43942
+ return EXIT_ERROR;
43943
+ }
43944
+ const baseDir = mkdtempSync5(join25(tmpdir3(), "border-roundtrip-"));
43945
+ try {
43946
+ const { bytes } = await fetchArtifact(spec, { fetcher: deps.fetcher ?? defaultScanFetcher });
43947
+ const hostPath = join25(baseDir, "artifact");
43948
+ writeFileSync7(hostPath, bytes);
43949
+ const container = containerNameFor(label);
43950
+ const { findings, installDelta, baselineDigest } = runPipeline2(exec, profile, spec, label, hostPath, container);
43951
+ const verdict = computeVerdict(findings);
43952
+ const report = {
43953
+ schemaVersion: 1,
43954
+ key: sha256Hex(`roundtrip:${label}:${baselineDigest}:${verdict}`),
43955
+ head: baselineDigest.slice(0, 40),
43956
+ dirty: false,
43957
+ exposureSet: [label],
43958
+ refSet: [],
43959
+ rulesHash: sha256Hex(`${profile.image}|${profile.install(profile.artifactPath, spec).join(" ")}|${profile.uninstall(spec).join(" ")}`),
43960
+ verdict,
43961
+ counts: countFindings(findings),
43962
+ findings,
43963
+ ts: (/* @__PURE__ */ new Date()).toISOString()
43964
+ };
43965
+ const sanitizer = new TextSanitizer();
43966
+ const line = (text) => sanitizer.sanitize(text).replace(/\s+/g, " ").trim();
43967
+ if (ctx.flags.json) {
43968
+ ctx.stdout(renderReportJson(report));
43969
+ } else {
43970
+ for (const f of report.findings) {
43971
+ ctx.stdout(line(` ${f.severity} ${f.rule} [${f.engine}] ${f.path ?? f.target} ${f.message}`));
43972
+ }
43973
+ ctx.stdout(line(`roundtrip: ${String(report.findings.length)} residue row(s); install-delta ${String(installDelta)} files`));
43974
+ }
43975
+ if (ctx.flags.record !== false) {
43976
+ await recordRoundtripProof(ctx, sha256Hex(bytes), report.findings.length, report.findings.length === 0 ? "clean" : "residue");
43977
+ }
43978
+ return exitCodeFromVerdict(verdict);
43979
+ } finally {
43980
+ rmSync12(baseDir, { recursive: true, force: true });
43981
+ }
43982
+ }
43983
+ async function recordRoundtripProof(ctx, artifactSha256, rows, verdict) {
43984
+ const notice = (msg) => ctx.stderr(`roundtrip: ${msg}`);
43985
+ try {
43986
+ const env2 = ctx.env;
43987
+ const load = loadConfig({
43988
+ cwd: ctx.cwd,
43989
+ ...ctx.flags.config !== void 0 ? { configPath: ctx.flags.config } : {},
43990
+ ...env2 !== void 0 ? { env: env2 } : {}
43991
+ });
43992
+ let effective = null;
43993
+ if (load.kind === "loaded") effective = load;
43994
+ else if (load.explicit !== void 0) effective = { kind: "loaded", config: load.explicit.config, warnings: [], source: load.explicit.source };
43995
+ if (effective === null) {
43996
+ notice("no border.yaml here \u2014 proof NOT recorded");
43997
+ return;
43998
+ }
43999
+ const repoDir = resolveRepoDir(ctx.cwd, { ...env2 !== void 0 ? { env: env2 } : {} });
44000
+ const probe = await probeEngines(effective.config, { ...env2 !== void 0 ? { env: env2 } : {} });
44001
+ if (probe.degraded) {
44002
+ notice("engine probe degraded \u2014 cannot mint the rulesHash this proof must match; proof NOT recorded");
44003
+ return;
44004
+ }
44005
+ const rulesHash = await computeCheckRulesHash({
44006
+ engineVersions: probe.engineVersions,
44007
+ configDigest: computeConfigDigest(effective),
44008
+ ...env2 !== void 0 ? { env: env2 } : {}
44009
+ });
44010
+ appendRecord(repoDir, buildRoundtripRecord({ artifactSha256, verdict, rulesHash, rows }));
44011
+ notice(`proof recorded for ${artifactSha256.slice(0, 12)}\u2026 (verdict ${verdict}, ${String(rows)} residue row(s))`);
44012
+ } catch (err) {
44013
+ notice(`proof NOT recorded (${err instanceof Error ? err.message : String(err)}) \u2014 'border check' with residue.requireProof will treat this artifact as unproven`);
44014
+ }
44015
+ }
44016
+ function dockerAvailableGuard(exec) {
44017
+ try {
44018
+ return exec(["--version"], DOCKER_PROBE_TIMEOUT_MS).status === 0;
44019
+ } catch {
44020
+ return false;
44021
+ }
44022
+ }
44023
+
44024
+ // src/commands/roundtrip.ts
44025
+ var runRoundtrip = (ctx) => runRoundtripCore(ctx);
44026
+
44027
+ // src/scan/scan.ts
44028
+ import { mkdtempSync as mkdtempSync6 } from "node:fs";
44029
+ import { tmpdir as tmpdir4 } from "node:os";
44030
+ import { join as join27 } from "node:path";
44031
+
44032
+ // src/scan/config.ts
44033
+ function buildScanConfigYaml(eco) {
44034
+ return [
44035
+ "version: 1",
44036
+ "targets:",
44037
+ " git:",
44038
+ " remotes: []",
44039
+ ` ${eco}: {}`,
44040
+ "rules:",
44041
+ " authors:",
44042
+ " emails: []",
44043
+ " names: []",
44044
+ " hosts: []",
44045
+ " ips: []",
44046
+ " pathPatterns: []",
44047
+ "residue:",
44048
+ " enabled: true",
44049
+ ""
44050
+ ].join("\n");
44051
+ }
44052
+ function synthesizeScanConfig(eco) {
44053
+ return parseConfig(buildScanConfigYaml(eco), "<border scan>");
44054
+ }
44055
+
43597
44056
  // src/scan/materialize.ts
43598
- import { existsSync as existsSync17, mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync16, rmSync as rmSync12, writeFileSync as writeFileSync7 } from "node:fs";
43599
- import { join as join25 } from "node:path";
44057
+ import { existsSync as existsSync17, mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync16, rmSync as rmSync13, writeFileSync as writeFileSync8 } from "node:fs";
44058
+ import { join as join26 } from "node:path";
43600
44059
  var CRATES_ENVELOPE_FILES = ["Cargo.toml.orig", ".cargo-ok", ".cargo_vcs_info.json"];
43601
44060
  var fail2 = (why) => {
43602
44061
  throw new Error(`border scan cannot materialize the artifact (${why}) \u2014 refusing to stage an unusable tree (fail-closed)`);
@@ -43613,12 +44072,12 @@ function unwrapSingleDir(dir) {
43613
44072
  const entries = readdirSync10(dir, { withFileTypes: true });
43614
44073
  const dirs = entries.filter((e) => e.isDirectory());
43615
44074
  if (entries.length === 1 && dirs.length === 1 && dirs[0] !== void 0) {
43616
- return join25(dir, dirs[0].name);
44075
+ return join26(dir, dirs[0].name);
43617
44076
  }
43618
44077
  return dir;
43619
44078
  }
43620
44079
  function requireManifest(repoDir, ecosystem, candidates) {
43621
- if (!candidates.some((rel) => existsSync17(join25(repoDir, rel)))) {
44080
+ if (!candidates.some((rel) => existsSync17(join26(repoDir, rel)))) {
43622
44081
  fail2(`${ecosystem} package tree has none of [${candidates.join(", ")}] at its root`);
43623
44082
  }
43624
44083
  }
@@ -43628,18 +44087,18 @@ function materializePackage(o) {
43628
44087
  `);
43629
44088
  });
43630
44089
  const safe = safeArtifactName(o.filename, `${o.ecosystem}-artifact`);
43631
- const archive = join25(o.baseDir, safe);
43632
- writeFileSync7(archive, o.bytes);
43633
- const extractDir = join25(o.baseDir, "extract");
44090
+ const archive = join26(o.baseDir, safe);
44091
+ writeFileSync8(archive, o.bytes);
44092
+ const extractDir = join26(o.baseDir, "extract");
43634
44093
  mkdirSync11(extractDir, { recursive: true });
43635
44094
  extractFlat(archive, extractDir);
43636
44095
  let repoDir = unwrapSingleDir(extractDir);
43637
44096
  if (o.ecosystem === "rubygems") {
43638
44097
  const outer = repoDir;
43639
44098
  const dataCandidates = findNativeMissArchives(outer).filter((f) => f.endsWith("data.tar.gz"));
43640
- const inner = dataCandidates[0] ?? join25(outer, "data.tar.gz");
44099
+ const inner = dataCandidates[0] ?? join26(outer, "data.tar.gz");
43641
44100
  if (!existsSync17(inner)) fail2(".gem archive contains no data.tar.gz");
43642
- const gemTree = join25(o.baseDir, "gem-tree");
44101
+ const gemTree = join26(o.baseDir, "gem-tree");
43643
44102
  mkdirSync11(gemTree, { recursive: true });
43644
44103
  extractFlat(inner, gemTree);
43645
44104
  repoDir = unwrapSingleDir(gemTree);
@@ -43650,10 +44109,10 @@ function materializePackage(o) {
43650
44109
  } else {
43651
44110
  requireManifest(repoDir, o.ecosystem, ["Cargo.toml"]);
43652
44111
  for (const file2 of CRATES_ENVELOPE_FILES) {
43653
- const path4 = join25(repoDir, file2);
44112
+ const path4 = join26(repoDir, file2);
43654
44113
  if (!existsSync17(path4)) continue;
43655
44114
  const original = readFileSync16(path4);
43656
- rmSync12(path4, { force: true });
44115
+ rmSync13(path4, { force: true });
43657
44116
  note(`border scan: crates envelope normalized: ${file2} sha256=${sha256Hex(original)}`);
43658
44117
  }
43659
44118
  }
@@ -43665,45 +44124,6 @@ function materializePackage(o) {
43665
44124
  return repoDir;
43666
44125
  }
43667
44126
 
43668
- // src/scan/spec.ts
43669
- var ECOSYSTEMS = ["npm", "pypi", "crates", "rubygems"];
43670
- var SCAN_SPEC_USAGE = "usage: border scan <[ecosystem:]name@version> \u2014 e.g. border scan puppeteer@23.11.1 | pypi:requests@2.32.3 | crates:serde@1.0.219 | rubygems:rails@7.1.1";
43671
- var PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
43672
- var SCOPED_NAME = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
43673
- var VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
43674
- var bad2 = (why) => {
43675
- throw new UnknownArgError(`${why}; ${SCAN_SPEC_USAGE}`);
43676
- };
43677
- function parseScanSpec(raw) {
43678
- const text = raw.trim();
43679
- if (text.length === 0) return bad2("empty spec");
43680
- let ecosystem = "npm";
43681
- let rest = text;
43682
- const colon = text.indexOf(":");
43683
- if (colon >= 0) {
43684
- const prefix = text.slice(0, colon);
43685
- const found = ECOSYSTEMS.find((e) => e === prefix);
43686
- if (found === void 0) {
43687
- return bad2(`unknown ecosystem '${prefix}' \u2014 expected one of: ${ECOSYSTEMS.join(", ")}`);
43688
- }
43689
- ecosystem = found;
43690
- rest = text.slice(colon + 1);
43691
- if (rest.length === 0) return bad2("missing package name after the ecosystem prefix");
43692
- }
43693
- const scoped = rest.startsWith("@");
43694
- const at = rest.lastIndexOf("@");
43695
- if (at <= 0) return bad2(`spec '${raw}' is missing '@version'`);
43696
- const name = rest.slice(0, at);
43697
- const version2 = rest.slice(at + 1);
43698
- if (!(scoped ? SCOPED_NAME : PLAIN_NAME).test(name)) {
43699
- return bad2(`invalid package name '${name}'`);
43700
- }
43701
- if (!VERSION.test(version2)) {
43702
- return bad2(`invalid version '${version2}' in spec '${raw}'`);
43703
- }
43704
- return { ecosystem, name, version: version2 };
43705
- }
43706
-
43707
44127
  // src/scan/scan.ts
43708
44128
  async function runScanCore(ctx, deps = {}) {
43709
44129
  const raw = ctx.positionals[0];
@@ -43719,7 +44139,7 @@ async function runScanCore(ctx, deps = {}) {
43719
44139
  }
43720
44140
  const cfg = synthesizeScanConfig(spec.ecosystem);
43721
44141
  const fetcher = deps.fetcher ?? defaultScanFetcher;
43722
- const baseDir = mkdtempSync5(join26(tmpdir3(), "border-scan-"));
44142
+ const baseDir = mkdtempSync6(join27(tmpdir4(), "border-scan-"));
43723
44143
  try {
43724
44144
  const { bytes, filename } = await fetchArtifact(spec, { fetcher });
43725
44145
  const env2 = { ...ctx.env };
@@ -43843,11 +44263,12 @@ var handlers = {
43843
44263
  status: runStatus,
43844
44264
  "llm-request": runLlmRequest,
43845
44265
  "llm-ingest": runLlmIngest,
43846
- scan: runScan2
44266
+ scan: runScan2,
44267
+ roundtrip: runRoundtrip
43847
44268
  };
43848
44269
 
43849
44270
  // src/cli/types.ts
43850
- var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest", "scan"];
44271
+ var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest", "scan", "roundtrip"];
43851
44272
 
43852
44273
  // src/cli.ts
43853
44274
  var VALID_TARGETS = targetIds();
@@ -43864,6 +44285,7 @@ function usage() {
43864
44285
  " llm-request emit the provenance request for LLM-authored commits",
43865
44286
  " llm-ingest record provenance captured by the harness",
43866
44287
  " scan inspect a third-party package ([ecosystem:]name@version) for residue",
44288
+ " roundtrip install+uninstall a package in a throwaway container, report uninstall residue",
43867
44289
  "",
43868
44290
  "flags:",
43869
44291
  " --config <path> config file (default: ./border.yaml, then git-remote fallback)",
@@ -43873,11 +44295,12 @@ function usage() {
43873
44295
  " --require-engine <list> comma-separated engines that must be healthy (else exit 2)",
43874
44296
  " --llm include the optional LLM review layer",
43875
44297
  " --json machine-readable report on stdout",
44298
+ " --record / --no-record `border roundtrip` records a ledger proof by default; --no-record opts out",
43876
44299
  " --help, -h this table",
43877
44300
  "",
43878
44301
  "exit codes: 0 pass (MEDIUM/INFO/LOW allowed) | 1 gate-blocked (CRITICAL/HIGH) or partial push | 2 config/tool error",
43879
44302
  "",
43880
- "subcommands: check, push, status, llm-request, llm-ingest, scan"
44303
+ "subcommands: check, push, status, llm-request, llm-ingest, scan, roundtrip"
43881
44304
  ].join("\n");
43882
44305
  }
43883
44306
  function isSubcommand(value) {
@@ -43908,10 +44331,15 @@ function parseFlags(argv) {
43908
44331
  "require-engine": { type: "string" },
43909
44332
  llm: { type: "boolean" },
43910
44333
  json: { type: "boolean" },
44334
+ record: { type: "boolean" },
44335
+ "no-record": { type: "boolean" },
43911
44336
  help: { type: "boolean" }
43912
44337
  }
43913
44338
  });
43914
44339
  const { config: config2, targets, force, yes, llm, json: json2 } = values;
44340
+ if (values.record === true && values["no-record"] === true) {
44341
+ throw new UnknownArgError("--record and --no-record contradict each other");
44342
+ }
43915
44343
  const requireEngine = values["require-engine"];
43916
44344
  const flags = {
43917
44345
  ...config2 !== void 0 ? { config: config2 } : {},
@@ -43920,7 +44348,8 @@ function parseFlags(argv) {
43920
44348
  yes: yes === true,
43921
44349
  ...requireEngine !== void 0 ? { requireEngine: splitList(requireEngine, { flag: "--require-engine", item: "engine" }) } : {},
43922
44350
  llm: llm === true,
43923
- json: json2 === true
44351
+ json: json2 === true,
44352
+ ...values["no-record"] === true ? { record: false } : values.record === true ? { record: true } : {}
43924
44353
  };
43925
44354
  return { flags, positionals, help: values.help === true };
43926
44355
  } catch (err) {
@@ -43986,11 +44415,18 @@ function run(argv, stdout = (line) => {
43986
44415
  }
43987
44416
 
43988
44417
  // src/index.ts
43989
- function isMainModule() {
43990
- const entry = process.argv[1];
43991
- return entry !== void 0 && import.meta.url === pathToFileURL(entry).href;
44418
+ function isMainModule({ argv1, moduleUrl, realpath }) {
44419
+ if (argv1 === void 0) return false;
44420
+ const canonical = (p) => {
44421
+ try {
44422
+ return realpath(p);
44423
+ } catch {
44424
+ return p;
44425
+ }
44426
+ };
44427
+ return pathToFileURL(canonical(argv1)).href === pathToFileURL(canonical(fileURLToPath5(moduleUrl))).href;
43992
44428
  }
43993
- if (isMainModule()) {
44429
+ if (isMainModule({ argv1: process.argv[1], moduleUrl: import.meta.url, realpath: realpathSync.native })) {
43994
44430
  const outcome = run(process.argv.slice(2));
43995
44431
  process.exitCode = outcome instanceof Promise ? await outcome : outcome;
43996
44432
  }
@@ -43999,6 +44435,7 @@ export {
43999
44435
  EXIT_ERROR,
44000
44436
  EXIT_PASS,
44001
44437
  SUBCOMMANDS,
44438
+ isMainModule,
44002
44439
  run,
44003
44440
  translateError,
44004
44441
  usage
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
+ "//": "publint is a runtime dep (exact pin): the npm-stage engine is invoked via bin resolution against the installed layout, so consumers get the publint-fail leg out of the box; the dev suite resolves the same node_modules/.bin/publint — no devDependencies entry is needed for it to stay green.",
4
5
  "description": "Fail-closed git push gate: secret scanning + supply-chain checks before any remote leaves the machine",
5
6
  "type": "module",
6
7
  "bin": {
@@ -30,13 +31,13 @@
30
31
  "@secretlint/secretlint-rule-no-homedir": "13.0.5",
31
32
  "@secretlint/secretlint-rule-pattern": "13.0.5",
32
33
  "@secretlint/secretlint-rule-preset-recommend": "13.0.5",
34
+ "publint": "0.3.24",
33
35
  "yaml": "2.9.0",
34
36
  "zod": "4.5.4"
35
37
  },
36
38
  "devDependencies": {
37
39
  "@types/node": "22.19.0",
38
40
  "esbuild": "0.25.12",
39
- "publint": "0.3.24",
40
41
  "secretlint": "13.0.5",
41
42
  "typescript": "5.9.3",
42
43
  "verdaccio": "6.10.2"