border-customs 0.5.1 → 0.7.1

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
@@ -237,6 +237,74 @@ itself keeps blocking until it stops tripping the scan.
237
237
  - Both new rules are ordinary findings: the allow-list can waive them and every waiver is
238
238
  enumerated in `allowHits` — no hidden channel.
239
239
 
240
+ ### The exfil lens (0.6.0)
241
+
242
+ Exfil-shaped literals — internal IPv4s, `user@internal-host` ssh targets, home paths,
243
+ credential-file locations, host fingerprints — are not credentials, so classic secret
244
+ scanners stay silent on them while they still describe the private network a public repo
245
+ lives inside. The exfil lens adds five rules over the surfaces about to become public:
246
+
247
+ | Rule | What it catches |
248
+ | --- | --- |
249
+ | `exfil-rfc1918` | RFC1918 private-address literals (10/8, 172.16/12, 192.168/16) |
250
+ | `exfil-ssh-target` | `user@host` where the host is a private literal, a configured `rules.hosts` entry, or one closed internal suffix set (`.internal` `.lan` `.local` `.corp` `.intra`) |
251
+ | `exfil-home-path` | `/home/<user>` and `C:\Users\<user>` shapes |
252
+ | `exfil-cred-location` | `~/.pypirc`, `SSHPASS`, `*.env` credential-location shapes |
253
+ | `exfil-host-profile` | multi-signal host fingerprints (os+version / no-docker / no-passwordless-sudo co-occurrences in one paragraph) |
254
+
255
+ The severity a reader **observes** is the (rule × facet × channel) matrix, and its single
256
+ home is [src/exfil/severity.ts](src/exfil/severity.ts) — the check pipeline, the
257
+ `border exfil` CLI and this table all render that table, never re-decide it; the
258
+ `EXFIL-DOC` guard test fails the build if a cell is restated wrong:
259
+
260
+ | Rule | Facet | Channel | Observed severity |
261
+ | --- | --- | --- | --- |
262
+ | `exfil-rfc1918` | tree | gitleaks-twin, secretlint-twin | CRITICAL |
263
+ | `exfil-ssh-target` | tree | gitleaks-twin, secretlint-twin | CRITICAL |
264
+ | `exfil-home-path` | tree | native | MEDIUM |
265
+ | `exfil-cred-location` | tree | native | MEDIUM |
266
+ | `exfil-host-profile` | tree | native | MEDIUM |
267
+ | `exfil-rfc1918` | message | native | HIGH |
268
+ | `exfil-ssh-target` | message | native | HIGH |
269
+ | `exfil-home-path` | message | native | MEDIUM |
270
+ | `exfil-cred-location` | message | native | MEDIUM |
271
+ | `exfil-host-profile` | message | native | MEDIUM |
272
+
273
+ Why an observed `CRITICAL` stands where the intent table says HIGH: the twin channels have
274
+ no HIGH rung — every ingested gitleaks finding carries CRITICAL, and secretlint's `error`
275
+ level maps to CRITICAL — so the matrix records the channel mechanics honestly
276
+ (`不硬掰,如实表`) instead of faking a severity the engines cannot emit. The message facet
277
+ is native-exclusive and carries the `:message` id family (`exfil-rfc1918:message`): allow
278
+ entries match ids exactly, so waiving the tree face can never waive a commit-message hit,
279
+ or vice versa. Tag notes are never scanned by the exfil core — the pre-existing engine leg
280
+ owns that facet and reports hits there under its own `tag-message-secret` id.
281
+
282
+ - **Exemptions.** Documented ranges never fire: loopback, the RFC5737 documentation bands
283
+ (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24), and RFC2606 TLDs (`.example` `.test`
284
+ `.invalid` `.localhost`). The **fixture band** 10.200.0.0/16 is this project's reserved
285
+ test range: on tree surfaces all channels stay silent on it, and the twins carry the same
286
+ band exemption natively (TOML allowlists / negative lookaheads) so verdicts agree across
287
+ channels. Commit messages are the one exception — a message describing any private-shape
288
+ literal still fires its `:message` rule.
289
+ - **Fixture doctrine (for contributors).** Checked-out bytes must not contain complete
290
+ leak shapes outside the fixture band: no real-looking private IPv4s, no full
291
+ `user@host.<internal-suffix>` tokens. Tests assemble them at runtime with
292
+ `assembleOctet()` / `assembleHost()` (test/helpers) and assert red — which keeps
293
+ border's own lens green against its own corpus while the red stories stay armed.
294
+ - **Truth lane.** `BORDER_EXFIL_TRUTH=1` opts the golden suite into its live-URL leg
295
+ against the external fixture repo; skipped by default in CI. Its red anchors are
296
+ product facts with a lifetime clause: when the upstream owner scrubs them, the lane
297
+ passes with an archive-backed note rather than failing — expiration by design is not
298
+ regression.
299
+
300
+ The same rule sources feed three places: `border check` (tip tree + will-publish commit
301
+ messages), the standalone read-only `border exfil <rev|url>` lens (tip tree by default,
302
+ plus tag notes in local mode — which the engine leg reports under its own
303
+ `tag-message-secret` id; `--deep` adds full-history blobs and every reachable
304
+ commit message; `file://` and scp-shaped URLs are fetched into a temp clone that is
305
+ destroyed on exit), and post-push landing verification. The sources are fingerprinted into
306
+ `rulesHash` like every other rule surface — editing a rule invalidates cached PASSes.
307
+
240
308
  ### Release coherence (0.4.1)
241
309
 
242
310
  Founding case — **aihr**: the wheel published on PyPI under version `0.2.2` carried
