border-customs 0.8.0 → 0.8.2

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
@@ -10,7 +10,7 @@ cd /path/to/your/repo
10
10
  border check
11
11
  ```
12
12
 
13
- Requires Node >= 22 and the `gitleaks` binary on `PATH` (border vendors the rule config for
13
+ Requires Node >= 22.22.2 (the transitive `ini@7` dependency sets that floor) and the `gitleaks` binary on `PATH` (border vendors the rule config for
14
14
  gitleaks 8.30.1; the binary itself is yours to install, same as `git`). secretlint runs
15
15
  in-process as a bundled dependency. Each registry leg additionally shells out to its platform's
16
16
  own publisher, which must be on `PATH` whenever that target is configured; a missing publisher
@@ -18,14 +18,29 @@ binary is exit 2, never a skipped leg:
18
18
 
19
19
  | Registry target | Publisher border runs | Version the same-bytes proofs were measured on |
20
20
  | --- | --- | --- |
21
- | `npm` | `npm` | (unchanged since 0.1.0) |
21
+ | `npm` | `npm` | npm >= 11 for packages with pack-phase hooks (see below) |
22
22
  | `pypi` | `python3 -m build` / `python3 -m twine` | (unchanged since 0.1.0) |
23
23
  | `crates` | `cargo` | cargo 1.93.1 (rustup) |
24
24
  | `rubygems` | `gem` | RubyGems 3.6.7 on ruby 3.3.8 |
25
25
 
26
+ **npm pack safety probe.** `npm pack --ignore-scripts` is not trustworthy on npm 10: measured
27
+ on npm 10.9.7, the target package's `prepare` script still executes (npm 11.19.1 is clean).
28
+ Before packing, border runs `npm --version`. On npm < 11 it refuses, with a named exit 2, any
29
+ package that declares a `prepack`, `prepare`, or `postpack` script — border never executes the
30
+ code it audits. A package with none of those hooks has nothing for npm to run and stays
31
+ scannable on npm 10. Installing npm >= 11 removes the refusal.
32
+
26
33
  Other toolchain versions of `cargo` and `gem` are untested by those byte-determinism proofs.
27
34
  Optional engines are listed in [Configuration](#configuration).
28
35
 
36
+ **Pinned consumption.** Install an exact version (`npm install -g border-customs@<version>`;
37
+ `npx border-customs@<version> check` for a one-off) instead of running gate code out of a
38
+ source checkout. Every check and push record carries `engineBuild` — the identity
39
+ (`<version>+<commit>[.dirty]`) of the border build that produced the verdict, the same string
40
+ `border --version` prints. A `.dirty` suffix means the verdict came from uncommitted code, and
41
+ records from one repo carrying different `engineBuild` values mean the audit trail is mixed:
42
+ both are findings an auditor should chase, which is exactly why the identity is on the record face.
43
+
29
44
  ## The problem it solves
30
45
 
31
46
  The usual secret workflow is leak-then-repent: `git push`, notice the AWS key in the diff
@@ -118,7 +133,18 @@ it, and the two registry-facing legs (pre-flight and publish) both have to agree
118
133
  against the history of every ref *and* against the object set a remote does not already
119
134
  have (the transmit set). Oversized-file, checked-in-binary, and notebook-output rules
120
135
  round it out.
121
- 7. **Registry pre-flight** (npm, PyPI, crates.io, and RubyGems targets). Three outcomes per
136
+ 7. **Landing-grade tip lens.** The exact machinery `border push` uses to verify a landing
137
+ (`scanTipTree`: the tip tree materialized from git objects into a throwaway directory,
138
+ scanned by the native exfil family plus both twins filtered to `exfil-*` ids) also runs
139
+ here, against local HEAD — the tree a push would transmit. This closes the
140
+ disk-versus-tip gap: content that exists only in HEAD (introduced by a merge commit's
141
+ resolution and since deleted from disk, say) is invisible to the history and working-tree
142
+ legs above but cannot hide from the gate. Tip findings merge before the allow-list, so a
143
+ config `allow` pin exempts them by rule+file+digest exactly like every other finding, and
144
+ the post-push landing check honors the *same* pins — a green packet means green landing, in
145
+ both directions. The tip leg's git plumbing is fail-closed: a git failure aborts the run
146
+ (exit 2), never a silent clean.
147
+ 8. **Registry pre-flight** (npm, PyPI, crates.io, and RubyGems targets). Three outcomes per
122
148
  target: version already published ⇒ CRITICAL `version-exists` ("bump version required");
123
149
  name owned by someone outside your `rules.authors` allow-list ⇒ CRITICAL
124
150
  `name-foreign-owner`; ambiguous ownership signals ⇒ also CRITICAL, because guessing wrong
@@ -129,7 +155,7 @@ it, and the two registry-facing legs (pre-flight and publish) both have to agree
129
155
  one of that API's exact *absent* messages (its 404s are text, not JSON, and an
130
156
  unrecognized one is never guessed), a timeout, or unparseable JSON all fail closed as
131
157
  exit 2. An unreachable registry blocks the push instead of letting it run blind.
132
- 8. **Artifact stage.** If registry targets are configured, packages are built **once** into
158
+ 9. **Artifact stage.** If registry targets are configured, packages are built **once** into
133
159
  `.border/dist/` (`npm pack --ignore-scripts`, `python -m build --no-isolation`,
134
160
  `cargo package --allow-dirty --no-verify`, `gem build <name>.gemspec -o .border/dist/`),
135
161
  that exact byte-stream is scanned (gitleaks + secretlint over the extracted contents),
@@ -145,14 +171,16 @@ it, and the two registry-facing legs (pre-flight and publish) both have to agree
145
171
  re-runs the check's exact `cargo package` into a throwaway directory and refuses to
146
172
  publish if the fresh digest differs from the certified `.crate`. `gem push` uploads the
147
173
  recorded `.gem` byte-for-byte, so it needs no such re-assertion.
148
- 9. **Report.** Findings carry `valueDigest` (sha256 of the matched value) and a masked
174
+ 10. **Report.** Findings carry `valueDigest` (sha256 of the matched value) and a masked
149
175
  snippet: fully blocked for short values, else `first4…last4`. Raw secret bytes never
150
176
  leave process memory. A per-run `TextSanitizer` holds the digest-to-value registry and
151
177
  replaces every flagged literal in *all* rendered text, including agent-written strings in
152
178
  the LLM layer, so a secret cannot reappear inside a message about itself. `report.json`
153
179
  (canonical, byte-stable for machines) and `report.md` land under `.border/runs/`.
154
- 10. **Ledger record.** A PASS is appended to `.border/ledger.jsonl` together with the key
155
- and artifact digests. Degraded and NO-OP runs can never write a PASS.
180
+ 11. **Ledger record.** A PASS is appended to `.border/ledger.jsonl` together with the key
181
+ and artifact digests. Degraded and NO-OP runs can never write a PASS. The key folds in a
182
+ *lens identity*, so a PASS recorded under an older, weaker set of legs never certifies a
183
+ push under the current lens — upgrading the gate forces one fresh full check.
156
184
 
157
185
  ```bash
