border-customs 0.1.0 → 0.3.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
@@ -1,6 +1,6 @@
1
1
  # border
2
2
 
3
- A fail-closed pre-push gate for git, npm, and PyPI. It scans your repo for secrets and
3
+ A fail-closed pre-push gate for git, npm, PyPI, crates.io, and RubyGems. It scans your repo for secrets and
4
4
  supply-chain risk, then refuses to let anything leave the machine unless a fresh, unbroken
5
5
  check passed for exactly the state you are about to publish.
6
6
 
@@ -12,8 +12,19 @@ border check
12
12
 
13
13
  Requires Node >= 22 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
- in-process as a bundled dependency. Optional engines are listed in
16
- [Configuration](#configuration).
15
+ in-process as a bundled dependency. Each registry leg additionally shells out to its platform's
16
+ own publisher, which must be on `PATH` whenever that target is configured; a missing publisher
17
+ binary is exit 2, never a skipped leg:
18
+
19
+ | Registry target | Publisher border runs | Version the same-bytes proofs were measured on |
20
+ | --- | --- | --- |
21
+ | `npm` | `npm` | (unchanged since 0.1.0) |
22
+ | `pypi` | `python3 -m build` / `python3 -m twine` | (unchanged since 0.1.0) |
23
+ | `crates` | `cargo` | cargo 1.93.1 (rustup) |
24
+ | `rubygems` | `gem` | RubyGems 3.6.7 on ruby 3.3.8 |
25
+
26
+ Other toolchain versions of `cargo` and `gem` are untested by those byte-determinism proofs.
27
+ Optional engines are listed in [Configuration](#configuration).
17
28
 
18
29
  ## The problem it solves
19
30
 
@@ -39,9 +50,9 @@ pushed" includes the six things people forget:
39
50
  transmit, not just HEAD.
40
51
  - **Archives.** Tarballs and zips inside the repo are unpacked and scanned with
41
52
  `<archive>!<inner-path>` attribution.
42
- - **The published bytes, not the source tree.** `npm pack` and `python -m build` rewrite
43
- what ships. border packs/builds once into `.border/dist/`, scans those exact bytes, and
44
- re-hashes them again at publish time.
53
+ - **The published bytes, not the source tree.** `npm pack`, `python -m build`,
54
+ `cargo package`, and `gem build` rewrite what ships. border packs/builds once into
55
+ `.border/dist/`, scans those exact bytes, and re-hashes them again at publish time.
45
56
  - **The registry.** Pushing `package.json` at version `1.2.3` when `1.2.3` already exists
46
57
  on the registry is a supply-chain event (silent stale artifact, or squatted name). border
47
58
  fails the check on "version exists, bump required" and on foreign ownership of your name.
@@ -57,9 +68,9 @@ One sentence: **a PASS is a statement about a fingerprint, not about the past.**
57
68
  checkKey = sha256( headSha
58
69
  ⊕ porcelainDigest # every tracked/untracked/staged byte-change
59
70
  ⊕ rulesHash # config digest + vendored rules + engine versions + prompt template
60
- ⊕ exposureSet # sanitized remote URLs + npm/pypi name@version
71
+ ⊕ exposureSet # sanitized remote URLs + npm/pypi/crates/rubygems name@version
61
72
  ⊕ refSet # branch being pushed + every local tag
62
- ⊕ effectiveTargets ) # git, npm, pypi as configured for this run
73
+ ⊕ effectiveTargets ) # git, npm, pypi, crates, rubygems as configured for this run
63
74
  ```
64
75
 
65
76
  Move *any* of those six and the key moves, the stored PASS no longer covers the state, and
@@ -107,22 +118,33 @@ it, and the two registry-facing legs (pre-flight and publish) both have to agree
107
118
  against the history of every ref *and* against the object set a remote does not already
108
119
  have (the transmit set). Oversized-file, checked-in-binary, and notebook-output rules
109
120
  round it out.
110
- 7. **Registry pre-flight** (npm/PyPI targets only). Three outcomes per target: version
111
- already published ⇒ CRITICAL `version-exists` ("bump version required"); name owned by
112
- someone outside your `rules.authors` allow-list ⇒ CRITICAL `name-foreign-owner`;
113
- ambiguous ownership signals ⇒ also CRITICAL, because guessing wrong is how squats ship.
114
- Critically, **silence never means absent**: an empty stdout from `npm view`, a non-200
115
- non-404 PyPI response, a timeout, or unparseable JSON all fail closed as exit 2. An
116
- unreachable registry blocks the push instead of letting it run blind.
117
- 8. **Artifact stage.** If npm/PyPI targets are configured, packages are built **once** into
118
- `.border/dist/` (`npm pack --ignore-scripts`, `python -m build --no-isolation`), that
119
- exact byte-stream is scanned (gitleaks + secretlint over the extracted contents),
120
- manifest-diffed (lifecycle `preinstall`/`install`/`postinstall`/`prepare` hooks are
121
- CRITICAL; entries outside the `files` whitelist are HIGH; PyPI sdists get a
122
- `sdist-unexpected-file` HIGH because setuptools builds from the working tree and is
123
- `.gitignore`-blind), checked with `publint` / `twine check --strict`, and recorded as
124
- `{file, sha256, bytes}` in the ledger.
125
- At publish time the *same bytes* are re-hashed: any mismatch is exit 2, before the wire.
121
+ 7. **Registry pre-flight** (npm, PyPI, crates.io, and RubyGems targets). Three outcomes per
122
+ target: version already published ⇒ CRITICAL `version-exists` ("bump version required");
123
+ name owned by someone outside your `rules.authors` allow-list ⇒ CRITICAL
124
+ `name-foreign-owner`; ambiguous ownership signals ⇒ also CRITICAL, because guessing wrong
125
+ is how squats ship. Critically, **silence never means absent**: an empty stdout from
126
+ `npm view`, a non-200 non-404 PyPI response, a crates.io 403 (the API rejects requests
127
+ without a self-identifying User-Agent; border sends one, and a 403 still stops the gate
128
+ instead of being read as "not published"), a rubygems 404 whose body text does not match
129
+ one of that API's exact *absent* messages (its 404s are text, not JSON, and an
130
+ unrecognized one is never guessed), a timeout, or unparseable JSON all fail closed as
131
+ 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
133
+ `.border/dist/` (`npm pack --ignore-scripts`, `python -m build --no-isolation`,
134
+ `cargo package --allow-dirty --no-verify`, `gem build <name>.gemspec -o .border/dist/`),
135
+ that exact byte-stream is scanned (gitleaks + secretlint over the extracted contents),
136
+ manifest-diffed (lifecycle `preinstall`/`install`/`postinstall`/`prepare` hooks are
137
+ CRITICAL; entries outside the `files` whitelist are HIGH; PyPI sdists get a
138
+ `sdist-unexpected-file` HIGH because setuptools builds from the working tree and is
139
+ `.gitignore`-blind), the npm and PyPI artifacts are additionally checked with
140
+ `publint` / `twine check --strict`, and everything is recorded as
141
+ `{file, sha256, bytes}` in the ledger.
142
+ At publish time the *same bytes* are re-hashed: any mismatch is exit 2, before the wire.
143
+ The crates leg carries one extra assertion because `cargo publish` repackages from the
144
+ manifest instead of uploading the staged file: immediately before the spawn, border
145
+ re-runs the check's exact `cargo package` into a throwaway directory and refuses to
146
+ publish if the fresh digest differs from the certified `.crate`. `gem push` uploads the
147
+ recorded `.gem` byte-for-byte, so it needs no such re-assertion.
126
148
  9. **Report.** Findings carry `valueDigest` (sha256 of the matched value) and a masked
127
149
  snippet: fully blocked for short values, else `first4…last4`. Raw secret bytes never
128
150
  leave process memory. A per-run `TextSanitizer` holds the digest-to-value registry and
@@ -142,12 +164,56 @@ The **skip-ledger** is what makes the gate cheap enough to run on every push: a
142
164
  `border check` on an unchanged fingerprint replays the recorded verdict in well under a
143
165
  second. The replay is not blind trust. Before honoring a skip border re-derives the live
144
166
  fingerprint (including engine probes), refuses skips recorded in the other LLM mode, and
145
- re-packs npm artifacts to prove the recorded tarball digests still match fresh byte-for-byte
146
- (a dirty tree, a drifted `package.json`, or a changed npm version all force a full re-check;
147
- PyPI builds are not byte-reproducible, so their skip proof is head+porcelain equality, and
148
- publish still re-hashes the exact dist files). A corrupt ledger line is dropped with a loud
167
+ re-packs npm, crates, and rubygems artifacts to prove the recorded digests still match fresh
168
+ byte-for-byte (a dirty tree, a drifted `package.json`, or a changed npm version all force a
169
+ full re-check; `cargo package` is deterministic per HEAD and stamps the sha into
170
+ `.cargo_vcs_info.json`, and `gem build` is byte-deterministic only because RubyGems embeds a
171
+ fixed default date, which is why a gemspec that assigns `s.date` is rejected outright rather
172
+ than certified; PyPI builds are not byte-reproducible, so their skip proof is head+porcelain
173
+ equality, and publish still re-hashes the exact dist files). A corrupt ledger line is dropped with a loud
149
174
  warning, never a crash, and never a silently empty history.
150
175
 
176
+ ### The residue scan (0.3.0)
177
+
178
+ The artifact stage no longer treats every install-time hook as one blanket CRITICAL. Packed
179
+ and built bytes are additionally run through a **closed signature table** that classifies what
180
+ an installer *does*, per tier — the split behind opencode-ai's in-tree `postinstall.mjs`
181
+ (legit) vs. a `curl … | bash` rc-appender (not):
182
+
183
+ | Tier | Rule | Severity | One-liner |
184
+ | --- | --- | --- | --- |
185
+ | T0 | *(no finding)* | — | Silent install: recorded in the ledger, never flagged. |
186
+ | T1 | `residue-in-tree-hook` | MEDIUM | Hook that only touches its own package tree and matches one closed safe-shape signature (the opencode-ai anatomy). Unknown hook shapes keep the original CRITICAL row verbatim — T1 is the ONLY downgrade in 0.3.0. |
187
+ | T2 | `residue-install-download` | HIGH | Install-time network fetch: bytes that never passed registry review run on every consumer install. |
188
+ | T3 | `residue-out-of-tree-write` | HIGH | Writes outside the package dir: shell rc files, `profile.d`, `git config --global`, `authorized_keys`, `%APPDATA%`, PATH-carrier lines (`setx /F PATH`, `HKCU\Environment`, …). |
189
+ | T4 | `residue-persistence-primitive` | CRITICAL | Any reference to cron / `systemctl --user` / `launchctl` / XDG autostart / `schtasks` / registry Run keys in install-time code or anything it reaches. Malware class: no legitimate pass exists. |
190
+ | cross | `residue-cross-manager-write` | HIGH | A lifecycle/build script spawning a *foreign* package manager (`pip install --user` from npm…): files land on another ledger and reclaim by this ledger is impossible by construction. |
191
+ | pairing | `residue-pairing-missing` | HIGH | A `# BEGIN <id>` marker-block WRITE whose symmetric REMOVE path or CLI inverse is absent. Detection only: a verified pair still pays the T3 HIGH — waiver downgrade is 0.4.0's roundtrip valve. |
192
+ | gem | `residue-gem-unmatched-extension` | MEDIUM | A Ruby `extensions:`/build hook the classifier cannot resolve — surfaced, never silently clean. |
193
+
194
+ The pairing classes behind these rows (multi-channel PASS **C5** subset/ordering discipline;
195
+ **C9** interpolated-and-indirected root spellings; **C10** comment/quote-boundary shapes that
196
+ once silent-passed) are frozen with their evidence in
197
+ [src/artifacts/RESIDUE-CONTRACT.md](src/artifacts/RESIDUE-CONTRACT.md).
198
+
199
+ **Fail-closed:** a residue scan whose rule table or classifier sources cannot be read is exit 2,
200
+ never a silent pass — and the classifier source bytes are part of the ledger `rulesHash`, so
201
+ editing any signature invalidates every cached PASS even at the same commit.
202
+ **Opt-out:** `residue: { enabled: false }` in `border.yaml` (strict; unknown sibling keys exit 2)
203
+ hides exactly the rows above from the report and verdict and nothing else; flipping it changes
204
+ the fingerprint and forces a re-check rather than honoring a stale PASS.
205
+
206
+ **Boundary honesty.** Static analysis proves *capability*, not *fact*: a T2 row says the
207
+ installer *can* fetch and run remote bytes at install time, not that it shipped malware, and a
208
+ clean residue scan is an absence of matched signatures, never a certificate of benign intent.
209
+ The signature table is deliberately closed, and the list of shapes it **cannot see** —
210
+ content-writes, handle-variants, dead spellings, interpolation-then-slash, span-quirk
211
+ 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
213
+ 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.
216
+
151
217
  ## Architecture: `border push`
152
218
 
153
219
  `border push` is a state machine over per-target states, recomputed from live git queries,
@@ -160,8 +226,9 @@ never from cache:
160
226
  | `BLOCKED` | no PASS for this state (run `border check`), or a registry finding says stop |
161
227
 
162
228
  - Bare `border push` is a **DRY-RUN**: it prints the exact `git push --dry-run` /
163
- `npm publish` / `twine upload` lines it would run, refuses to act, and exits with the
164
- gate's verdict. `--yes` is what executes.
229
+ `npm publish` / `twine upload` / `cargo publish` / `gem push` lines it would run, refuses
230
+ to act, and exits with the gate's verdict. `--yes` is what executes. The real line shapes
231
+ per channel are in [What `border push` actually runs](#what-border-push-actually-runs).
165
232
  - Multi-remote git push is **all-or-nothing before anything moves**: every remote × every
166
233
  ref must be a fast-forward, checked up front. If any remote diverged, border prints
167
234
  `DIVERGED` with the offending shas and pushes nothing. **It never force-pushes; there is
@@ -169,12 +236,44 @@ never from cache:
169
236
  - Registry legs go through the same gate as push: a PASS record for the current key, then
170
237
  the artifact re-hash match against `.border/dist/`, then an immediate version-exists
171
238
  re-probe (the registry is allowed to change between check and publish), then the upload
172
- with `stdio: inherit` so `npm`/`twine` OTP and credential prompts reach you untouched.
173
- border never reads, stores, or handles registry tokens. Published versions are not
239
+ with `stdio: inherit` so `npm`, `twine`, `cargo`, and `gem` OTP and credential prompts
240
+ reach you untouched. border never reads, stores, or handles registry tokens (including
241
+ `CARGO_REGISTRY_TOKEN` and `GEM_HOST_API_KEY`, which the publisher tools own). Published versions are not
174
242
  retried on failure because a half-published version can never be republished.
175
243
  - If one remote of several succeeded before a failure, border says so explicitly (PARTIAL)
176
244
  and exits 1; a rerun of `border push --yes` picks up only the still-PENDING targets.
177
245
 
246
+ ### What `border push` actually runs
247
+
248
+ Every DRY-RUN line is rendered from the same argv source as the real publish spawn (one line
249
+ per recorded artifact row, execution order `git` remotes, then npm, PyPI, crates, rubygems).
250
+ For a repo configured like the five-target `border.yaml` in
251
+ [Configuration](#configuration), the plan prints as:
252
+
253
+ ```bash
254
+ $ border push
255
+ border DRY-RUN: no --yes, so nothing runs — this is the plan (m-R5-a) contract
256
+ DRY-RUN: git push --dry-run origin --follow-tags (https://github.com/acme/widgets.git)
257
+ DRY-RUN: npm publish .border/dist/widgets-1.2.3.tgz --registry https://registry.npmjs.org
258
+ DRY-RUN: twine upload --repository-url https://pypi.org .border/dist/widgets-1.2.3-py3-none-any.whl .border/dist/widgets-1.2.3.tar.gz
259
+ DRY-RUN: cargo publish --allow-dirty --no-verify
260
+ DRY-RUN: gem push .border/dist/widgets-1.2.3.gem --host https://gems.acme.example
261
+ ```
262
+
263
+ - `cargo publish` is the one line with no filename: the command takes no positional `.crate`
264
+ (verified against cargo 1.93.1 `--help`; it always repackages from the manifest), so the
265
+ pre-publish repackage digest-assert described in step 8 of the check pipeline is what pins
266
+ the upload back to the certified bytes. Divergence is exit 2 before anything is sent.
267
+ - `twine upload` is a single row carrying every recorded `.whl`/`.tar.gz`; `npm publish` and
268
+ `gem push` get one row per staged file.
269
+ - A `--registry` / `--repository-url` / `--host` flag appears only when the config sets it.
270
+ With `targets.npm.registry`, `targets.pypi.repository`, and `targets.rubygems.host` unset,
271
+ the lines are the bare `npm publish <file>`, `twine upload <files...>`, and
272
+ `gem push <file>`, and the tool's own defaults decide where bytes go.
273
+ - If the fingerprint has no PASS covering a target, dry-run cannot list its artifacts and
274
+ prints `DRY-RUN: <target> registry leg: run 'border check --force' first — dry-run cannot
275
+ list artifacts` in place of the command lines.
276
+
178
277
  ## Architecture: the LLM layer (optional, agent-executed)
179
278
 
180
279
  border itself **never calls any model API**. The `--llm` layer is a two-file handoff with
@@ -220,7 +319,7 @@ Every subcommand accepts the same global flags (verified against `border <cmd> -
220
319
  | Flag | Effect |
221
320
  | --- | --- |
222
321
  | `--config <path>` | config file; default `./border.yaml` |
223
- | `--targets <list>` | comma-separated `git,npm,pypi` subset restricting this run's scope; a named target that is not configured is exit 2 |
322
+ | `--targets <list>` | comma-separated `git,npm,pypi,crates,rubygems` subset restricting this run's scope; a named target that is not configured is exit 2 |
224
323
  | `--force` | ignore the skip-ledger, re-run the full check |
225
324
  | `--yes` | execute mutations (push only); without it a push is always DRY-RUN |
226
325
  | `--require-engine <list>` | replaces the required-engine set from config; unknown or unprobeable names degrade the run (exit 2) |
@@ -245,7 +344,7 @@ matrix (`gitleaks` 0/1, `trufflehog` 0/183, `secretlint` 0/1); *anything* else,
245
344
 
246
345
  Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
247
346
  rejected at load (a typo in a gate config is a gate you did not ask for). `${VAR}`
248
- expansion applies only to remote URLs and registry/repository values, and an unset variable
347
+ expansion applies only to remote URLs and registry/repository/host values, and an unset variable
249
348
  is a hard error, never an empty string.
250
349
 
251
350
  Minimal, a git-only repo:
@@ -268,7 +367,8 @@ rules:
268
367
 
269
368
  `rules` is required by design: a gate with silent defaults is a gate you did not ask for.
270
369
 
271
- A repo that publishes a package, with the full surface:
370
+ A repo that publishes a package, with the full surface (every one of the five channels, new in
371
+ 0.2.0; a config without `crates`/`rubygems` behaves exactly as it did on 0.1.0):
272
372
 
273
373
  ```yaml
274
374
  version: 1
@@ -281,6 +381,13 @@ targets:
281
381
  registry: https://registry.npmjs.org # optional; any npm-protocol registry works
282
382
  pypi:
283
383
  repository: https://pypi.org # optional; TestPyPI or private indexes work
384
+ crates: # optional; public crates.io only — there is
385
+ name: widgets # deliberately no url/host key; overrides
386
+ # the Cargo.toml [package] name when set
387
+ rubygems: # optional; exactly one *.gemspec at HEAD,
388
+ name: widgets # this only disambiguates multi-gemspec repos
389
+ host: "${GEM_HOST}" # optional; any gemcutter-compatible push host,
390
+ # ${VAR} expansion allowed on this field only
284
391
  rules:
285
392
  authors: # identity allow-list
286
393
  emails: [devs@acme.example]
@@ -294,6 +401,8 @@ allow: # enumerated suppressions, never bla
294
401
  - { rule: "junk-artifact", match: "*", file: "test/fixtures/**" }
295
402
  engines:
296
403
  require: [gitleaks, secretlint] # trufflehog: true adds the third-party engine
404
+ residue:
405
+ enabled: true # default; false hides ONLY the residue-* rows
297
406
  ```
298
407
 
299
408
  Notes that change behavior:
@@ -302,11 +411,32 @@ Notes that change behavior:
302
411
  history/tree/identity/artifact legs still run, only the exposure set is empty. A
303
412
  completely missing config with real remotes falls back to config-from-`git remote` with
304
413
  a loud stderr warning.
414
+ - `targets.crates` reads its coordinates from `git show HEAD:Cargo.toml` and accepts only a
415
+ literal `[package] version`; a workspace-inherited or computed version is exit 2 telling
416
+ you to set a literal one. There is no crates.io host/registry key *by design*: the channel
417
+ is pinned to public crates.io, and an unknown key there is rejected at load rather than
418
+ quietly ignored (or silently trusted as a private-registry config the pre-flight cannot
419
+ probe).
420
+ - `targets.rubygems` discovers `*.gemspec` files at HEAD; exactly one may exist unless
421
+ `targets.rubygems.name` selects one, and `s.name`/`s.version` must be literals (computed
422
+ versions are exit 2, border never evaluates gemspec code to find out). A gemspec assigning
423
+ `s.date` is rejected wholesale: `gem build` is byte-deterministic precisely because it
424
+ embeds a fixed default date, so an explicit `s.date` breaks the digest proof border would
425
+ otherwise be certifying. `host` redirects both the probes and `gem push --host`, and is
426
+ the only `${VAR}`-expandable field of the two new targets.
305
427
  - Every `allow` entry must be scoped (a `file` pin); `{rule: "*", match: "*"}` with no
306
428
  file is rejected at load. Suppressed findings are enumerated in the report's
307
429
  `allow-hits` section, so an exit 0 never hides what it hid.
308
430
  - `rules.hosts/ips/pathPatterns` feed the same matcher pipeline as the built-ins, so your
309
431
  patterns get the same archive attribution and the same allow-listing semantics.
432
+ - `residue.enabled` (0.3.0) is the only sanctioned skip of the residue scan; its default is
433
+ `true`, an unknown sibling under `residue:` is a typed exit 2, and — unlike an `allow`
434
+ entry, which suppresses findings while *listing* them — turning it off changes the
435
+ fingerprint, so no PASS certified with residue on can ever skip a run that has it off.
436
+ - 0.2.0 adds the `crates` and `rubygems` channels; existing configs are unchanged. Channels
437
+ are opt-in by the presence of their `targets.<id>` section, and the ledger's append-only
438
+ format means old records keep parsing: `confirmedVia` gained two enum members
439
+ (`crates-json`, `rubygems-json`), 0.1.x ledgers are accepted as-is and never rewritten.
310
440
 
311
441
  ## Integration
312
442
 
@@ -340,6 +470,17 @@ same one that wrote the commit) how to run the five subcommands, how to produce
340
470
  - **Not an escape hatch.** There is no force-push flag, no `--i-know-what-im-doing`, no
341
471
  per-run expiry of a missing PASS. The refusal paths are the product. If a check cannot be
342
472
  trusted (engine gone, registry unreachable), the result is stop, not proceed.
473
+ - **Not a config runner.** You cannot define a custom channel or override what border
474
+ shells out to (a `push.command`-style escape hatch is deliberately absent). A user-defined
475
+ command would void the two properties the gate exists to provide: the same-bytes proof
476
+ (only a built-in channel descriptor knows which artifact bytes the check certified and can
477
+ re-hash exactly those before the wire) and the fail-closed registry pre-flight (only a
478
+ built-in knows each registry's response polarity: which status and which body string mean
479
+ *absent*, and that everything else is silence, which never means absent). Free-form
480
+ commands would turn "a PASS covers this exact state" into "a PASS covers whatever your
481
+ script happened to run": a config runner wearing a gate's clothes. Channels are built-in
482
+ only; adding one means shipping a descriptor whose probe, packaging, and byte-determinism
483
+ were measured against the real registry first.
343
484
  - **Not a replacement for review or branch protection.** It gates *this machine's* push
344
485
  surface; server-side controls still own everything else.
345
486
 
@@ -352,20 +493,60 @@ same one that wrote the commit) how to run the five subcommands, how to produce
352
493
  registry stdout, unparsed versions, non-translatable exit codes, unresolvable ownership,
353
494
  corrupt-but-present rules inputs, all become exit 2 or a CRITICAL, never a pass.
354
495
  - **No telemetry, no callbacks.** `border check` touches the network only for the
355
- registry pre-flight, against URLs you configured (`npmjs.org`/`pypi.org` defaults, fully
356
- overridable to private indexes).
496
+ registry pre-flight, against the URLs the channels define: `registry.npmjs.org`,
497
+ `pypi.org`, and `rubygems.org` are overridable (private indexes; `targets.rubygems.host`),
498
+ and the crates.io leg is pinned to public `crates.io` with no override by design.
357
499
  - **No credential handling.** Subprocess publishes inherit stdio so tokens and OTP prompts
358
- go between you and `npm`/`twine`, never through border.
500
+ go between you and `npm`/`twine`/`cargo`/`gem`, never through border.
359
501
  - **State is inert.** Everything border writes lives in `.border/` (ledger, run archives,
360
502
  dist, lock), which carries its own `.gitignore` with `*` so a `git add -A` cannot stage
361
503
  it, and which every scan leg refuses to treat as a finding source or an allow-list
362
504
  target. A repo that *tracks* `.border/**` is itself a CRITICAL finding. A single-writer
363
505
  lock makes concurrent runs exit 2 instead of racing.
364
506
 
507
+ ## Changelog
508
+
509
+ ### 0.3.0 (2026-09-09)
510
+
511
+ - **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
512
+ family of seven closed `residue-*` rules across tiers T1–T4 plus cross-manager, pairing,
513
+ and gem-unmatched lanes (see "The residue scan"): a hook matching the closed T1 safe-shape
514
+ signature drops to MEDIUM; every unknown shape keeps the original CRITICAL row verbatim.
515
+ Pairing (`# BEGIN <id>` marker blocks) is **detected only** — `residue-pairing-missing`
516
+ fires on the orphaned WRITE, a verified pair still pays its T3 HIGH; the waiver downgrade
517
+ path is the 0.4.0 roundtrip valve. Detection classes: C5 pairing discipline, C9 indirection
518
+ spellings, C10 comment/quote boundaries — evidence frozen in `src/artifacts/RESIDUE-CONTRACT.md`.
519
+ - New `residue: { enabled }` config key (default on; strict; the ONLY skip path for the family,
520
+ and flipping it invalidates cached PASS rows because the toggle rides the fingerprint). The
521
+ residue rule table and classifier sources are now part of the ledger `rulesHash`: editing a
522
+ signature forces a re-check even at an unchanged commit and tree.
523
+ - Boundary stated plainly: static analysis proves *capability*, not *fact*; border is not a
524
+ malware sandbox and never executes what it reads.
525
+
526
+ ### 0.2.0 (2026-09-05)
527
+
528
+ - Two new push channels, same doctrine end to end: **crates** (public crates.io only;
529
+ literal `Cargo.toml` coordinates; pre-publish repackage digest-assert) and **rubygems**
530
+ (rubygems.org or any `targets.rubygems.host` gemcutter-compatible host; literal-only
531
+ gemspecs; `s.date` rejection).
532
+ - Platforms are now descriptors in a channel registry: each channel owns its config schema,
533
+ env expansion, coordinate reading, probe, artifact stage, publish argv, and ledger fields
534
+ in one file. The git/npm/pypi legs share the same publish core they already used; their
535
+ behavior is unchanged.
536
+ - Ledger `confirmedVia` gained `crates-json` and `rubygems-json`; 0.1.x ledgers parse
537
+ unchanged.
538
+ - New prerequisites when those targets are configured: `cargo` on `PATH` (measured against
539
+ 1.93.1 via rustup) and `gem` (measured against RubyGems 3.6.7 on ruby 3.3.8); other
540
+ versions are untested by the gate's same-bytes proofs.
541
+
542
+ ### 0.1.0
543
+
544
+ - Initial release: git, npm, and PyPI push channels behind the fingerprint PASS gate.
545
+
365
546
  ## License
366
547
 
367
548
  MIT, see [LICENSE](LICENSE).
368
549
 
369
550
  ## 中文概要
370
551
 
371
- border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install -g border-customs` 安装,在仓库里跑 `border check`。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 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 的凭据经 stdio 透传,border 从不触碰。可选 LLM 层 border 自身从不调用模型 API:`llm-request` 导出掩码审阅包,`llm-ingest` 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
552
+ 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 许可,无遥测,除你配置的注册表预检外不联网。
@@ -0,0 +1,228 @@
1
+ // provenance: original clean-room implementation per .omo/plans/border-push-channels.md todo C3
2
+ //
3
+ // cargo package-once artifact pipeline (plan C3 Scope + CHANNEL-CONTRACTS.md §a/§c).
4
+ // ONE `cargo package --allow-dirty --no-verify` into <repo>/.border/dist/ is the
5
+ // single source of bytes for the whole gate chain: the recorded {file, sha256,
6
+ // bytes} is what the push core re-hashes before publish and todo 14 pins in the
7
+ // ledger — nothing downstream rebuilds. The package is byte-deterministic for a
8
+ // given HEAD (spike §a: fixed tar member mtimes, gzip MTIME 0, digest bound to
9
+ // the .cargo_vcs_info.json sha1) — which is what makes the pre-publish repack
10
+ // digest-assert and the freshness repack sound. The .crate is a raw gzip tar
11
+ // that gitleaks 8.30.1 provably does NOT scan natively (spike §c), so it is
12
+ // extracted to a throwaway sandbox under .border/tmp/ and the EXTRACTED TREE is
13
+ // scanned (gitleaks dir + secretlint paths); the sandbox is removed on success
14
+ // AND failure. Build output lands in CARGO_TARGET_DIR under .border/tmp/
15
+ // (plan) so no target/ dir can pollute the repo tree.
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
17
+ import { createHash } from "node:crypto";
18
+ import { join, relative } from "node:path";
19
+ import { spawnSync } from "node:child_process";
20
+
21
+ import type { BorderConfig } from "../config.ts";
22
+ import type { Finding } from "../findings.ts";
23
+ import { BORDER_STATE_DIR, ensureStateDir } from "../check/lock.ts";
24
+ import { extractArchive, removeSandbox } from "./extract.ts";
25
+ import { residueFindings } from "./residue.ts";
26
+ import { residueCratesHits } from "./residueRust.ts";
27
+ import { scanTree } from "../engines/gitleaks.ts";
28
+ import { scanPaths, type SecretlintMode } from "../engines/secretlint.ts";
29
+ import { EngineMissingError, EngineRunError, type EngineOptions } from "../engines/support.ts";
30
+ import type { TextSanitizer } from "../redact.ts";
31
+
32
+ /** Native-finding target label shared across the artifact-stage scans. */
33
+ export const CRATES_TARGET_LABEL = "artifact";
34
+
35
+ export type CrateArtifactRecord = {
36
+ /** repo-relative path of the single packed crate (`.border/dist/<file>`). */
37
+ readonly file: string;
38
+ readonly sha256: string;
39
+ readonly bytes: number;
40
+ };
41
+
42
+ export type CrateStageOptions = {
43
+ readonly repoDir: string;
44
+ readonly cfg: BorderConfig;
45
+ readonly env?: EngineOptions["env"];
46
+ readonly sanitizer?: TextSanitizer;
47
+ readonly skipGitleaks?: boolean;
48
+ readonly skipSecretlint?: boolean;
49
+ readonly secretlintMode?: SecretlintMode;
50
+ /** Exact cargo binary seam (tests / pinned toolchains); default "cargo" on PATH. */
51
+ readonly cargoBinPath?: string;
52
+ /** shared engine/extract timeout. */
53
+ readonly timeoutMs?: number;
54
+ readonly cargoTimeoutMs?: number;
55
+ };
56
+
57
+ export type CrateStageResult = {
58
+ readonly findings: readonly Finding[];
59
+ readonly artifact: CrateArtifactRecord | null;
60
+ };
61
+
62
+ function scopeFindingPath(f: Finding, extractDir: string): Finding {
63
+ if (f.path === undefined) return f;
64
+ const bang = f.path.indexOf("!");
65
+ if (bang > 0) {
66
+ const rel = relative(extractDir, f.path.slice(0, bang));
67
+ if (!rel.startsWith("..")) return { ...f, path: rel === "" ? f.path.slice(bang + 1) : `${rel}${f.path.slice(bang)}` };
68
+ }
69
+ const rel = relative(extractDir, f.path);
70
+ return rel === "" || rel.startsWith("..") ? f : { ...f, path: rel };
71
+ }
72
+
73
+ function listFiles(dir: string): string[] {
74
+ const out: string[] = [];
75
+ const stack = [dir];
76
+ while (stack.length > 0) {
77
+ const cur = stack.pop() as string;
78
+ for (const ent of readdirSync(cur, { withFileTypes: true })) {
79
+ const p = join(cur, ent.name);
80
+ if (ent.isDirectory()) stack.push(p);
81
+ else if (ent.isFile()) out.push(p);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Read `name` + `version` from the WORKING-TREE Cargo.toml — cargo packages
88
+ * the working tree, not HEAD, so the crate identity is the tree's identity. */
89
+ function manifestIdentity(repoDir: string, cfg: BorderConfig): { name: string; version: string } {
90
+ const manifestPath = join(repoDir, "Cargo.toml");
91
+ if (!existsSync(manifestPath)) {
92
+ throw new EngineRunError("targets.crates is configured but the repo has no Cargo.toml to package", null);
93
+ }
94
+ const raw = readFileSync(manifestPath, "utf8");
95
+ const pkg = /^\[package\]/m.test(raw);
96
+ if (!pkg) throw new EngineRunError("Cargo.toml has no [package] section — nothing cargo package can publish", null);
97
+ const name = cfg.targets.crates?.name ?? /^name\s*=\s*"([^"]+)"/m.exec(raw)?.[1];
98
+ if (name === undefined) {
99
+ throw new EngineRunError("Cargo.toml has no literal package name in the [package] section (and no targets.crates.name override)", null);
100
+ }
101
+ const version = /^version\s*=\s*"([^"]+)"/m.exec(raw)?.[1];
102
+ if (version === undefined) {
103
+ throw new EngineRunError(
104
+ "Cargo.toml has no literal package version — workspace-inherited or computed versions (version.workspace = true) are not supported; publish by setting a literal version",
105
+ null,
106
+ );
107
+ }
108
+ return { name, version };
109
+ }
110
+
111
+ /**
112
+ * Run the crates artifact stage. Throws EngineMissingError/EngineRunError
113
+ * (exit 2 via translateError) for anything that must NOT read as "clean":
114
+ * cargo absent, package failure, missing .crate output.
115
+ */
116
+ export async function runCargoArtifactStage(o: CrateStageOptions): Promise<CrateStageResult> {
117
+ if (o.cfg.targets.crates === undefined) {
118
+ throw new EngineRunError("runCargoArtifactStage invoked without targets.crates configured — pipeline bug", null);
119
+ }
120
+ const { repoDir } = o;
121
+ const bin = o.cargoBinPath ?? "cargo";
122
+ const identity = manifestIdentity(repoDir, o.cfg);
123
+ const crateName = `${identity.name}-${identity.version}.crate`;
124
+
125
+ const versionCheck = spawnSync(bin, ["--version"], {
126
+ encoding: "utf8",
127
+ stdio: "ignore",
128
+ ...(o.env === undefined ? {} : { env: { ...o.env } }),
129
+ });
130
+ if (versionCheck.error !== undefined || versionCheck.status !== 0) {
131
+ throw new EngineMissingError(
132
+ `cargo is not available (\`${bin} --version\` failed) — install Rust via rustup (https://rustup.rs) or pass cargoBinPath before running border check (border exit 2)`,
133
+ );
134
+ }
135
+
136
+ ensureStateDir(repoDir);
137
+ const stateDir = join(repoDir, BORDER_STATE_DIR);
138
+ const distDir = join(stateDir, "dist");
139
+ const targetDir = join(stateDir, "tmp", "cargo-target");
140
+ mkdirSync(distDir, { recursive: true });
141
+ rmSync(targetDir, { recursive: true, force: true }); // stale build output from an interrupted run
142
+ mkdirSync(targetDir, { recursive: true });
143
+
144
+ try {
145
+ const result = spawnSync(bin, ["package", "--allow-dirty", "--no-verify"], {
146
+ cwd: repoDir,
147
+ encoding: "utf8",
148
+ timeout: o.cargoTimeoutMs ?? 120_000,
149
+ env: {
150
+ ...(o.env === undefined ? process.env : { ...o.env }),
151
+ CARGO_TARGET_DIR: targetDir,
152
+ },
153
+ });
154
+ if (result.error !== undefined || result.status !== 0) {
155
+ throw new EngineRunError(
156
+ `cargo package failed (exit ${String(result.status)}) — nothing to gate: ${(result.stderr ?? "").trim().slice(0, 400)}`,
157
+ result.status,
158
+ );
159
+ }
160
+
161
+ // cargo writes the crate to <target-dir>/package/<name>-<version>.crate
162
+ const packedDir = join(targetDir, "package");
163
+ const packed = join(packedDir, crateName);
164
+ if (!existsSync(packed)) {
165
+ throw new EngineRunError(`cargo package produced no ${crateName} in ${relative(repoDir, packedDir)} — failing closed`, null);
166
+ }
167
+ const dist = join(distDir, crateName);
168
+ renameSync(packed, dist);
169
+
170
+ const bytes = readFileSync(dist);
171
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
172
+
173
+ // raw .crate is a gitleaks native miss (contract §c) — scan the EXTRACTED tree
174
+ const findings: Finding[] = [];
175
+ const extractDir = join(stateDir, "tmp", sha256.slice(0, 8));
176
+ rmSync(extractDir, { recursive: true, force: true });
177
+ mkdirSync(extractDir, { recursive: true });
178
+ try {
179
+ extractArchive(dist, extractDir, {
180
+ ...(o.env !== undefined ? { env: o.env } : {}),
181
+ ...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
182
+ });
183
+ const relPaths = listFiles(extractDir).map((p) => relative(extractDir, p));
184
+ if (relPaths.length === 0) throw new EngineRunError("extracted artifact is empty — unusable cargo package output", null);
185
+
186
+ if (o.skipGitleaks !== true) {
187
+ const tree = scanTree({
188
+ dir: extractDir,
189
+ stateDir,
190
+ target: CRATES_TARGET_LABEL,
191
+ ...(o.env !== undefined ? { env: o.env } : {}),
192
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
193
+ ...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
194
+ });
195
+ for (const f of tree) findings.push(scopeFindingPath(f, extractDir));
196
+ }
197
+ if (o.skipSecretlint !== true) {
198
+ const linted = await scanPaths({
199
+ dir: extractDir,
200
+ files: relPaths,
201
+ target: CRATES_TARGET_LABEL,
202
+ rules: o.cfg.rules,
203
+ ...(o.secretlintMode !== undefined ? { mode: o.secretlintMode } : {}),
204
+ ...(o.env !== undefined ? { env: o.env } : {}),
205
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
206
+ ...(o.timeoutMs !== undefined ? { timeoutMs: o.timeoutMs } : {}),
207
+ });
208
+ findings.push(...linted);
209
+ }
210
+ // R3a residue leg (plan §57): build.rs OUT_DIR confinement + [build-dependencies] net scan
211
+ // + artifact-wide T4; inner rels keep the `<name>-<version>/` wrapper (crates convention).
212
+ findings.push(...residueFindings(residueCratesHits(extractDir, relPaths), {
213
+ root: "",
214
+ identity: crateName,
215
+ ...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
216
+ }));
217
+ } finally {
218
+ removeSandbox(extractDir);
219
+ }
220
+
221
+ return {
222
+ findings,
223
+ artifact: { file: `${BORDER_STATE_DIR}/dist/${crateName}`, sha256, bytes: bytes.length },
224
+ };
225
+ } finally {
226
+ rmSync(targetDir, { recursive: true, force: true });
227
+ }
228
+ }