@@ -341,6 +409,15 @@ never from cache:
341
409
  retried on failure because a half-published version can never be republished.
342
410
  - If one remote of several succeeded before a failure, border says so explicitly (PARTIAL)
343
411
  and exits 1; a rerun of `border push --yes` picks up only the still-PENDING targets.
412
+ - After every git leg that moved, border **verifies the landing**: it re-observes the remote
413
+ tip (`git ls-remote`, then a fetch of exactly that tip when it is not local) and scans the
414
+ landed tree with the same exfil machinery as `border exfil`. Three honest outcomes: clean
415
+ appends a `t:"landing"` ledger record and preserves the push exit; a blocking hit exits 1
416
+ with `ALREADY PUBLIC; border detects, never erases` — the bytes are out, and border's job
417
+ there is detection, recording the fact, never rewriting history; an unobservable tip exits
418
+ 2 with `landed, verification unavailable` and mints **no record** (no verification, no
419
+ fact). The ledger stays append-only: executed-push rows are never mutated, and a DRY-RUN
420
+ push never fetches or records anything landing-related.
344
421
 
345
422
  ### What `border push` actually runs
346
423
 
@@ -413,6 +490,7 @@ border <command> [options]
413
490
  | `llm-request` | emit the masked review bundle for LLM-authored commits |
414
491
  | `llm-ingest <findings.json>` | validate agent findings and record the combined verdict |
415
492
  | `scan <[ecosystem:]name@version>` | inspect a third-party published artifact for residue before installing it |
493
+ | `exfil <rev\|url>` | read-only exfil lens over a local rev or a remote URL (tip tree by default) |
416
494
  | `opencode <install\|status\|uninstall>` | install/status/uninstall the official OpenCode plugin adapter |
417
495
 
418
496
  Every subcommand accepts the same global flags (verified against `border <cmd> --help`):