158
186
  $ border check # full scan, ~seconds on a mid-size repo
@@ -194,7 +222,7 @@ an installer *does*, per tier — the split behind opencode-ai's in-tree `postin
194
222
  The pairing classes behind these rows (multi-channel PASS **C5** subset/ordering discipline;
195
223
  **C9** interpolated-and-indirected root spellings; **C10** comment/quote-boundary shapes that
196
224
  once silent-passed) are frozen with their evidence in
197
- [src/artifacts/RESIDUE-CONTRACT.md](src/artifacts/RESIDUE-CONTRACT.md).
225
+ [src/artifacts/RESIDUE-CONTRACT.md](https://github.com/TachikomaGundam/Border-Customs/blob/main/src/artifacts/RESIDUE-CONTRACT.md).
198
226
 
199
227
  **Fail-closed:** a residue scan whose rule table or classifier sources cannot be read is exit 2,
200
228
  never a silent pass — and the classifier source bytes are part of the ledger `rulesHash`, so
@@ -209,10 +237,30 @@ clean residue scan is an absence of matched signatures, never a certificate of b
209
237
  The signature table is deliberately closed, and the list of shapes it **cannot see** —
210
238
  content-writes, handle-variants, dead spellings, interpolation-then-slash, span-quirk
211
239
  over-blocks, IO-instance writers, user-shadowed bare verbs — lives in
212
- [RESIDUE-CONTRACT.md §8](src/artifacts/RESIDUE-CONTRACT.md); this README points, it does not
240
+ [RESIDUE-CONTRACT.md §8](https://github.com/TachikomaGundam/Border-Customs/blob/main/src/artifacts/RESIDUE-CONTRACT.md); this README points, it does not
213
241
  restate. The empirical roundtrip (install → snapshot → uninstall → rc-delta-zero) is the 0.4.0
214
- valve. **border is NOT a malware sandbox**: it never executes, intercepts, or sandboxes the
215
- code it reads; it matches text and blocks the push.
242
+ valve. **border does not execute the code it audits.** Every scan leg (gitleaks, secretlint,
243
+ the native rules, the exfil lens) is pure byte inspection; the cross-channel marker fixture
244
+ (`test/build.execMarkers.test.ts`) pins that a consentless `border check` spawns ZERO build
245
+ toolchains. Registry artifact builds (npm pack, `python -m build`, `gem build`, `cargo
246
+ package`) DO execute repo-supplied build code, so they run only on operator consent:
247
+ `--allow-builds`, or `allowBuilds: true` in the machine store / an explicit `--config` file
248
+ outside the repo (the environment is deliberately NOT a consent source — a hostile `.envrc`
249
+ owns it). Both sources live OUTSIDE the audited tree, and a repo's own border.yaml can declare
250
+ targets but can never enable building them. Without
251
+ consent the run is scan-only: no artifact digests are recorded (visible as MEDIUM
252
+ `build-stage-withheld` findings), and a scan-only PASS can never back a registry publish.
253
+ Consented builds can additionally run inside a **pinned container** (`targets.<ch>.sandboxImage`,
254
+ see Configuration): the container is launched with the network disabled — the `--network none`
255
+ argv the daemon applies is the enforcement anchor, and an in-container probe re-checks DNS and
256
+ TCP from inside as a tripwire — the repo mounted read-only and built from a tmpfs copy, host
257
+ credentials never entering the container, and every ledger record bound to the exact image
258
+ digest that certified its bytes. `sandboxImage` itself is a security-tier key: honored only
259
+ from the machine store or a `--config` file outside the repo whose ownership and modes pass
260
+ the fd-chain qualification, and any competing declaration (repo config, lax-mode file, hijacked
261
+ `XDG_CONFIG_HOME`) is a hard refusal, never a silent fallback. Border is
262
+ containment-by-consent plus optional container containment — it is not a malware sandbox and
263
+ not a VM: a kernel-level escape from the container remains in the threat model.
216
264
 
217
265
  #### Proof valve: `residue.requireProof` (0.4.0)
218
266
 
@@ -251,9 +299,10 @@ lives inside. The exfil lens adds five rules over the surfaces about to become p
251
299
  | `exfil-home-path` | `/home/<user>` and `C:\Users\<user>` shapes |
252
300
  | `exfil-cred-location` | `~/.pypirc`, `SSHPASS`, `*.env` credential-location shapes |
253
301
  | `exfil-host-profile` | multi-signal host fingerprints (os+version / no-docker / no-passwordless-sudo co-occurrences in one paragraph) |
302
+ | `exfil-machine-binding` | vendor inference-model ids pinned on portable surfaces next to model/default/provider context (L-MACHINE-LOCAL; born 2026-10-02) |
254
303
 
255
304
  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
305
+ home is [src/exfil/severity.ts](https://github.com/TachikomaGundam/Border-Customs/blob/main/src/exfil/severity.ts) — the check pipeline, the
257
306
  `border exfil` CLI and this table all render that table, never re-decide it; the
258
307
  `EXFIL-DOC` guard test fails the build if a cell is restated wrong:
259
308
 
@@ -264,11 +313,13 @@ home is [src/exfil/severity.ts](src/exfil/severity.ts) — the check pipeline, t
264
313
  | `exfil-home-path` | tree | native | MEDIUM |
265
314
  | `exfil-cred-location` | tree | native | MEDIUM |
266
315
  | `exfil-host-profile` | tree | native | MEDIUM |
316
+ | `exfil-machine-binding` | tree | native | MEDIUM |
267
317
  | `exfil-rfc1918` | message | native | HIGH |
268
318
  | `exfil-ssh-target` | message | native | HIGH |
269
319
  | `exfil-home-path` | message | native | MEDIUM |
270
320
  | `exfil-cred-location` | message | native | MEDIUM |
271
321
  | `exfil-host-profile` | message | native | MEDIUM |
322
+ | `exfil-machine-binding` | message | native | MEDIUM |
272
323
 
273
324
  Why an observed `CRITICAL` stands where the intent table says HIGH: the twin channels have
274
325
  no HIGH rung — every ingested gitleaks finding carries CRITICAL, and secretlint's `error`
@@ -411,7 +462,10 @@ never from cache:
411
462
  and exits 1; a rerun of `border push --yes` picks up only the still-PENDING targets.
412
463
  - After every git leg that moved, border **verifies the landing**: it re-observes the remote
413
464
  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
465
+ landed tree with the same exfil machinery as `border exfil` — the same lens `border check`
466
+ now runs pre-push, and the same config `allow` pins apply: blocking counts are computed
467
+ after the allow-list, and any exemption is echoed loudly (rule, entry index, count), never
468
+ silent. Three honest outcomes: clean
415
469
  appends a `t:"landing"` ledger record and preserves the push exit; a blocking hit exits 1
416
470
  with `ALREADY PUBLIC; border detects, never erases` — the bytes are out, and border's job
417
471
  there is detection, recording the fact, never rewriting history; an unobservable tip exits
@@ -589,11 +643,60 @@ degraded run.
589
643
 
590
644
  ## Configuration
591
645
 
592
- Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
646
+ Border is a property of the **checking machine**, not of the checked repo. Config resolves
647
+ in this order, and the machine-side rungs exist so gate config (including waivers) never has
648
+ to be committed into a product repo:
649
+
650
+ 1. `--config <path>` (explicit);
651
+ 2. `$BORDER_CONFIG` — fail-closed: set-but-empty or unreadable is exit 2, never a fall-through;
652
+ 3. the **machine store**: `$BORDER_CONFIG_DIR` (else `$XDG_CONFIG_HOME/border/machines`, else
653
+ `~/.config/border/machines`) holding `<sha256-16>.yaml` files keyed by the repo's canonical
654
+ remote URL (`host/path`, credentials/`.git`/scp-vs-https spellings collapsed);
655
+ 4. `./border.yaml` (cwd, then git toplevel) — kept for repos that *choose* to carry their config;
656
+ 5. git-remote inference (targets only, no waivers) when nothing above exists.
657
+
658
+ Unknown keys are
593
659
  rejected at load (a typo in a gate config is a gate you did not ask for). `${VAR}`
594
660
  expansion applies only to remote URLs and registry/repository/host values, and an unset variable
595
661
  is a hard error, never an empty string.
596
662
 
663
+ #### Sandboxed builds (`targets.<ch>.sandboxImage`)
664
+
665
+ Configure pins with the helper instead of hand-editing the machine store:
666
+ `border sandbox init --lane npm --image node@sha256:<64hex>` (writes only the
667
+ 0600 machine-store file under the §3-qualified root, refuses tmp/unowned dirs and
668
+ tag refs); `border sandbox status` shows what the loader will actually honor;
669
+ `unpin` removes keys. The helper never writes into the audited repo.
670
+
671
+ When a lane declares `sandboxImage`, that lane's consented build runs inside `docker run`
672
+ instead of on the host: `--network none` (the daemon-applied argv is the enforcement anchor;
673
+ a DNS+TCP probe re-checks from *inside* as a tripwire that fails the build closed), read-only repo mount, build on a tmpfs snapshot, `--cap-drop ALL`,
674
+ `no-new-privileges`, and a scrubbed env (image defaults + `HOME=/tmp` only). The image MUST be
675
+ digest-pinned:
676
+
677
+ ```yaml
678
+ targets:
679
+ npm:
680
+ sandboxImage: node@sha256:<64 hex> # tags are rejected at load — a mutable tag
681
+ # would let check and push certify different bytes
682
+ ```
683
+
684
+ `sandboxImage` is a **security-tier key**: it is honored only from the machine store or an
685
+ explicit `--config` file outside the repo, and only after fd-chain qualification (every path
686
+ component verified by open file descriptor: root-owned or euid-owned above your trust anchor,
687
+ euid-owned `0700`-below files `0600` below it — symlink or mode violations fail closed).
688
+ Any other declaration of the same key — the repo's own border.yaml, a lax-mode (group/world
689
+ readable or writable) store file, an `XDG_CONFIG_HOME` override pointing somewhere
690
+ unqualifiable — is a named hard refusal (`sandbox-config-shadowed` /
691
+ `sandbox-config-unqualified`), never a silent ignore and never a host fallback.
692
+
693
+ Freshness and push carry the same binding: a PASS certified inside image X is re-proved only
694
+ inside X (config drift forces a full re-check), and docker being *absent* while an image is
695
+ configured is exit 2 with the remedy named — never a silent host fallback. No `sandboxImage`
696
+ means today's host build, unchanged. The crates lane keeps its registry cache in
697
+ `.border/cache/` (the one writable bind); the pypi image must ship the build backend's
698
+ dependencies (there is no network inside).
699
+
597
700
  Minimal, a git-only repo:
598
701
 
599
702
  ```yaml
@@ -644,8 +747,15 @@ rules:
644
747
  ips: ["192.0.2.1"]
645
748
  pathPatterns: ["/Users/*", "*.pfx"]
646
749
  maxFileKB: 500 # oversized-file threshold (default)
750
+ aliases: # optional: rule-rename migration for allow pins
751
+ old-rule-name: current-rule-name # {renamed-from: current} — waivers survive renames
647
752
  allow: # enumerated suppressions, never blanket
648
753
  - { rule: "junk-artifact", match: "*", file: "test/fixtures/**" }
754
+ - rule: "gitleaks:*" # fixture-set waiver: values pins the exact
755
+ file: "tests/fixtures/**" # value digests; ANY value outside stays red
756
+ values: ["<64hex>", "<64hex>"]
757
+ until: "2027-01-01" # optional UTC sunset; after it the entry is
758
+ # inert and echoed as allow-entry-expired
649
759
  engines:
650
760
  require: [gitleaks, secretlint] # trufflehog: true adds the third-party engine
651
761
  residue:
@@ -656,6 +766,15 @@ release:
656
766
 
657
767
  Notes that change behavior:
658
768
 
769
+ - **Archive member addressing in `allow.file`:** findings inside archives are attributed
770
+ `archive!<inner>`. A plain `file` glob matches only the truncated OUTER path (`archive`),
771
+ so member-level pins need the escape form `archive\\!inner` (the `\!` switches the entry to
772
+ full-attribution matching). Members are addressable; forgetting the escape silently matches
773
+ the whole archive instead.
774
+ - **Waiver floors:** a `values` entry suppresses only the listed value digests — a new value
775
+ under the same path/rule stays red; an `until` entry is inert after its UTC date and is
776
+ echoed as an INFO `allow-entry-expired` finding instead of quietly keeping work.
777
+
659
778
  - `targets.git.remotes: []` (explicit, empty) is a *deliberate* repo-local scope: all
660
779
  history/tree/identity/artifact legs still run, only the exposure set is empty. A
661
780
  completely missing config with real remotes falls back to config-from-`git remote` with
@@ -700,7 +819,10 @@ the hook must first pass. Drop `--force` to get sub-second skip-ledger replay in
700
819
  re-scan on every `git push`.
701
820
 
702
821
  **CI.** `border check --json` emits the stable report object; gate on the exit code, parse
703
- `verdict`/`counts` for dashboards. In CI you typically want `--force` on a cache-less
822
+ `verdict`/`counts` for dashboards. Each finding is
823
+ `{ rule, severity, target, path?, line?, commit?, engine, message, valueDigest, snippet }` —
824
+ the file location is **`path`** (absent when the finding is commit-only, e.g. an identity
825
+ finding); there is no `file` field. In CI you typically want `--force` on a cache-less
704
826
  runner, plus your `gitleaks` binary provisioned in the image (a degraded run exits 2, which
705
827
  most CI maps to red; that is the point).
706
828
 
@@ -717,7 +839,7 @@ plugin entry ships as `exports["./server"]`):
717
839
  ```jsonc
718
840
  {
719
841
  // pin the exact version — an @latest channel hits the registry on every cold start
720
- "plugin": ["border-customs@0.8.0"]
842
+ "plugin": ["border-customs@0.8.2"]
721
843
  }
722
844
  ```
723
845
 
@@ -813,6 +935,10 @@ which is now a thin consumer of `inspect --json` with the rc contract unchanged
813
935
  - **Fail-closed doctrine.** "Silence never means absent" is applied mechanically: empty
814
936
  registry stdout, unparsed versions, non-translatable exit codes, unresolvable ownership,
815
937
  corrupt-but-present rules inputs, all become exit 2 or a CRITICAL, never a pass.
938
+ - **Platform behaviour is probed, not assumed.** The npm stage reads `npm --version` before
939
+ packing. On npm < 11 a declared pack-phase hook is a named exit 2, because that npm line
940
+ executes `prepare` despite `--ignore-scripts` (reproduced on npm 10.9.7, 2026-10-19); a
941
+ missing or unparseable version is also exit 2.
816
942
  - **No telemetry, no callbacks.** `border check` touches the network only for the
817
943
  registry pre-flight, against the URLs the channels define: `registry.npmjs.org`,
818
944
  `pypi.org`, and `rubygems.org` are overridable (private indexes; `targets.rubygems.host`),
@@ -824,9 +950,89 @@ which is now a thin consumer of `inspect --json` with the rc contract unchanged
824
950
  it, and which every scan leg refuses to treat as a finding source or an allow-list
825
951
  target. A repo that *tracks* `.border/**` is itself a CRITICAL finding. A single-writer
826
952
  lock makes concurrent runs exit 2 instead of racing.
953
+ - **Reports go through a private channel.** See [SECURITY.md](SECURITY.md) for the
954
+ reporting route and scope. Do not put exploit details or live secrets in a public issue.
955
+
956
+ ## Development
957
+
958
+ Contributor setup, the exact external-engine list, subset test commands, and the version
959
+ rotation procedure live in [CONTRIBUTING.md](CONTRIBUTING.md). In short:
960
+
961
+ ```bash
962
+ npm ci
963
+ npm run typecheck
964
+ npm test
965
+ ```
966
+
967
+ CI runs the full suite on Node 22 (npm 10) and Node 24 (npm 11) for every push to `main`
968
+ and every pull request, plus version single-source, typecheck, build, and audit gates.
827
969
 
828
970
  ## Changelog
829
971
 
972
+ ### 0.8.2 (2026-11-01)
973
+ - Production dependency graph now measures ZERO advisories (`npm audit --omit=dev`): the
974
+ pattern rule is vendored (`src/engines/borderPatternRule.ts`, MIT attribution; drops the
975
+ unpatchable braces/micromatch + fast-uri chain) and `tool` — an identity function — is
976
+ vendored in `plugin/border.ts`, moving `@opencode-ai/plugin` (and its effect-beta → toml
977
+ chain) to devDependencies; live-host probe tests keep the activation proof. The CI audit
978
+ gate tightens from critical to HIGH so this cannot rot.
979
+ - `border sandbox init|pin|status|unpin` — the opt-in pin helper from the Q1 ruling
980
+ (machine store only, 0600 atomic writes, §3 self-verification with rollback of its own
981
+ writes; deliberately absent from the plugin's agent-reachable command set).
982
+ - Security tier for execution-deciding keys (`src/tier.ts`, sibyl §3⁗/§3⁖/§3‡):
983
+ `sandboxImage`/`allowBuilds` honor only from fd-chain-qualified machine-store or out-of-repo
984
+ `--config` sources; repo declarations, lax-mode files, and hijacked `XDG_CONFIG_HOME` are
985
+ named refusals (`sandbox-config-shadowed`/`sandbox-config-unqualified`); the
986
+ `BORDER_ALLOW_BUILDS` environment channel is REMOVED (never released, so no migration);
987
+ passwd-derived canonical store root — `$HOME` spoofing cannot move it.
988
+ - Sandboxed artifact builds: optional `targets.<ch>.sandboxImage` (digest-pinned, tags fail
989
+ closed at load) runs consented npm/pypi/crates/rubygems builds — and freshness re-packs —
990
+ inside `docker run` with `--network none` proved by an in-container DNS+TCP probe, a
991
+ read-only repo mount, tmpfs build, `--cap-drop ALL`, scrubbed env, and the ledger record
992
+ bound to the exact image digest (a rotated image invalidates cached PASS). Missing docker
993
+ with an image configured is a named exit 2, never a host fallback. New `test/sandbox.test.ts`
994
+ pins the docker argv contract token by token.
995
+ - Hardened the npm pack-phase guard (sibyl must_fix): ALL package.json manifests in the tree
996
+ are scanned, not just the root (a workspaces child's `prepare` is now refused on npm < 11),
997
+ and unreadable manifests or a walk-cap hit count as a hook — unknown is never assumed safe.
998
+ - Machine-side config (HR decoupling handoff 2026-10-23): resolution chain is now
999
+ `--config > $BORDER_CONFIG (fail-closed) > machine store (XDG/border/machines, keyed by
1000
+ canonical remote URL) > ./border.yaml > git-remote inference`, so gate config and waiver
1001
+ lists live on the checking machine and never need to be committed into a product repo.
1002
+ Allow entries gained `values` (fixture-set waivers that never cover a new value), `until`
1003
+ (UTC sunset, expired entries echoed never silently honored), `\!` archive-member escaping,
1004
+ and `rules.aliases` (rule renames no longer strand waivers). Border state was already
1005
+ porcelain-invisible (`.border/.gitignore` self-ignore; verified 2026-10-23).
1006
+ - Provenance: every check and push record carries `engineBuild`
1007
+ (`<version>+<commit>[.dirty]`, the string `border --version` prints), so a verdict names
1008
+ the exact border build that produced it. Motivated by a machine where every gate ran off
1009
+ an unpinned worktree bundle and no record said so; worktree use is now visible on the
1010
+ record face (see "Pinned consumption"). Bundles are stamped by `tools/build.mjs`.
1011
+ - Security (breaking): artifact build stages now require operator consent. A hostile clone can
1012
+ ship its own border.yaml declaring pypi/rubygems targets, and `border check` built them —
1013
+ PEP 517 backends and gemspecs ARE code (`python3 -m build --no-isolation` and `gem build`
1014
+ markers reproduced 2026-10-23). Default runs are now scan-only (zero build spawns, pinned by
1015
+ `test/build.execMarkers.test.ts`); consent comes only from outside the audited tree:
1016
+ `--allow-builds` or `BORDER_ALLOW_BUILDS=1`. A scan-only PASS records no artifact digests and
1017
+ therefore can never back a registry publish; `border scan` of third-party artifacts requires
1018
+ the same consent (named exit 2 without it).
1019
+ - Security: refuse npm < 11 packages that declare `prepack`/`prepare`/`postpack` before
1020
+ spawning `npm pack`. npm 10 executes `prepare` even with `--ignore-scripts` (reproduced on
1021
+ 10.9.7; npm 11.19.1 is clean), so the old defense could execute code from the audited repo.
1022
+ The probe also runs before freshness re-packs. New regression tests pin the refusal and the
1023
+ never-fires hook marker.
1024
+ - Fix: the Windows drive-letter path pattern no longer fires on JS escapes in prose
1025
+ (`raw-repoPath:\n`); the UNC pattern no longer spans newlines.
1026
+ - Docs: the `--json` findings schema is stated explicitly (`path`, no `file` alias);
1027
+ npm-target prerequisites now name the npm 11 boundary.
1028
+
1029
+ ### 0.8.1 (2026-10-06)
1030
+ - Maintenance release: certificate-chain rebuild off the scrubbed lineage (INCIDENT-20261006
1031
+ identity-exposure cleanup). No functional change vs 0.8.0; 0.8.0's tarball is byte-intact but
1032
+ its OIDC provenance pin moved with the history rewrite, so a fresh tag + publish restores a
1033
+ verifiable chain. Rotation: package.json + lock roots + plugin marker + scan UA + README pins +
1034
+ this entry.
1035
+
830
1036
  ### 0.8.0 (2026-09-30)
831
1037
  - Add: **`border opencode inspect [--json]`** — the load-surface self-audit becomes a gate-owned
832
1038
  surface: six read-only aspects (cache self-consistency, spec freshness vs pin/registry, identity
@@ -868,7 +1074,7 @@ which is now a thin consumer of `inspect --json` with the rc contract unchanged
868
1074
 
869
1075
  ### 0.6.0 (2026-09-25)
870
1076
  - Add: **the exfil lens** — five rules (`exfil-rfc1918`, `exfil-ssh-target`,
871
- `exfil-home-path`, `exfil-cred-location`, `exfil-host-profile`) that flag
1077
+ `exfil-home-path`, `exfil-cred-location`, `exfil-host-profile`, `exfil-machine-binding`) that flag
872
1078
  exfil-shaped literals on about-to-be-public surfaces, the gap classic secret
873
1079
  scanners leave open because an internal IP is not a credential. The observed
874
1080
  severity of every (rule × facet × channel) cell lives in one machine-readable
@@ -1065,4 +1271,4 @@ border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install
1065
1271
 
1066
1272
  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/exfil/--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:工具与命令在启动时扫描。
1067
1273
 
1068
- 外泄透镜(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,红锚点带生命周期条款:上游作者清理后按设计转绿、留档不判败。
1274
+ 外泄透镜(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,任何情况下不改写历史。2026-10-04 补上透镜对等:check 流水线在原生规则之后、注册表预检之前多跑一条**树尖腿**,用与落地核验完全同一套 scanTipTree 机制预扫本地 HEAD(push 将传输的那棵树),历史与工作面腿看不见的"只在树尖存在"内容(如合并提交解析引入、随后从磁盘删除的文件)从此无处藏;树尖发现汇入 allow 前合并点,按 rule+file+digest 与所有发现同权豁免——落地核验同样消费 cfg allow 并把每一次豁免大声列出(规则、条目号、条数),绝不沉默;绿色交接单与绿色落地双向一致。ledger key 折入透镜身份,旧一代(无树尖腿)的缓存 PASS 不再为新透镜背书,升级后强制一次全新全扫。树尖腿 git 故障即 exit 2,没有静默清白。测试语料遵守夹具纪律:10.200.0.0/16 为预留夹具段(树面全通道静默、双生规则原生镜像该豁免,消息面例外照红),其余泄漏形状一律由 `assembleOctet`/`assembleHost` 运行时拼装,签出字节里没有完整泄漏 token——border 的透镜对自己的仓库绿,红故事照常武装。真值通道 `BORDER_EXFIL_TRUTH=1` 为 opt-in,红锚点带生命周期条款:上游作者清理后按设计转绿、留档不判败。
@@ -176,4 +176,4 @@ export function matchSshTarget(text: string, o: RuleOptions): RuleHit[] {
176
176
  // matchHomePath / matchCredLocation / matchHostProfile live in rulesPath.ts
177
177
  // and are re-exported here so every consumer keeps importing the five
178
178
  // predicates from one module. Type-only imports keep that pair acyclic.
179
- export { matchCredLocation, matchHomePath, matchHostProfile } from "./rulesPath.ts";
179
+ export { matchCredLocation, matchHomePath, matchHostProfile, matchMachineBinding } from "./rulesPath.ts";
@@ -110,3 +110,31 @@ export function matchHostProfile(text: string, _o: RuleOptions): RuleHit[] {
110
110
  flush();
111
111
  return hits;
112
112
  }
113
+
114
+ // ---------------------------------------------------------------- exfil-machine-binding
115
+
116
+ /**
117
+ * L-MACHINE-LOCAL (owner ruling 2026-10-01; rule born 2026-10-02 after a
118
+ * vendor model id sat in another repo's portable README+code default for six
119
+ * releases unnoticed). Vendor inference-model identifiers pinned on portable
120
+ * surfaces couple a package to ONE operator's device authorization. Context
121
+ * gate: the line must ALSO speak of model/default/provider — prose mentions
122
+ * of a model name stay green (location, not vocabulary — mirrors the
123
+ * cred-location "position, not value" discipline).
124
+ * Self-stealth: literals are built in fragments so THIS file never trips
125
+ * its own regex.
126
+ */
127
+ const BINDING_TOKEN = String.raw`\b(?:q` + "wen" + String.raw`|deep` + "seek" + String.raw`|kimi|g` + "lm" + String.raw`|ernie|dou` + "bao" + String.raw`|hunyuan|sp` + "ark" + String.raw`)[-\.\d][\w.-]*\b`;
128
+ const BINDING_VENDOR_RE = new RegExp(BINDING_TOKEN, "gi");
129
+ const BINDING_CONTEXT_RE = /(?:model|模型|default|默认|provider)/i;
130
+
131
+ export function matchMachineBinding(text: string, _o: RuleOptions): RuleHit[] {
132
+ const hits: RuleHit[] = [];
133
+ text.split("\n").forEach((body, idx) => {
134
+ if (!BINDING_CONTEXT_RE.test(body)) return;
135
+ for (const m of body.matchAll(BINDING_VENDOR_RE)) {
136
+ hits.push({ rule: "exfil-machine-binding", matched: m[0] ?? "", line: idx + 1, message: "Machine binding: a vendor inference-model id pinned next to model/default/provider context — device authorization belongs in operator-local config, not portable surfaces (L-MACHINE-LOCAL)." });
137
+ }
138
+ });
139
+ return hits;
140
+ }
@@ -40,6 +40,7 @@ import {
40
40
  import {
41
41
  matchCredLocation,
42
42
  matchHomePath,
43
+ matchMachineBinding,
43
44
  matchHostProfile,
44
45
  matchRfc1918,
45
46
  matchSshTarget,
@@ -99,6 +100,7 @@ const PREDICATES: Readonly<Record<ExfilRuleId, (text: string, o: RuleOptions) =>
99
100
  "exfil-home-path": matchHomePath,
100
101
  "exfil-cred-location": matchCredLocation,
101
102
  "exfil-host-profile": matchHostProfile,
103
+ "exfil-machine-binding": matchMachineBinding,
102
104
  };
103
105
 
104
106
  /**
@@ -44,13 +44,14 @@
44
44
  export const EXFIL_SEVERITIES = ["INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"] as const;
45
45
  export type ExfilSeverity = (typeof EXFIL_SEVERITIES)[number];
46
46
 
47
- /** The five exfil-lens rule ids, verbatim; twins must ship these ids byte-equal (T2 guard). */
47
+ /** The exfil-lens rule ids, verbatim; twins must ship these ids byte-equal (T2 guard). */
48
48
  export const EXFIL_RULE_IDS = [
49
49
  "exfil-rfc1918",
50
50
  "exfil-ssh-target",
51
51
  "exfil-home-path",
52
52
  "exfil-cred-location",
53
53
  "exfil-host-profile",
54
+ "exfil-machine-binding",
54
55
  ] as const;
55
56
  export type ExfilRuleId = (typeof EXFIL_RULE_IDS)[number];
56
57
 
@@ -71,6 +72,7 @@ export const EXFIL_TABLE_SEVERITY: Readonly<Record<ExfilRuleId, ExfilSeverity>>
71
72
  "exfil-home-path": "MEDIUM",
72
73
  "exfil-cred-location": "MEDIUM",
73
74
  "exfil-host-profile": "MEDIUM",
75
+ "exfil-machine-binding": "MEDIUM",
74
76
  };
75
77
 
76
78
  /** HIGH family: the two identity-carrying location rules; tree face belongs to the twins. */
@@ -85,7 +87,7 @@ export const EXFIL_HIGH_FAMILY: readonly ExfilRuleId[] = ["exfil-rfc1918", "exfi
85
87
  */
86
88
  export const EXFIL_SECRET_VALUE_RULES: readonly ExfilRuleId[] = EXFIL_HIGH_FAMILY;
87
89
  /** MEDIUM family: privacy-shape rules; native carries them on both scanned facets. */
88
- export const EXFIL_MEDIUM_FAMILY: readonly ExfilRuleId[] = ["exfil-home-path", "exfil-cred-location", "exfil-host-profile"];
90
+ export const EXFIL_MEDIUM_FAMILY: readonly ExfilRuleId[] = ["exfil-home-path", "exfil-cred-location", "exfil-host-profile", "exfil-machine-binding"];
89
91
 
90
92
  /** null = FORBIDDEN cell: that channel must never emit that rule on that facet. */
91
93
  export type ObservedSeverity = ExfilSeverity | null;
@@ -96,6 +98,7 @@ const TWIN_HIGH: Readonly<Record<ExfilRuleId, ObservedSeverity>> = {
96
98
  "exfil-home-path": null,
97
99
  "exfil-cred-location": null,
98
100
  "exfil-host-profile": null,
101
+ "exfil-machine-binding": null,
99
102
  };
100
103
  const NULL_ROW: Readonly<Record<ExfilRuleId, ObservedSeverity>> = {
101
104
  "exfil-rfc1918": null,
@@ -103,13 +106,14 @@ const NULL_ROW: Readonly<Record<ExfilRuleId, ObservedSeverity>> = {
103
106
  "exfil-home-path": null,
104
107
  "exfil-cred-location": null,
105
108
  "exfil-host-profile": null,
109
+ "exfil-machine-binding": null,
106
110
  };
107
111
 
108
112
  /** The full (facet × channel × rule) truth table — the plan's 分面契约, machine-readable. */
109
113
  export const EXFIL_MATRIX: Readonly<Record<ExfilFacet, Readonly<Record<ExfilChannel, Readonly<Record<ExfilRuleId, ObservedSeverity>>>>>> = {
110
114
  tree: {
111
115
  // native tree leg = MEDIUM family补发 only (v2 double-emission revoked).
112
- native: { ...NULL_ROW, "exfil-home-path": "MEDIUM", "exfil-cred-location": "MEDIUM", "exfil-host-profile": "MEDIUM" },
116
+ native: { ...NULL_ROW, "exfil-home-path": "MEDIUM", "exfil-cred-location": "MEDIUM", "exfil-host-profile": "MEDIUM", "exfil-machine-binding": "MEDIUM" },
113
117
  // gitleaks.ts:81 hardcodes CRITICAL for every ingested finding.
114
118
  "gitleaks-twin": TWIN_HIGH,
115
119
  // secretlint.ts:299-309 has no HIGH rung — error maps to CRITICAL as-is.
@@ -1,3 +1,27 @@
1
+ # Vendored from gitleaks v8.30.1 (assets/default_config.toml), MIT License.
2
+ #
3
+ # MIT License
4
+ #
5
+ # Copyright (c) 2019 Zachary Rice
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in all
15
+ # copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ # SOFTWARE.
24
+
1
25
  # This file has been auto-generated. Do not edit manually.
2
26
  # If you would like to contribute new rules, please use
3
27
  # cmd/generate/config/main.go and follow the contributing guidelines