@@ -425,16 +503,17 @@ Every subcommand accepts the same global flags (verified against `border <cmd> -
425
503
  | `--yes` | execute mutations (push only); without it a push is always DRY-RUN |
426
504
  | `--require-engine <list>` | replaces the required-engine set from config; unknown or unprobeable names degrade the run (exit 2) |
427
505
  | `--llm` | opt this check into the LLM review layer (a plain check can never satisfy an llm-recorded skip) |
428
- | `--json` | machine-readable report on stdout (check, scan) |
506
+ | `--json` | machine-readable report on stdout (check, scan, exfil) |
507
+ | `--deep` | exfil only: also scan full-history blobs and every reachable commit message (default scans the tip tree + tag notes) |
429
508
  | `--help, -h` | usage table |
430
509
 
431
510
  ### Exit codes (the contract)
432
511
 
433
512
  | Code | Meaning |
434
513
  | --- | --- |
435
- | `0` | PASS (or no-op): MEDIUM / LOW / INFO findings are allowed and listed |
436
- | `1` | gate-blocked: HIGH or CRITICAL findings; or a push refused (BLOCKED targets); or a partial push |
437
- | `2` | the gate could not answer: config error, missing/degraded engine, unreachable registry, malformed input, concurrent lock holder |
514
+ | `0` | PASS (or no-op): MEDIUM / LOW / INFO findings are allowed and listed; a clean landing verification preserves the executor's exit |
515
+ | `1` | gate-blocked: HIGH or CRITICAL findings; or a push refused (BLOCKED targets); or a partial push; or landing verification found blocking findings on the tip that is now **already public** (border detects, never erases) |
516
+ | `2` | the gate could not answer: config error, missing/degraded engine, unreachable registry, malformed input, concurrent lock holder; or a push landed but the remote tip could not be verified (executed-push records stand, no PASS is endorsed for the public face) |
438
517
 
439
518
  The `2` class matters as much as `1`: a tool error never exits 0, and no exit-0 run ever
440
519
  rests on a leg that silently skipped. Engine exit codes are translated against a closed
@@ -562,7 +641,7 @@ rules:
562
641
  names: [Acme CI]
563
642
  allowBots: true
564
643
  hosts: ["git.internal.acme.example"] # turned into detection patterns
565
- ips: ["10.20.30.40"]
644
+ ips: ["192.0.2.1"]
566
645
  pathPatterns: ["/Users/*", "*.pfx"]
567
646
  maxFileKB: 500 # oversized-file threshold (default)
568
647
  allow: # enumerated suppressions, never blanket
@@ -638,7 +717,7 @@ plugin entry ships as `exports["./server"]`):
638
717
  ```jsonc
639
718
  {
640
719
  // pin the exact version — an @latest channel hits the registry on every cold start
641
- "plugin": ["border-customs@0.5.1"]
720
+ "plugin": ["border-customs@0.7.1"]
642
721
  }
643
722
  ```
644
723
 
@@ -659,6 +738,18 @@ exit 2 and never overwritten, uninstall removes only marker-bearing files, and i
659
738
  report `up to date`. Either route, restart opencode afterwards — tools and commands are
660
739
  scanned at startup.
661
740
 
741
+ **OpenCode 2 (beta) compatibility (0.7.0).** The same module is dual-shaped: V1 hosts call
742
+ `server()` (object entry form, opencode ≥ 1.18.29 required); V2 hosts (`@opencode-ai/cli@beta`,
743
+ bin `opencode2`) call `setup(ctx)` and register the identical tool through
744
+ `ctx.tool.transform` — hand-written `{id, setup}` (the SDK's `define()` is identity), plain
745
+ JSON Schema input, zero V2-SDK runtime dependency. Live-verified on beta-19271 via
746
+ project-local `.opencode/plugins/` (both single-file and package-directory shapes; directory
747
+ discovery resolves the root `index.ts` re-export, which is why the package ships it). V2
748
+ activates plugins lazily — listing them requires `POST /api/plugin/await-activation` first.
749
+ The `/border` slash command stays V1-only until the V2 command-delivery API settles; the
750
+ `border` tool is the supported V2 surface. Config keys are shared (V2 normalizes
751
+ `plugin`→`plugins`). Global file-drop discovery under V2 is untested and not claimed.
752
+
662
753
  What loads is one agent tool `border` plus a `/border` slash command. The tool spawns the CLI
663
754
  argv-only (no shell; 300 s cap, 64 KiB per stream) restricted to the closed command list
664
755
  `check`, `push`, `status`, `llm-request`, `llm-ingest`, `scan`, `roundtrip`, `--help`, and
@@ -724,6 +815,64 @@ code.
724
815
 
725
816
  ## Changelog
726
817
 
818
+ ### 0.7.1 (2026-09-30)
819
+ - Fix: **release-gate test ruler race** (`test/push.integration.test.ts` I4) — the
820
+ "dry-run made ZERO verdaccio requests" invariant counted lines in verdaccio's stdout
821
+ log, an eventually-consistent view: on a saturated CI runner the gate-leg probe lines
822
+ of an earlier phase land after the snapshot, inflating the dry-run delta (v0.7.0 CI
823
+ run 36671401844 failed `6 !== 2` while the identical bytes pass locally). The suite
824
+ now takes every snapshot through a FIFO drain barrier (sentinel request whose log line
825
+ must be visible before counting; sentinel traffic is excluded from the count). The
826
+ zero-network dry-run contract itself is unchanged — verified request-free on both sides
827
+ of the barrier; only the measuring instrument was repaired.
828
+
829
+ ### 0.7.0 (2026-09-28)
830
+ - Add: **OpenCode 2 (beta) plugin compatibility** — the plugin module is dual-shaped
831
+ (`{id, setup}` for V2 hosts, frozen V1 `server()`), sharing the pre-spawn
832
+ allowlist/`--yes` gates and the 0.5.1 identity handshake across both shapes;
833
+ refusal/handshake/rendering byte-parity between edges is pinned by
834
+ `test/opencode.v2shape.test.ts` while the V1 path stays 16/16 on unmodified
835
+ tests. Root `index.ts` re-export shipped for V2 directory discovery (which
836
+ bypasses the exports map; live-proven on `@opencode-ai/cli` beta-19271).
837
+ - Fix: the live V1 probe is anti-vacuous — opting in (`BORDER_OPENCODE_PROBE=1`)
838
+ without a resolvable binary or built dist now FAILS naming the remedy instead
839
+ of skipping green-adjacent (`BORDER_OPENCODE_BIN` override added).
840
+ - Docs: support matrix (V1 object entry ≥ 1.18.29; V2 project-local verified;
841
+ `/border` command V1-only pending the V2 delivery API).
842
+
843
+ ### 0.6.0 (2026-09-25)
844
+ - Add: **the exfil lens** — five rules (`exfil-rfc1918`, `exfil-ssh-target`,
845
+ `exfil-home-path`, `exfil-cred-location`, `exfil-host-profile`) that flag
846
+ exfil-shaped literals on about-to-be-public surfaces, the gap classic secret
847
+ scanners leave open because an internal IP is not a credential. The observed
848
+ severity of every (rule × facet × channel) cell lives in one machine-readable
849
+ home, `src/exfil/severity.ts`, rendered — never re-decided — by the check
850
+ pipeline, the new read-only `border exfil <rev|url>` CLI (tip tree by default,
851
+ `--deep` history, URL modes into a destroyed temp clone) and the README table,
852
+ each cell pinned by a docs guard. The tree HIGH family ships as vendored
853
+ twins (gitleaks TOML + secretlint patterns, ids byte-equal, verdict parity
854
+ tested); the commit-message facet is native-exclusive with a `:message` id
855
+ family so allow-list waivers can never cross facets; tag notes stay with the
856
+ pre-existing `tag-message-secret` engine leg. All exfil sources are
857
+ fingerprinted into `rulesHash`.
858
+ - Add: **post-push landing verification** — after git legs move, the remote tip
859
+ is re-observed and scanned with the same machinery; clean appends an
860
+ append-only `t:"landing"` record and preserves the exit, a blocking hit exits
861
+ 1 ("ALREADY PUBLIC; border detects, never erases"), an unobservable tip exits
862
+ 2 with no record minted. Executed-push rows are never mutated; DRY-RUN lands
863
+ nothing.
864
+ - Add: **fixture band + assembly doctrine** — 10.200.0.0/16 is reserved as the
865
+ always-exempt-on-tree test range (exemption mirrored natively in both twins,
866
+ message facet deliberately not exempt), and the test corpus assembles every
867
+ other leak shape at runtime (`assembleOctet`/`assembleHost`) so checked-out
868
+ bytes carry no complete leak tokens: border's own lens is green against
869
+ border's own corpus. Golden-suite coverage includes an opt-in truth lane
870
+ (`BORDER_EXFIL_TRUTH=1`) with a documented red-anchor lifetime clause.
871
+ - Note: this release line traces to the 0.5.1 impersonation-wave audit; that
872
+ wave's H-3 follow-up — scrubbing this repo's own historical tip surfaces —
873
+ landed here as byte edits with the extracted values withheld from public
874
+ records by design (see the sanitized `.omo/evidence/` trail, not this log).
875
+
727
876
  ### 0.5.1 (2026-09-24)
728
877
  - Fix: **plugin impersonation — the `border` tool could spawn the host binary instead of the
729
878
  gate.** On Route B the 0.5.0 adapter resolved its packaged CLI as `process.execPath +
@@ -851,6 +1000,10 @@ MIT, see [LICENSE](LICENSE).
851
1000
 
852
1001
  ## 中文概要
853
1002
 
1003
+ **0.7.1(2026-09-30)发布门禁尺伤修复**:I4 集成测试的"dry-run 对 registry 零请求"断言按 verdaccio stdout 日志行计数,而日志是最终一致视图——CI 满载 runner 上更早阶段的合法 probe 行晚于快照落盘,把差额错记到 dry-run 头上(v0.7.0 run 36671401844 红 `6 !== 2`,同字节本机绿)。修复=尺子不松语义:所有快照前后加 FIFO drain 屏障(哨兵请求行可见⇒先前请求行必已落盘;哨兵流量不计入计数)。dry-run 零网络契约本身未动,两侧实测请求为零。
1004
+
1005
+ **0.7.0(2026-09-28)OpenCode 2(beta)兼容**:插件模块双形状——V1 宿主调 `server()`(对象入口需 ≥1.18.29),V2 宿主(`opencode2`)调 `setup(ctx)` 经 `ctx.tool.transform` 注册同一个工具;spawn 前门(allowlist/`--yes`)与 0.5.1 身份握手两形状共享,双边渲染逐字 parity 由测试钉死。V2 懒激活(断言前须 `POST /api/plugin/await-activation`);`/border` 命令暂仅 V1(V2 投递 API 未定形);已在 beta-19271 项目本地两形态活体验证。V1 活体探针反真空化:opt-in 缺前提即 FAIL 点名补救。
1006
+
854
1007
  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 新增证明阀
855
1008
  `residue.requireProof`(默认关,strict):开启后,发布构件若触发阻断级 `residue-*` 发现,通道 PASS
856
1009
  还要求账本里存在按其 sha256 索引且 rulesHash 新鲜的 `border roundtrip` 实测记录——check 自身从不
@@ -883,3 +1036,5 @@ border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install
883
1036
  发布一致性(0.4.1):起因是 aihr 事故——PyPI 上标为 0.2.2 的 wheel 里 `__init__.__version__` 却写着 0.2.1,构件名、元数据与实际模块行为三者不一致,所有 `==0.2.2` 的用户静默装上了旧行为;同类漂移在本仓库也出现过两次(npm tarball 的 User-Agent 停在 0.3.0 而 package.json 是 0.3.1;package-lock.json 根版本两个 release 一直躺在 0.1.0)。发布阶段扫描器因此在构件内部逐源交叉核对版本号:`release-coherence-version-drift` 为 CRITICAL(package.json 与被强制打包的 package-lock、wheel 文件名对 dist-info 目录对 METADATA `Version:` 对 `__init__` 字面量、sdist 的 pyproject 对 PKG-INFO/setup 系字面量、Cargo.toml 对已打包的 Cargo.lock、.gemspec 对 metadata.gz、dist 文件名对 sdist 内 PKG-INFO);版本源存在但无法静态解析(动态 `__version__`、`attr:`、`dynamic = ["version"]`、坏 lockfile)判 MEDIUM `release-coherence-unverifiable-source`——不可核查绝不静默算干净;第二版本源根本没被打包时不发现在内,缺源不是漂移,这条边界是承重的,误报的门禁会把用户训练成 `--force`。唯一可静态强制的跨包管理器声明是 opt-in `release.twin`(`{pypi, npm}` 严格 zod 对列表,未知键 exit 2):同一 run 的 dist 里孪生版本不等 ⇒ CRITICAL `release-coherence-twin-drift`,消息点名两个构件。规则源文件与 residue 指纹表一样并入 rulesHash,改一个匹配器即令全部缓存 PASS 失效。
884
1037
 
885
1038
  OpenCode 插件适配层(0.5.0):零手工装载两条路线——主路是在 `opencode.jsonc` 声明 `"plugin": ["border-customs@0.5.0"]`(建议钉具体版本,@latest 每次冷启动都打注册表),opencode 自行下载构件,插件入口走 `exports["./server"]`;插件自带 CLI 解析,顺序为 `BORDER_BIN` 覆盖(独占,坏了就报错、绝不静默回退)> 包内同侧 `dist/index.js`(shebang 直接执行,不可执行则回退 PATH 上的 `node` 承载)> PATH 上的 `border`,免全局安装、免 PATH 配置;0.5.1 起任何候选都必须先过**身份握手**(以 `--help` 应答出 border 横幅与 `usage: border` 才算数),通过之前绝不投递用户 argv,握手不过即 cannot-answer exit 2、不运行任何其他二进制——0.5.0 曾用 `process.execPath` 承载包内 dist,而在插件宿主里 execPath 是 opencode 本体,边检门被静默冒充为宿主(2026-09-22 事故,见 0.5.1 变更日志)。备路是 `border opencode install|status|uninstall` 文件投递,落盘 `$XDG_CONFIG_HOME/opencode` 下的 `plugins/border.ts` 与 `commands/border.md`(无 XDG 回退 HOME/.config):受管文件凭 marker 行识别身份,同名外人文件 exit 2 拒绝、绝不覆盖,卸载只删自己带 marker 的文件,字节相同即 `up to date`。装载面只有一个 `border` 工具(argv-only 生成、无 shell、300 秒与每流 64 KiB 上限,命令闭集 check/push/status/llm-request/llm-ingest/scan/roundtrip/--help)与一条 `/border` 斜杠命令(config 钩子以 `??=` 自注册,用户同名文件命令保持权威)。边界如实说:allowlist 是 UX/防呆而非安全边界——插件进程与 opencode 进程同权,门禁的牙齿在 CLI 与账本,不在适配层;`push --yes` 按设计留在终端人审,工具端机械拒绝,会话内裸 push 本就是 CLI 合同的 DRY-RUN。装载验证走 opt-in 探针 `BORDER_OPENCODE_PROBE=1`(临时 HOME/XDG 起 `opencode serve` 对照组,断言 `border` 工具 id 与恰好一条 border 命令,重复即 FAIL;插件加载失败是静默的,只认 HTTP 响应不认退出码)。两条路线装载后都需重启 opencode:工具与命令在启动时扫描。
1039
+
1040
+ 外泄透镜(0.6.0):经典密钥扫描器看不见内网 IP、`user@内网主机`、家目录、凭据文件位置、主机画像这类"外泄形状"字面量——它们不是密钥,却如实描述公网仓库身后的私网。0.6.0 加五条规则 `exfil-rfc1918/exfil-ssh-target/exfil-home-path/exfil-cred-location/exfil-host-profile`,每个(规则×分面×通道)格子的**观察严重度**只有单一事实源 `src/exfil/severity.ts`:树面 HIGH 族走双生规则(gitleaks TOML + secretlint pattern,id 逐字节相等、三通道判决对等性有测试钉死),因两引擎都没有 HIGH 档而如实记为 CRITICAL(不硬掰,如实表);树面 MEDIUM 族走原生腿;提交消息面原生独占、id 带 `:message` 后缀,allow 条目按 id 精确匹配、跨面豁免在结构上不可能;tag note 分面归既有引擎腿 `tag-message-secret`,核心绝不重复发。规则源文件并入 rulesHash,改一个匹配器即令全部缓存 PASS 失效。独立只读入口 `border exfil <rev|url>`(默认树尖,`--deep` 补全史 blob 与全部可达提交消息;URL 模式落临时克隆、退出即销毁)。push 之后新增**落地核验**:ls-remote 复读远端尖、必要时精确 fetch 该对象,用同一套机制扫已公开树——干净则追加 `t:"landing"` 账本行并保留原退出码;有阻断发现则 exit 1 并明说 ALREADY PUBLIC;border 只检测、从不抹除;远端不可观测则 exit 2、一条记录都不造(没核验就没事实),已执行的 push 记录原样矗立——账本 append-only,任何情况下不改写历史。测试语料遵守夹具纪律:10.200.0.0/16 为预留夹具段(树面全通道静默、双生规则原生镜像该豁免,消息面例外照红),其余泄漏形状一律由 `assembleOctet`/`assembleHost` 运行时拼装,签出字节里没有完整泄漏 token——border 的透镜对自己的仓库绿,红故事照常武装。真值通道 `BORDER_EXFIL_TRUTH=1` 为 opt-in,红锚点带生命周期条款:上游作者清理后按设计转绿、留档不判败。
@@ -0,0 +1,179 @@
1
+ // provenance: .omo/plans/border-exfil-lens.md T1 — pure predicates for the five
2
+ // exfil rules, plan §"规则语义表" (intent level; observed severity is resolved
3
+ // through severity.ts, NEVER here).
4
+ //
5
+ // Purity contract: every predicate is (text, options) → hits. No fs, no git,
6
+ // no config-module import — rules.hosts arrives as a parameter (T3's message
7
+ // leg and the later CLI pass cfg.rules.hosts in). Line numbers are 1-based.
8
+ // Hit.message is generic finding text and NEVER embeds the matched value;
9
+ // scan.ts carries the value only as sha256 digest + mask (G23 invariant).
10
+ //
11
+ // SELF-STEALTH (dogfood lesson from 0.5.1, where the identity regex tripped
12
+ // border's own drive-letter rule): this directory writes NO literal 4-octet
13
+ // dotted-quad IP strings and no absolute home paths — CIDR bands live as
14
+ // NUMBER tuples and IPv4 sources are assembled from octet character classes,
15
+ // so border's own detectors (vendored TOML generic-path rule, no-homedir,
16
+ // the repo's path-pattern rules) can never pattern-match our rule sources.
17
+ import type { ExfilFacet, ExfilRuleId } from "./severity.ts";
18
+
19
+ export type RuleHit = {
20
+ readonly rule: ExfilRuleId;
21
+ /** the matched raw value — digested/masked by scan.ts, never rendered. */
22
+ readonly matched: string;
23
+ readonly line: number;
24
+ readonly message: string;
25
+ };
26
+
27
+ export type RuleOptions = {
28
+ /** drives the band-exemption split (tree exempts DEFAULT_EXEMPT_BANDS, message fires everything); never selects severity. */
29
+ readonly facet: ExfilFacet;
30
+ /** operator's rules.hosts (config.ts:102) — exact-match ssh targets, closed set. */
31
+ readonly hosts?: readonly string[];
32
+ /** exemption-band override (production default DEFAULT_EXEMPT_BANDS; [] forces in-range arms red in unit tests). */
33
+ readonly exemptBands?: readonly string[];
34
+ };
35
+
36
+ // ---------------------------------------------------------------- IPv4 machinery
37
+
38
+ const OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
39
+ /** Full IPv4 with strict token boundaries (won't fire inside 5-dotted versions/urls). */
40
+ const IPV4_SRC = `(?<![0-9A-Za-z.\\-])${OCTET}\\.${OCTET}\\.${OCTET}\\.${OCTET}(?![0-9A-Za-z.\\-])`;
41
+
42
+ /**
43
+ * The production closed exemption bands (F2 twin-parity + F3 fixture doctrine):
44
+ * loopback, each RFC 5737 documentation /24, and the 10.200/16 SYNTHETIC-FIXTURE
45
+ * band. Dotted PREFIX strings (never full quads — self-stealth); a hit is
46
+ * exempt when its leading octets equal a band. ALL channels — native tree
47
+ * policy and both twins — exempt exactly these; the message facet alone stays
48
+ * band-fired (it is native-exclusive and carries the golden red anchors).
49
+ * Unit tests may pass options.exemptBands = [] to force in-range arms red with
50
+ * assembled (never checked-in-as-quad) literals.
51
+ */
52
+ export const DEFAULT_EXEMPT_BANDS: readonly string[] = ["127", "192.0.2", "198.51.100", "203.0.113", "10.200"];
53
+
54
+ function bandOctets(band: string): number[] {
55
+ return band.split(".").map(Number);
56
+ }
57
+ function inAnyBand(oct: readonly number[], bands: readonly string[]): boolean {
58
+ return bands.some((b) => {
59
+ const parts = bandOctets(b);
60
+ return parts.every((v, i) => oct[i] === v);
61
+ });
62
+ }
63
+ /** 10/8 ∪ 172.16/12 ∪ 192.168/16 — the plan's exfil-rfc1918 range set (loopback & RFC5737 are OUT of range by construction). */
64
+ function inRfc1918(o: readonly number[]): boolean {
65
+ const [a = 0, b = 0] = o;
66
+ if (a === 10) return true;
67
+ if (a === 172 && b >= 16 && b <= 31) return true;
68
+ return a === 192 && b === 168;
69
+ }
70
+ function octetsOf(ip: string): number[] {
71
+ return ip.split(".").map(Number);
72
+ }
73
+ function isIpLiteral(host: string): boolean {
74
+ return /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.test(host);
75
+ }
76
+
77
+ /** Octet + boundary pieces shared by the JS twins (secretlint) — single source. */
78
+ const OCTET_GRP = `(?:${OCTET})`;
79
+ // Each branch is FULLY expanded (three or four octets as the prefix demands):
80
+ // an alternation branch would otherwise swallow a shared suffix only into its
81
+ // LAST arm — the precedence trap this exact line replaces.
82
+ const RFC1918_FULL = `10\\.${OCTET_GRP}\\.${OCTET_GRP}\\.${OCTET_GRP}|172\\.(?:1[6-9]|2[0-9]|3[01])\\.${OCTET_GRP}\\.${OCTET_GRP}|192\\.168\\.${OCTET_GRP}\\.${OCTET_GRP}`;
83
+ const SUFFIX_HOST = `[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*\\.(?:internal|lan|local|corp|intra)`;
84
+
85
+ /**
86
+ * The secretlint-twin pattern sources for the HIGH family (plan §分面契约 row
87
+ * 1: emission id = pattern name, ids VERBATIM equal to the rule ids). Negative
88
+ * classes at the IP/head position exempt exactly DEFAULT_EXEMPT_BANDS (loopback
89
+ * / RFC5737 / 10.200/16 fixture band) — F2 verdict parity: the same bytes must
90
+ * read the same on every channel at the tree facet; the corpus test
91
+ * (test/exfil.pipeline.test.ts) is the real contract, id-equality alone was a
92
+ * hole. The gitleaks TOML carries Go-RE2 variants (no lookaround) of these
93
+ * same bodies + equivalent per-rule allowlists.
94
+ */
95
+ const BAND_NEGATIVE = `(?!127\\.|10\\.200\\.|192\\.0\\.2\\.|198\\.51\\.100\\.|203\\.0\\.113\\.)`;
96
+ export const EXFIL_TWIN_PATTERNS: ReadonlyArray<{ readonly name: ExfilRuleId; readonly source: string }> = [
97
+ { name: "exfil-rfc1918", source: `(?<![0-9A-Za-z.\\-])${BAND_NEGATIVE}(?:${RFC1918_FULL})(?![0-9A-Za-z.\\-])` },
98
+ { name: "exfil-ssh-target", source: `(?<![A-Za-z0-9._+%-])[A-Za-z0-9._+%-]+@${BAND_NEGATIVE}(?:${RFC1918_FULL}|${SUFFIX_HOST})(?![A-Za-z0-9._-])` },
99
+ ];
100
+
101
+ // ---------------------------------------------------------------- exfil-rfc1918
102
+
103
+ /**
104
+ * RFC1918 literals. Closed exemptions: DEFAULT_EXEMPT_BANDS (loopback + each
105
+ * RFC5737 /24 are OUT of range by construction; the 10.200/16 fixture band is
106
+ * in-range and exempted on every facet EXCEPT message — commit messages are
107
+ * the public-exposure surface with no fixture excuse, so the band FIRES there
108
+ * ("message 面带合成 IP 树干净 ⇒ 必红" stays mechanically true, and the twins,
109
+ * which never scan messages, match the tree-side policy exactly: F2 parity).
110
+ */
111
+ export function matchRfc1918(text: string, o: RuleOptions): RuleHit[] {
112
+ const re = new RegExp(IPV4_SRC, "g");
113
+ const bands = o.exemptBands ?? DEFAULT_EXEMPT_BANDS;
114
+ const hits: RuleHit[] = [];
115
+ text.split("\n").forEach((body, idx) => {
116
+ for (const m of body.matchAll(re)) {
117
+ const ip = m[0] ?? "";
118
+ const oct = octetsOf(ip);
119
+ if (!inRfc1918(oct)) continue;
120
+ if (o.facet !== "message" && inAnyBand(oct, bands)) continue;
121
+ hits.push({ rule: "exfil-rfc1918", matched: ip, line: idx + 1, message: "RFC1918 private-network address literal on a surface about to become public." });
122
+ }
123
+ });
124
+ return hits;
125
+ }
126
+
127
+ // ---------------------------------------------------------------- exfil-ssh-target
128
+
129
+ /** Closed private-suffix set (plan table): internal hostname shapes, not public domains. */
130
+ const PRIVATE_SUFFIXES: readonly string[] = [".internal", ".lan", ".local", ".corp", ".intra"];
131
+ /** RFC 2606 reserved TLDs — documentation/exemption shapes, never real targets. */
132
+ const RFC2606_SUFFIXES: readonly string[] = [".example", ".test", ".invalid", ".localhost"];
133
+ const SSH_TOKEN_RE = /(?<![A-Za-z0-9._+%-])[A-Za-z0-9._+%-]+@([A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)/g;
134
+
135
+ function hasDotSuffix(host: string, suffixes: readonly string[]): boolean {
136
+ return suffixes.some((s) => host.length > s.length && host.endsWith(s));
137
+ }
138
+
139
+ /**
140
+ * `user@host` where host ∈ {RFC1918 literal, operator's rules.hosts (exact,
141
+ * case-insensitive), closed suffix set {.internal,.lan,.local,.corp,.intra}}.
142
+ * Closed exemption: RFC 2606 (bare public domains like user@example.com are
143
+ * out of the hit set by construction — they match nothing). An explicitly
144
+ * operator-configured host wins over the RFC2606 shape exemption (listing it
145
+ * IS the operator declaring it internal). IP hosts: DEFAULT_EXEMPT_BANDS
146
+ * exempt on the tree facet (twin parity, F2); the message facet still fires
147
+ * `synthuser@` + fixture-band address — the T6 golden red anchor rides :message.
148
+ */
149
+ export function matchSshTarget(text: string, o: RuleOptions): RuleHit[] {
150
+ const hosts = (o.hosts ?? []).map((h) => h.trim().toLowerCase()).filter((h) => h !== "");
151
+ const bands = o.exemptBands ?? DEFAULT_EXEMPT_BANDS;
152
+ const hits: RuleHit[] = [];
153
+ text.split("\n").forEach((body, idx) => {
154
+ for (const m of body.matchAll(SSH_TOKEN_RE)) {
155
+ const host = (m[1] ?? "").toLowerCase();
156
+ if (host === "") continue;
157
+ const isIp = isIpLiteral(host);
158
+ let fired = false;
159
+ if (isIp ? inRfc1918(octetsOf(host)) && !(o.facet !== "message" && inAnyBand(octetsOf(host), bands)) : false) {
160
+ fired = true;
161
+ } else if (!isIp && hosts.includes(host)) {
162
+ fired = true;
163
+ } else if (!isIp && !hasDotSuffix(host, RFC2606_SUFFIXES) && hasDotSuffix(host, PRIVATE_SUFFIXES)) {
164
+ fired = true;
165
+ }
166
+ if (fired) {
167
+ hits.push({ rule: "exfil-ssh-target", matched: `${m[0]}`, line: idx + 1, message: "SSH-style user@host naming an internal-only target (RFC1918 literal, configured host, or private suffix)." });
168
+ }
169
+ }
170
+ });
171
+ return hits;
172
+ }
173
+
174
+ // ---------------------------------------------------------------- path & profile rules (250-LOC ceiling split)
175
+ //
176
+ // matchHomePath / matchCredLocation / matchHostProfile live in rulesPath.ts
177
+ // and are re-exported here so every consumer keeps importing the five
178
+ // predicates from one module. Type-only imports keep that pair acyclic.
179
+ export { matchCredLocation, matchHomePath, matchHostProfile } from "./rulesPath.ts";
@@ -0,0 +1,112 @@
1
+ // provenance: .omo/plans/border-exfil-lens.md T1/T2 — path-shape rules split out
2
+ // of rules.ts at the 250-LOC ceiling (home-path, cred-location, host-profile).
3
+ // Type-only import back into rules.ts keeps the pair runtime-acyclic.
4
+ // Self-stealth: no absolute workspace paths and no dotted quads in this file
5
+ // (see rules.ts header for the 0.5.1 dogfood lesson this obeys).
6
+ import type { RuleHit, RuleOptions } from "./rules.ts";
7
+
8
+ // ---------------------------------------------------------------- exfil-home-path
9
+
10
+ /** /home/<user> — class-gated so "/homebrew" or a lone "/home/" never fires. */
11
+ const HOME_POSIX_RE = /\/home\/([A-Za-z0-9._-]+)/g;
12
+ /** C:\Users\<user> (any drive letter, either slash), case-insensitive. */
13
+ const HOME_WINDOWS_RE = /([A-Za-z]:[\\/]+Users[\\/]+)([A-Za-z0-9._-]+)/gi;
14
+
15
+ export function matchHomePath(text: string, _o: RuleOptions): RuleHit[] {
16
+ const hits: RuleHit[] = [];
17
+ text.split("\n").forEach((body, idx) => {
18
+ for (const m of body.matchAll(HOME_POSIX_RE)) {
19
+ hits.push({ rule: "exfil-home-path", matched: m[0] ?? "", line: idx + 1, message: "Home-directory path exposing a local username (privacy leak — T5 calibration graded this rule MEDIUM per lead ruling)." });
20
+ }
21
+ for (const m of body.matchAll(HOME_WINDOWS_RE)) {
22
+ hits.push({ rule: "exfil-home-path", matched: m[0] ?? "", line: idx + 1, message: "Windows user-profile path exposing a local username (privacy leak — T5 calibration graded this rule MEDIUM per lead ruling)." });
23
+ }
24
+ });
25
+ return hits;
26
+ }
27
+
28
+ // ---------------------------------------------------------------- exfil-cred-location
29
+
30
+ const PYPIRC_RE = /~\/\.pypirc/g;
31
+ const SSHPASS_RE = /\bSSHPASS\b/g;
32
+ /** *.env token: path chars then ".env" ending the token (prod.env.example stays green). */
33
+ const ENVFILE_RE = /[A-Za-z0-9_.~/-]*\.env(?![A-Za-z0-9_./-])/g;
34
+
35
+ /** *.env location-shape gate (see matchCredLocation header for the calibration evidence). */
36
+ function envTokenIsLocation(tok: string): boolean {
37
+ const stem = tok.slice(0, -".env".length);
38
+ if (stem === "") return true;
39
+ if (/[\/~]/.test(stem)) return true;
40
+ const parts = stem.split(".");
41
+ const receiver = parts[parts.length - 1] ?? "";
42
+ if (receiver === "process" || receiver === "os") return false;
43
+ return receiver.length >= 3;
44
+ }
45
+
46
+ /**
47
+ * Credential-BEARING LOCATIONS referenced by the outgoing text — position, not
48
+ * value (plan: "位置 ≠ 值"): ~/.pypirc, SSHPASS (env var name), *.env files.
49
+ * T5-calibrated (2026-09-25 self-scan: 186/354 raw tree matches were JS
50
+ * property access like process.env / o.env / x?.env, not disk locations):
51
+ * a *.env token counts only when it names the file itself, carries a path
52
+ * prefix, or has a real file-stem receiver (≥3 chars, never the process/os
53
+ * API names); the JS optional-chain read `x?.env` never counts.
54
+ */
55
+ export function matchCredLocation(text: string, _o: RuleOptions): RuleHit[] {
56
+ const hits: RuleHit[] = [];
57
+ const push = (matched: string, line: number): void => {
58
+ hits.push({ rule: "exfil-cred-location", matched, line, message: "Reference to a credential-bearing location (env file / pypirc / SSHPASS); the location itself, value not required to fire." });
59
+ };
60
+ text.split("\n").forEach((body, idx) => {
61
+ for (const m of body.matchAll(PYPIRC_RE)) push(m[0] ?? "", idx + 1);
62
+ for (const m of body.matchAll(SSHPASS_RE)) push(m[0] ?? "", idx + 1);
63
+ for (const m of body.matchAll(ENVFILE_RE)) {
64
+ const tok = m[0] ?? "";
65
+ if ((body[(m.index ?? 0) - 1] ?? "") === "?") continue;
66
+ if (envTokenIsLocation(tok)) push(tok, idx + 1);
67
+ }
68
+ });
69
+ return hits;
70
+ }
71
+
72
+ // ---------------------------------------------------------------- exfil-host-profile
73
+
74
+ /** Conservative multi-signal table (plan: 要素 ≥2 同段, closed regex set). */
75
+ const PROFILE_SIGNALS: ReadonlyArray<{ readonly name: string; readonly re: RegExp }> = [
76
+ { name: "os+version", re: /\b(?:Ubuntu|Debian|Raspbian|CentOS|Rocky Linux|AlmaLinux|Amazon Linux|Red Hat Enterprise Linux|RHEL|Windows Server|SUSE Linux Enterprise|Fedora)\s+v?[0-9][0-9.]*/i },
77
+ { name: "no-docker", re: /\bno docker\b|\bdocker\b[^\n]{0,24}?\bnot (?:installed|present|available|running)\b|\bwithout docker\b|\bno container (?:runtime|engine)\b/i },
78
+ { name: "sudo-pattern", re: /\bpasswordless sudo\b|\bnopasswd\b|\bsudo\b[^\n]{0,24}?\b(?:without|needs?|requires?) (?:a )?password\b|\b(?:needs?|requires?) (?:a )?password\b[^\n]{0,12}?\bsudo\b/i },
79
+ ];
80
+
81
+ /**
82
+ * Host-fingerprint disclosure: ≥2 DISTINCT signal groups (os+version /
83
+ * no-docker / sudo pattern) inside the SAME paragraph (blank-line-split
84
+ * block). Deliberately conservative: one signal never fires. The matched
85
+ * value is the SIGNAL-NAME CLUSTER, never the paragraph text (privacy +
86
+ * nothing raw on the Finding). Phase-2 note (plan): bare user@hostname and
87
+ * semantic combos ride ledger rows later — this predicate stays silent there.
88
+ */
89
+ export function matchHostProfile(text: string, _o: RuleOptions): RuleHit[] {
90
+ const hits: RuleHit[] = [];
91
+ let paraLines: string[] = [];
92
+ let paraStart = 1;
93
+ const flush = (): void => {
94
+ if (paraLines.length === 0) return;
95
+ const para = paraLines.join("\n");
96
+ const found = PROFILE_SIGNALS.filter((s) => s.re.test(para)).map((s) => s.name);
97
+ if (found.length >= 2) {
98
+ hits.push({ rule: "exfil-host-profile", matched: found.join("+"), line: paraStart, message: `Host configuration fingerprint: ${String(found.length)} profile signals (os+version / no-docker / sudo-pattern) clustered in one paragraph.` });
99
+ }
100
+ paraLines = [];
101
+ };
102
+ text.split("\n").forEach((body, idx) => {
103
+ if (body.trim() === "") {
104
+ flush();
105
+ return;
106
+ }
107
+ if (paraLines.length === 0) paraStart = idx + 1;
108
+ paraLines.push(body);
109
+ });
110
+ flush();
111
+ return hits;
112
+ }