border-customs 0.8.3 → 0.8.5

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
@@ -40,6 +40,10 @@ source checkout. Every check and push record carries `engineBuild` — the ident
40
40
  `border --version` prints. A `.dirty` suffix means the verdict came from uncommitted code, and
41
41
  records from one repo carrying different `engineBuild` values mean the audit trail is mixed:
42
42
  both are findings an auditor should chase, which is exactly why the identity is on the record face.
43
+ Freshness telemetry (the `global-resolution` WARN rung, drift-watch rc 3, and heartbeat lines) is
44
+ read-only information. Pinned consumption governs the install and execution surfaces: running an
45
+ older, deliberately chosen version is a legitimate steady state, not a defect. The decision to
46
+ update always stays with the human operator. (Owner-approved clarification, 2026-11-06.)
43
47
 
44
48
  ## The problem it solves
45
49
 
@@ -59,7 +63,11 @@ pushed" includes the six things people forget:
59
63
 
60
64
  - **Untracked and dirty files are inputs too.** History scanning alone misses the `.env`
61
65
  that was never committed but will ride along in a `git add -A`. border fingerprints the
62
- full `git status --porcelain` digest and scans the working tree directly.
66
+ full `git status --porcelain` digest and scans the working tree directly. One carve-out is
67
+ provably unpushable and demoted to INFO (never silently dropped): paths that are
68
+ `.gitignore`'d **and** untracked — `git add` honours `.gitignore`, so they can only reach a
69
+ commit through a deliberate `git add -f`, which moves them into the tracked surface where
70
+ the full gate applies. Blocking on them would train operators to mute the gate.
63
71
  - **Deleted-but-pushed blobs.** A secret removed in a later commit still ships in the
64
72
  history of the ref being pushed. The history leg scans everything the push would
65
73
  transmit, not just HEAD.
@@ -129,9 +137,13 @@ it, and the two registry-facing legs (pre-flight and publish) both have to agree
129
137
  (`.omo/**`, `**/transcripts/**`, `*.session.jsonl`, `opencode.json(c)`, `.opencode/**`,
130
138
  env files except `.env.example`/`.env.sample`, junk like `probe*`/`*.rej`/
131
139
  `node_modules/**`, plus your `rules.pathPatterns` through the same compiler). Identity
132
- allow-listing over commit authors, merge committers, and annotated-tag taggers, checked
133
- against the history of every ref *and* against the object set a remote does not already
134
- have (the transmit set). Oversized-file, checked-in-binary, and notebook-output rules
140
+ allow-listing over commit authors, merge committers, and annotated-tag taggers, split by
141
+ *exposure class* against each configured remote's **live** `git ls-remote` (never a stale
142
+ local tracking ref): objects leaving the machine for the first time are CRITICAL
143
+ `identity-not-allowlisted`; offenders the remote already carries everywhere are recorded as
144
+ INFO `identity-already-public` without remediation — border never advises rewriting
145
+ published history. Offenders plus an unreachable remote exit 2 (the exposure class is
146
+ never guessed); no configured remote means first-push semantics (all fresh). Oversized-file, checked-in-binary, and notebook-output rules
135
147
  round it out.
136
148
  7. **Landing-grade tip lens.** The exact machinery `border push` uses to verify a landing
137
149
  (`scanTipTree`: the tip tree materialized from git objects into a throwaway directory,
@@ -608,38 +620,10 @@ dropped file to stderr with the sha256 of its original bytes
608
620
  (`border scan: crates envelope normalized: <file> sha256=<hex>`); the rebuild regenerates
609
621
  them from the normalized manifest, so no scanned byte is silently lost.
610
622
 
611
- ### Engines in consumer installs
612
-
613
- `dist/index.js` is one self-contained esbuild bundle and every runtime asset (vendored
614
- gitleaks rules, prompt template, the secretlint fingerprint snapshot) ships inside `dist/`,
615
- so after `npm i -D border-customs` (or `-g`) the engine surface is:
616
-
617
- - **publint** — installed automatically: it is an exact-pinned runtime dependency of the
618
- package, invoked via bin resolution (package-local `node_modules/.bin`, the hoisted
619
- project-level `node_modules/.bin`, then `PATH`, then `~/.local/bin`). The
620
- `publint-fail` leg of the npm artifact stage works out of the box in a consumer install.
621
- - **secretlint** — no consumer action, ever: the default lint runs **in-process** with the
622
- `@secretlint/*` rule modules already declared as border's own runtime dependencies
623
- (`src/engines/secretlint.ts` header). The `mode: "cli"` fallback that spawns a
624
- `secretlint` binary is border-internal (test transport); it is not reachable from
625
- `border.yaml` or any flag, so installing `secretlint` into your project changes nothing
626
- for border. If the engine probe cannot read the bundled lock snapshot
627
- (`dist/assets/package-lock.json`), the run fails closed as `DEGRADED-ENGINE` exit 2 —
628
- never a silent pass.
629
- - **gitleaks** — stays external, same posture as `git`: border vendors the 8.30.1 rule
630
- config but the binary is yours (on `PATH` or `~/.local/bin/gitleaks`; see the
631
- requirements paragraph at the top and `src/engines/ADAPTER-CONTRACT.md`). Missing
632
- gitleaks means `border check` records a CRITICAL `DEGRADED-ENGINE` finding and exits 2,
633
- and `border scan` exits 2 with `engine binary 'gitleaks' not found; border fails
634
- closed`. Neither command degrades to a skip.
635
- - **trufflehog** — optional third engine, external binary, same posture as gitleaks once
636
- enabled: `engines.trufflehog: true` puts it in the required set, and an absent binary
637
- degrades the run to exit 2 exactly like a missing gitleaks.
638
-
639
- There is no "engine absent ⇒ leg silently skipped" state anywhere: missing required
640
- engines are exit 2 (`2` = the gate could not answer, see
641
- [Exit codes](#exit-codes-the-contract)), and the ledger refuses to write a PASS for a
642
- degraded run.
623
+ ## Engines
624
+
625
+ Engine posture per scanner — vendored vs external, version matrices,
626
+ consumer-install behavior: [docs/engines.md](docs/engines.md).
643
627
 
644
628
  ## Configuration
645
629
 
@@ -839,7 +823,7 @@ plugin entry ships as `exports["./server"]`):
839
823
  ```jsonc
840
824
  {
841
825
  // pin the exact version — an @latest channel hits the registry on every cold start
842
- "plugin": ["border-customs@0.8.3"]
826
+ "plugin": ["border-customs@0.8.5"]
843
827
  }
844
828
  ```
845
829
 
@@ -877,7 +861,12 @@ argv-only (no shell; 300 s cap, 64 KiB per stream) restricted to the closed comm
877
861
  `check`, `push`, `status`, `llm-request`, `llm-ingest`, `scan`, `roundtrip`, `exfil`, `--help`, and
878
862
  renders the exit-code contract unchanged: 0 pass, 1 gate-blocked or partial push, 2 the gate
879
863
  could not answer — which since 0.5.1 also covers "no border CLI found or verified": the tool
880
- answers exit 2 cannot-answer and spawns nothing else. The tool REFUSES `--yes` — a real push
864
+ answers exit 2 cannot-answer and spawns nothing else. Every result carries a
865
+ `face: <resolved candidate> version=<--version stamp>` line: the identity handshake proves *which
866
+ binary*, the stamp line makes *which version* observable on every call (a PATH-fallback to an older
867
+ published CLI is loud, never silent). `BORDER_REQUIRE_PKG_VERSION=1` escalates that to a gate: the
868
+ resolved CLI must report the plugin package's own version or the call is refused as cannot-answer
869
+ with nothing spawned. The tool REFUSES `--yes` — a real push
881
870
  is the human gate, terminal-only, and a bare `border push` from the session is DRY-RUN by the
882
871
  CLI's own contract anyway. `/border` self-registers via the plugin `config` hook with `??=`, so
883
872
  a user file command of the same name — including a Route-A `commands/border.md` — stays
@@ -892,16 +881,24 @@ fails); plugin load failures are silent, so judge by the HTTP responses, never b
892
881
  code.
893
882
 
894
883
  `border opencode inspect [--json]` audits this machine's **V1 load surface** as one gate-owned
895
- command: six read-only aspects — cache self-consistency (package.json vs plugin marker line),
884
+ command: seven read-only aspects — cache self-consistency (package.json vs plugin marker line),
896
885
  spec freshness (exact pin offline; dist-tag against the registry), the 0.5.1 identity handshake
897
886
  replayed against the CLI resolver's candidate order, Route A byte parity, Route A∧B dual-registration
898
- collision, and the scope declaration — aggregated fail-closed: any FAIL exits 1, else any CANNOT
899
- exits 2, all PASS exits 0. The certificate boundary is printed, not implied: `--json` carries
887
+ collision, the scope declaration, and global-resolution (which CLI build answers on which face —
888
+ `BORDER_BIN`, packaged dist, PATH — and whether the active face lags the registry's latest) —
889
+ aggregated fail-closed: any FAIL exits 1, else any CANNOT exits 2, else 0. The seventh aspect's lag
890
+ rung is **WARN — informational**: lag under pinned consumption is a legitimate steady state, so WARN
891
+ never moves the aggregate or any purge trigger; registry-unreachable for this aspect stays named.
892
+ `BORDER_INSPECT_OFFLINE=1` skips the freshness leg explicitly on air-gapped machines — the aspect
893
+ then reports WARN "skipped", never fresh (an unevaluated face is never reported as fresh).
894
+ The certificate boundary is printed, not implied: `--json` carries
900
895
  `scope:"v1"`, and V2 load surfaces (project-local `.opencode/plugins/`, npm-prefix layouts, lazy
901
896
  activation) are a DECLARED BLIND SPOT pending V2 stabilization — a PASS is never evidence that
902
897
  "the whole machine is clean". The tool never writes: `--purge` lives in the drift-watch cron shell,
903
- which is now a thin consumer of `inspect --json` with the rc contract unchanged
904
- (FRESH 0 / DRIFT 1 / NOT-CACHED 0 / CANNOT 2).
898
+ which is now a thin consumer of `inspect --json` with rc contract FRESH 0 / DRIFT 1 / NOT-CACHED 0 /
899
+ CANNOT 2 / **GLOBAL-WARN or GLOBAL-UNREACHABLE 3** (warn-only by construction: the cron purge guard
900
+ acts on rc 1 only, and rc 3 can never reach it), plus an optional `--heartbeat <path>` that appends
901
+ one `ISO / verdict / rc / bin` line per run.
905
902
 
906
903
  ## What this is not
907
904
 
@@ -969,271 +966,9 @@ and every pull request, plus version single-source, typecheck, build, and audit
969
966
 
970
967
  ## Changelog
971
968
 
972
- ### 0.8.3 (2026-11-02)
973
- - Fix: `exfil-cred-location` is now syntax-aware in JS/TS sources (handoff b61b42a) —
974
- `.env` tokens count only inside string-literal spans (TypeScript compiler API, shipped
975
- as a runtime dependency, bundle stays external-pinned); member accesses (`opts.env`),
976
- short receivers and comment text are no longer reported. Markdown/text keep the
977
- bare-mention behavior; every other rule is untouched. All tree faces (check, history,
978
- tip lens) share the guard; parse failure ⇒ old behavior + one stderr note, exit codes
979
- unchanged. Rules fingerprint rotated (cached PASSes re-verify once).
980
-
981
- ### 0.8.2 (2026-11-01)
982
- - Production dependency graph now measures ZERO advisories (`npm audit --omit=dev`): the
983
- pattern rule is vendored (`src/engines/borderPatternRule.ts`, MIT attribution; drops the
984
- unpatchable braces/micromatch + fast-uri chain) and `tool` — an identity function — is
985
- vendored in `plugin/border.ts`, moving `@opencode-ai/plugin` (and its effect-beta → toml
986
- chain) to devDependencies; live-host probe tests keep the activation proof. The CI audit
987
- gate tightens from critical to HIGH so this cannot rot.
988
- - `border sandbox init|pin|status|unpin` — the opt-in pin helper from the Q1 ruling
989
- (machine store only, 0600 atomic writes, §3 self-verification with rollback of its own
990
- writes; deliberately absent from the plugin's agent-reachable command set).
991
- - Security tier for execution-deciding keys (`src/tier.ts`, sibyl §3⁗/§3⁖/§3‡):
992
- `sandboxImage`/`allowBuilds` honor only from fd-chain-qualified machine-store or out-of-repo
993
- `--config` sources; repo declarations, lax-mode files, and hijacked `XDG_CONFIG_HOME` are
994
- named refusals (`sandbox-config-shadowed`/`sandbox-config-unqualified`); the
995
- `BORDER_ALLOW_BUILDS` environment channel is REMOVED (never released, so no migration);
996
- passwd-derived canonical store root — `$HOME` spoofing cannot move it.
997
- - Sandboxed artifact builds: optional `targets.<ch>.sandboxImage` (digest-pinned, tags fail
998
- closed at load) runs consented npm/pypi/crates/rubygems builds — and freshness re-packs —
999
- inside `docker run` with `--network none` proved by an in-container DNS+TCP probe, a
1000
- read-only repo mount, tmpfs build, `--cap-drop ALL`, scrubbed env, and the ledger record
1001
- bound to the exact image digest (a rotated image invalidates cached PASS). Missing docker
1002
- with an image configured is a named exit 2, never a host fallback. New `test/sandbox.test.ts`
1003
- pins the docker argv contract token by token.
1004
- - Hardened the npm pack-phase guard (sibyl must_fix): ALL package.json manifests in the tree
1005
- are scanned, not just the root (a workspaces child's `prepare` is now refused on npm < 11),
1006
- and unreadable manifests or a walk-cap hit count as a hook — unknown is never assumed safe.
1007
- - Machine-side config (HR decoupling handoff 2026-10-23): resolution chain is now
1008
- `--config > $BORDER_CONFIG (fail-closed) > machine store (XDG/border/machines, keyed by
1009
- canonical remote URL) > ./border.yaml > git-remote inference`, so gate config and waiver
1010
- lists live on the checking machine and never need to be committed into a product repo.
1011
- Allow entries gained `values` (fixture-set waivers that never cover a new value), `until`
1012
- (UTC sunset, expired entries echoed never silently honored), `\!` archive-member escaping,
1013
- and `rules.aliases` (rule renames no longer strand waivers). Border state was already
1014
- porcelain-invisible (`.border/.gitignore` self-ignore; verified 2026-10-23).
1015
- - Provenance: every check and push record carries `engineBuild`
1016
- (`<version>+<commit>[.dirty]`, the string `border --version` prints), so a verdict names
1017
- the exact border build that produced it. Motivated by a machine where every gate ran off
1018
- an unpinned worktree bundle and no record said so; worktree use is now visible on the
1019
- record face (see "Pinned consumption"). Bundles are stamped by `tools/build.mjs`.
1020
- - Security (breaking): artifact build stages now require operator consent. A hostile clone can
1021
- ship its own border.yaml declaring pypi/rubygems targets, and `border check` built them —
1022
- PEP 517 backends and gemspecs ARE code (`python3 -m build --no-isolation` and `gem build`
1023
- markers reproduced 2026-10-23). Default runs are now scan-only (zero build spawns, pinned by
1024
- `test/build.execMarkers.test.ts`); consent comes only from outside the audited tree:
1025
- `--allow-builds` or `BORDER_ALLOW_BUILDS=1`. A scan-only PASS records no artifact digests and
1026
- therefore can never back a registry publish; `border scan` of third-party artifacts requires
1027
- the same consent (named exit 2 without it).
1028
- - Security: refuse npm < 11 packages that declare `prepack`/`prepare`/`postpack` before
1029
- spawning `npm pack`. npm 10 executes `prepare` even with `--ignore-scripts` (reproduced on
1030
- 10.9.7; npm 11.19.1 is clean), so the old defense could execute code from the audited repo.
1031
- The probe also runs before freshness re-packs. New regression tests pin the refusal and the
1032
- never-fires hook marker.
1033
- - Fix: the Windows drive-letter path pattern no longer fires on JS escapes in prose
1034
- (`raw-repoPath:\n`); the UNC pattern no longer spans newlines.
1035
- - Docs: the `--json` findings schema is stated explicitly (`path`, no `file` alias);
1036
- npm-target prerequisites now name the npm 11 boundary.
1037
-
1038
- ### 0.8.1 (2026-10-06)
1039
- - Maintenance release: certificate-chain rebuild off the scrubbed lineage (INCIDENT-20261006
1040
- identity-exposure cleanup). No functional change vs 0.8.0; 0.8.0's tarball is byte-intact but
1041
- its OIDC provenance pin moved with the history rewrite, so a fresh tag + publish restores a
1042
- verifiable chain. Rotation: package.json + lock roots + plugin marker + scan UA + README pins +
1043
- this entry.
1044
-
1045
- ### 0.8.0 (2026-09-30)
1046
- - Add: **`border opencode inspect [--json]`** — the load-surface self-audit becomes a gate-owned
1047
- surface: six read-only aspects (cache self-consistency, spec freshness vs pin/registry, identity
1048
- handshake replay, Route A byte parity, Route A∧B dual-route collision, scope declaration),
1049
- fail-closed aggregation FAIL 1 > CANNOT 2 > PASS 0. `--json` pins `scope:"v1"` — V2 load surfaces
1050
- are a declared blind spot, not silently covered. Zero writes; `--purge` stays in the cron shell.
1051
- - Change: **`exfil` joins the plugin's closed command list** (owner ruling R1 under the four-question
1052
- eligibility gate — no mutation, no credentials, no foreign-byte execution, no local egress;
1053
- packet: `.omo/evidence/F-EXFIL-ALLOWLIST-RULING-2026-09-30.md`). `border opencode …` verbs stay
1054
- terminal-only: they mutate `$XDG_CONFIG_HOME` and fail the gate's first question.
1055
- - Change: `tools/plugin-drift-watch.mjs` retires to a thin shell over `border opencode inspect --json`
1056
- (single source of verdicts); the cron rc contract is byte-stable — FRESH 0, DRIFT 1, NOT-CACHED 0
1057
- (special-cased per ruling B2), CANNOT 2 — and the crontab line is unchanged.
1058
-
1059
- ### 0.7.1 (2026-09-30)
1060
- - Fix: **release-gate test ruler race** (`test/push.integration.test.ts` I4) — the
1061
- "dry-run made ZERO verdaccio requests" invariant counted lines in verdaccio's stdout
1062
- log, an eventually-consistent view: on a saturated CI runner the gate-leg probe lines
1063
- of an earlier phase land after the snapshot, inflating the dry-run delta (v0.7.0 CI
1064
- run 36671401844 failed `6 !== 2` while the identical bytes pass locally). The suite
1065
- now takes every snapshot through a FIFO drain barrier (sentinel request whose log line
1066
- must be visible before counting; sentinel traffic is excluded from the count). The
1067
- zero-network dry-run contract itself is unchanged — verified request-free on both sides
1068
- of the barrier; only the measuring instrument was repaired.
1069
-
1070
- ### 0.7.0 (2026-09-28)
1071
- - Add: **OpenCode 2 (beta) plugin compatibility** — the plugin module is dual-shaped
1072
- (`{id, setup}` for V2 hosts, frozen V1 `server()`), sharing the pre-spawn
1073
- allowlist/`--yes` gates and the 0.5.1 identity handshake across both shapes;
1074
- refusal/handshake/rendering byte-parity between edges is pinned by
1075
- `test/opencode.v2shape.test.ts` while the V1 path stays 16/16 on unmodified
1076
- tests. Root `index.ts` re-export shipped for V2 directory discovery (which
1077
- bypasses the exports map; live-proven on `@opencode-ai/cli` beta-19271).
1078
- - Fix: the live V1 probe is anti-vacuous — opting in (`BORDER_OPENCODE_PROBE=1`)
1079
- without a resolvable binary or built dist now FAILS naming the remedy instead
1080
- of skipping green-adjacent (`BORDER_OPENCODE_BIN` override added).
1081
- - Docs: support matrix (V1 object entry ≥ 1.18.29; V2 project-local verified;
1082
- `/border` command V1-only pending the V2 delivery API).
1083
-
1084
- ### 0.6.0 (2026-09-25)
1085
- - Add: **the exfil lens** — five rules (`exfil-rfc1918`, `exfil-ssh-target`,
1086
- `exfil-home-path`, `exfil-cred-location`, `exfil-host-profile`, `exfil-machine-binding`) that flag
1087
- exfil-shaped literals on about-to-be-public surfaces, the gap classic secret
1088
- scanners leave open because an internal IP is not a credential. The observed
1089
- severity of every (rule × facet × channel) cell lives in one machine-readable
1090
- home, `src/exfil/severity.ts`, rendered — never re-decided — by the check
1091
- pipeline, the new read-only `border exfil <rev|url>` CLI (tip tree by default,
1092
- `--deep` history, URL modes into a destroyed temp clone) and the README table,
1093
- each cell pinned by a docs guard. The tree HIGH family ships as vendored
1094
- twins (gitleaks TOML + secretlint patterns, ids byte-equal, verdict parity
1095
- tested); the commit-message facet is native-exclusive with a `:message` id
1096
- family so allow-list waivers can never cross facets; tag notes stay with the
1097
- pre-existing `tag-message-secret` engine leg. All exfil sources are
1098
- fingerprinted into `rulesHash`.
1099
- - Add: **post-push landing verification** — after git legs move, the remote tip
1100
- is re-observed and scanned with the same machinery; clean appends an
1101
- append-only `t:"landing"` record and preserves the exit, a blocking hit exits
1102
- 1 ("ALREADY PUBLIC; border detects, never erases"), an unobservable tip exits
1103
- 2 with no record minted. Executed-push rows are never mutated; DRY-RUN lands
1104
- nothing.
1105
- - Add: **fixture band + assembly doctrine** — 10.200.0.0/16 is reserved as the
1106
- always-exempt-on-tree test range (exemption mirrored natively in both twins,
1107
- message facet deliberately not exempt), and the test corpus assembles every
1108
- other leak shape at runtime (`assembleOctet`/`assembleHost`) so checked-out
1109
- bytes carry no complete leak tokens: border's own lens is green against
1110
- border's own corpus. Golden-suite coverage includes an opt-in truth lane
1111
- (`BORDER_EXFIL_TRUTH=1`) with a documented red-anchor lifetime clause.
1112
- - Note: this release line traces to the 0.5.1 impersonation-wave audit; that
1113
- wave's H-3 follow-up — scrubbing this repo's own historical tip surfaces —
1114
- landed here as byte edits with the extracted values withheld from public
1115
- records by design (see the sanitized `.omo/evidence/` trail, not this log).
1116
-
1117
- ### 0.5.1 (2026-09-24)
1118
- - Fix: **plugin impersonation — the `border` tool could spawn the host binary instead of the
1119
- gate.** On Route B the 0.5.0 adapter resolved its packaged CLI as `process.execPath +
1120
- dist/index.js`, but inside the opencode plugin host `process.execPath` is the opencode
1121
- (bun-compiled) binary — sessions saw opencode's banner for `--help` and opencode usage
1122
- errors for every other command, while the tool's schema still promised border's
1123
- fail-closed contract. The resolution order is now: `BORDER_BIN` (wins alone; a broken
1124
- override never silently falls back) → packaged `dist/index.js` exec'd directly via its
1125
- shebang, else under `PATH` `node` → `border` on `PATH`. `process.execPath` is removed from
1126
- the adapter, pinned by a source-level regression test, and **every candidate must pass an
1127
- identity handshake** (`--help` answering with border's banner + `usage: border`, exit 0,
1128
- 20 s cap) before any user argv is spawned — failure reports `cannot-answer` at exit 2 and
1129
- runs nothing else (spawn failures now map to 2, not 127, aligning the tool with the CLI's
1130
- exit-code contract). Regression tests cover the unspawnable-override and the
1131
- opencode-style-impostor fixtures; the impostor logs prove user argv never reaches it.
1132
- Honest boundary: the handshake catches mistaken identity (host binaries, PATH accidents),
1133
- not a deliberately forged banner.
1134
-
1135
- ### 0.5.0 (2026-09-12)
1136
- - Add: OpenCode plugin adapter (`exports["./server"]` + `/border` + `border` tool) loadable
1137
- two ways with zero manual install — config-declared `"plugin": ["border-customs@0.5.0"]`
1138
- (opencode downloads the package; the plugin spawns its own packaged CLI via a
1139
- package-relative `dist/index.js` resolution, `BORDER_BIN` override, PATH fallback) or
1140
- `border opencode install` (marker-identified idempotent file drop, foreign files refused,
1141
- never overwritten). Trigger: an agent session drives the gate without a shell-out or an
1142
- install ritual; `push --yes` stays terminal-only by design — the tool refuses it.
1143
-
1144
- ### 0.4.2 (2026-09-12)
1145
- - Fix: `npm publish` no longer prints the misleading "bin[border] script name
1146
- dist/index.js was invalid and removed" warning — package.json now carries the
1147
- canonical bare-relative bin form npm normalizes to anyway (mechanism traced to
1148
- @npmcli/package-json publish-side; artifacts were never broken; an always-on
1149
- test locks the form).
1150
- - Change: RubyGems freshness (skip-ledger) parity compares a normalized CONTENT
1151
- digest instead of raw file bytes — `gem build` stamps the building toolchain
1152
- (RubyGems <3.6 embeds the build second in gzip headers), so raw-sha repack
1153
- parity could never be honored on those machines. The ledger digest and the
1154
- publish-time same-bytes re-hash stay RAW: what gets uploaded is still exactly
1155
- the certified bytes, and any unparseable artifact fails closed to a full
1156
- re-check.
1157
-
1158
- ### 0.4.1 (2026-09-11)
1159
- - Add: release-coherence rule family — every version source inside a published artifact
1160
- must agree (pyproject vs MODULE vs filename, gemspec vs gem metadata, Cargo.toml vs
1161
- crate filename, package.json self-consistency; the git-tag half is a publish-workflow
1162
- hard gate). Trigger: the aihr incident where a wheel labeled 0.2.2 shipped modules
1163
- self-reporting 0.2.1 — every pinned consumer silently got old behavior.
1164
- - Add: `border roundtrip` local-artifact input — an argument resolving to an existing
1165
- file (.whl/.tgz/.crate/.gem, closed table) proves unpublished bytes end-to-end; the
1166
- streaming sha256 of those exact bytes is the ledger identity and the report carries
1167
- `source:local:<abs>` provenance. Honest boundary documented: the local PyPI-wheel lane
1168
- is residue-INERT by construction (pip fully tracks and prunes; verified live on pip
1169
- 24.0 AND 25.0.1) — planted-detection demos belong to the hook-executing lanes
1170
- (npm postinstall, gem extconf, crates build.rs).
1171
- - Change: pypi roundtrip lane dep-closure calibration — pip's resolver report demotes
1172
- dependency-storm files to LOW `dep-owned` rows, so genuine orphans stand alone; the
1173
- demotion requires a legitimate claimant dist-info (pin Name+Version match, canonical
1174
- dirname, unique, own site-packages root, not the target) after an independent verifier
1175
- proved a shadow `*-9.9.9.dist-info` could otherwise launder hostile writes.
1176
-
1177
- ### 0.4.0 (2026-09-10)
1178
- - Add: `border roundtrip <[ecosystem:]name@version>` — fact-proof valve. Installs the
1179
- package inside a throwaway Docker container, content-hashes the whole filesystem before
1180
- and after uninstall, and reports exactly what the uninstaller left behind. Persistence
1181
- surfaces (rc/profile mutations) => CRITICAL; orphan files => HIGH. Verdicts record to
1182
- the ledger by default (`--no-record` opts out); Docker absent => fail-closed, never clean.
1183
- - Add: `residue.requireProof` — when on, a residue-capable artifact only passes `border
1184
- check` with a fresh recorded roundtrip proof (bound to rulesHash; flipping the config
1185
- invalidates cached verdicts). Missing proof => CRITICAL `roundtrip-proof-missing`.
1186
- - Opt-in test legs: BORDER_ROUNDTRIP_DOCKER=1 (8 real container legs, 4 ecosystems).
1187
-
1188
- ### 0.3.2 (2026-09-10)
1189
- - Fix: CLI invoked through the npm `.bin` shim silently exited 0 without running
1190
- (entrypoint detection now realpath-canonicalizes both sides). Regression-locked by a
1191
- pack -> install -> shim-invocation test (`BORDER_PACK_TEST=1`, wired into CI).
1192
- - Fix: `publint` is an exact-pinned runtime dependency, so `border scan`/npm checks work
1193
- out of the box in consumer installs; README documents the engine posture truthfully.
1194
- - Add: release-coherence seed test pinning the scan User-Agent version to package.json.
1195
-
1196
- ### 0.3.1 (2026-09-10)
1197
- - Add: `border scan [ecosystem:]name@version` — static residue inspection of third-party
1198
- registry packages (npm/PyPI/crates.io/RubyGems), ledger-free by design.
1199
- - Docs: README scan section; crates envelope normalization now logs scrubbed-file hashes.
1200
-
1201
- ### 0.3.0 (2026-09-09)
1202
-
1203
- - **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
1204
- family of seven closed `residue-*` rules across tiers T1–T4 plus cross-manager, pairing,
1205
- and gem-unmatched lanes (see "The residue scan"): a hook matching the closed T1 safe-shape
1206
- signature drops to MEDIUM; every unknown shape keeps the original CRITICAL row verbatim.
1207
- Pairing (`# BEGIN <id>` marker blocks) is **detected only** — `residue-pairing-missing`
1208
- fires on the orphaned WRITE, a verified pair still pays its T3 HIGH; the waiver downgrade
1209
- path is the 0.4.0 roundtrip valve. Detection classes: C5 pairing discipline, C9 indirection
1210
- spellings, C10 comment/quote boundaries — evidence frozen in `src/artifacts/RESIDUE-CONTRACT.md`.
1211
- - New `residue: { enabled }` config key (default on; strict; the ONLY skip path for the family,
1212
- and flipping it invalidates cached PASS rows because the toggle rides the fingerprint). The
1213
- residue rule table and classifier sources are now part of the ledger `rulesHash`: editing a
1214
- signature forces a re-check even at an unchanged commit and tree.
1215
- - Boundary stated plainly: static analysis proves *capability*, not *fact*; border is not a
1216
- malware sandbox and never executes what it reads.
1217
-
1218
- ### 0.2.0 (2026-09-05)
1219
-
1220
- - Two new push channels, same doctrine end to end: **crates** (public crates.io only;
1221
- literal `Cargo.toml` coordinates; pre-publish repackage digest-assert) and **rubygems**
1222
- (rubygems.org or any `targets.rubygems.host` gemcutter-compatible host; literal-only
1223
- gemspecs; `s.date` rejection).
1224
- - Platforms are now descriptors in a channel registry: each channel owns its config schema,
1225
- env expansion, coordinate reading, probe, artifact stage, publish argv, and ledger fields
1226
- in one file. The git/npm/pypi legs share the same publish core they already used; their
1227
- behavior is unchanged.
1228
- - Ledger `confirmedVia` gained `crates-json` and `rubygems-json`; 0.1.x ledgers parse
1229
- unchanged.
1230
- - New prerequisites when those targets are configured: `cargo` on `PATH` (measured against
1231
- 1.93.1 via rustup) and `gem` (measured against RubyGems 3.6.7 on ruby 3.3.8); other
1232
- versions are untested by the gate's same-bytes proofs.
1233
-
1234
- ### 0.1.0
1235
-
1236
- - Initial release: git, npm, and PyPI push channels behind the fingerprint PASS gate.
969
+ Full per-version history with the motivating incident for each release:
970
+ [CHANGELOG.md](CHANGELOG.md). Release receipts (build provenance, gate transcripts):
971
+ [docs/release/](docs/release/).
1237
972
 
1238
973
  ## License
1239
974
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "border-customs",
9
- "version": "0.8.3",
9
+ "version": "0.8.5",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@secretlint/core": "13.0.5",
package/dist/index.js CHANGED
@@ -44,7 +44,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
44
44
  var define_BORDER_BUILD_default;
45
45
  var init_define_BORDER_BUILD = __esm({
46
46
  "<define:__BORDER_BUILD__>"() {
47
- define_BORDER_BUILD_default = { version: "0.8.3", commit: "5f2f4c31d43916bed00a3c5b3448b3f377af4c23", dirty: false, builtAt: "2026-11-02T05:02:18.524Z" };
47
+ define_BORDER_BUILD_default = { version: "0.8.5", commit: "56c5aa3959e18fa143e49904608f4564798ff0a6", dirty: false, builtAt: "2026-11-08T08:34:50.133Z" };
48
48
  }
49
49
  });
50
50
 
@@ -30368,8 +30368,8 @@ function sandboxBindingJoin(images) {
30368
30368
  return [...new Set(images)].sort().join("\n");
30369
30369
  }
30370
30370
  function sandboxRun(o) {
30371
- const probe = o.laneProbes.map((p) => `{ printf '%s ' '${p}'; ${p} --version 2>&1 | head -n 1; }`).join("\n");
30372
- const script = `${probe} > /audit/out/toolchain.txt
30371
+ const probe2 = o.laneProbes.map((p) => `{ printf '%s ' '${p}'; ${p} --version 2>&1 | head -n 1; }`).join("\n");
30372
+ const script = `${probe2} > /audit/out/toolchain.txt
30373
30373
  ${o.laneScript}`;
30374
30374
  const r = runSandboxedBuild({
30375
30375
  spec: o.spec,
@@ -37531,8 +37531,8 @@ function pypiArtifactCoherenceFindings(o) {
37531
37531
  for (const initRel of o.files.filter((f) => /(?:^|\/)__init__\.py$/.test(f)).sort()) {
37532
37532
  const body = o.read(initRel);
37533
37533
  if (body === null) continue;
37534
- const probe = probeDunderVersion(body);
37535
- if (probe !== null) sources.push({ label: `__version__ literal in ${initRel}`, path: initRel, probe });
37534
+ const probe2 = probeDunderVersion(body);
37535
+ if (probe2 !== null) sources.push({ label: `__version__ literal in ${initRel}`, path: initRel, probe: probe2 });
37536
37536
  }
37537
37537
  const hits = [];
37538
37538
  analyze(sources, `${o.archiveName}!`, hits);
@@ -37978,6 +37978,29 @@ init_define_BORDER_BUILD();
37978
37978
  import { spawnSync as spawnSync6 } from "node:child_process";
37979
37979
  import { dirname as dirname6, resolve as resolve6 } from "node:path";
37980
37980
 
37981
+ // src/lsremote.ts
37982
+ init_define_BORDER_BUILD();
37983
+ var PushStateError = class extends Error {
37984
+ exitCode = 2;
37985
+ name = "PushStateError";
37986
+ };
37987
+ var SHA_SHAPE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
37988
+ function parseLsRemote(text, targetId) {
37989
+ const out = /* @__PURE__ */ new Map();
37990
+ for (const line of text.split("\n")) {
37991
+ if (line.trim() === "") continue;
37992
+ const fields = line.split(" ");
37993
+ const sha = fields[0];
37994
+ const ref = fields[1];
37995
+ const shapeOk = fields.length === 2 && sha !== void 0 && SHA_SHAPE.test(sha) && (ref === "HEAD" || ref !== void 0 && ref.startsWith("refs/"));
37996
+ if (!shapeOk) {
37997
+ throw new PushStateError(`target '${targetId}': malformed 'git ls-remote' output line '${line.slice(0, 72)}' \u2014 refusing to interpret (fail closed)`);
37998
+ }
37999
+ if (ref !== "HEAD") out.set(ref, sha);
38000
+ }
38001
+ return out;
38002
+ }
38003
+
37981
38004
  // src/rules/allowlist.ts
37982
38005
  init_define_BORDER_BUILD();
37983
38006
  var BOT_EMAIL_PATTERN = "*[bot]@users.noreply.github.com";
@@ -38055,7 +38078,6 @@ function runGit2(repoDir, args) {
38055
38078
  }
38056
38079
  var LOG_FIELDS = 6;
38057
38080
  var LOG_FORMAT = "--format=%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%P";
38058
- var REV_BATCH = 200;
38059
38081
  function collectCommits(s, revs) {
38060
38082
  const out = runGit2(s.repoDir, ["log", LOG_FORMAT, ...revs]);
38061
38083
  for (const line of out.split("\n")) {
@@ -38094,30 +38116,66 @@ function revExists(repoDir, rev) {
38094
38116
  });
38095
38117
  return r.status === 0;
38096
38118
  }
38097
- function trackingEndpoint(repoDir, remoteName, ref) {
38098
- const candidates = [`refs/remotes/${remoteName}/${ref}`];
38099
- const short = ref.replace(/^refs\/(?:heads|tags)\//, "");
38100
- if (short !== ref) candidates.push(`refs/remotes/${remoteName}/${short}`);
38101
- return candidates.find((c) => revExists(repoDir, c)) ?? null;
38119
+ function revParse(repoDir, rev) {
38120
+ return runGit2(repoDir, ["rev-parse", rev]).trim();
38102
38121
  }
38103
- function scanTransmitted(s, refSet, remotes) {
38104
- const names = remotes.map((r) => r.name).filter((n) => n !== void 0);
38105
- if (names.length === 0) return;
38106
- for (const ref of refSet) {
38107
- if (!revExists(s.repoDir, ref)) continue;
38108
- for (const name of names) {
38109
- const endpoint = trackingEndpoint(s.repoDir, name, ref);
38110
- if (endpoint === null) continue;
38111
- const out = runGit2(s.repoDir, ["rev-list", `${endpoint}..${ref}`]);
38112
- const fresh = [];
38113
- for (const sha of out.split("\n")) {
38114
- if (sha !== "" && !s.seen.has(sha)) fresh.push(sha);
38122
+ var LS_REMOTE_TIMEOUT_MS = 3e4;
38123
+ function urlForMessage(url2) {
38124
+ try {
38125
+ const u = new URL(url2);
38126
+ if (u.protocol === "file:") return url2;
38127
+ return `${u.protocol}//${u.host}/***`;
38128
+ } catch {
38129
+ return "***";
38130
+ }
38131
+ }
38132
+ function lsRemoteFetch(repoDir, url2, env2) {
38133
+ const r = spawnSync6("git", ["ls-remote", url2], {
38134
+ cwd: repoDir,
38135
+ encoding: "utf8",
38136
+ env: { ...process.env, ...env2, GIT_CEILING_DIRECTORIES: dirname6(resolve6(repoDir)) },
38137
+ timeout: LS_REMOTE_TIMEOUT_MS
38138
+ });
38139
+ if (r.error !== void 0 || r.status !== 0) {
38140
+ const why = r.error !== void 0 ? r.error.message : (r.stderr ?? "").trim().slice(-200);
38141
+ throw new Error(
38142
+ `identity: remote ${urlForMessage(url2)} is unreachable via git ls-remote (${why}) \u2014 the exposure class of the offending identities cannot be decided and border never guesses it; restore connectivity or scope the check and retry`
38143
+ );
38144
+ }
38145
+ return parseLsRemote(r.stdout ?? "", urlForMessage(url2));
38146
+ }
38147
+ function remoteTruth(o, walkShas) {
38148
+ const truth = { published: /* @__PURE__ */ new Set(), publishedTags: /* @__PURE__ */ new Set(), remoteLabels: [] };
38149
+ const resolvable = o.refSet.filter((ref) => revExists(o.repoDir, ref));
38150
+ const urls = [...new Set(o.cfg.targets.git.remotes.map((r) => r.url).filter((u) => u !== void 0 && u.length > 0))];
38151
+ if (resolvable.length === 0 || urls.length === 0) return truth;
38152
+ const env2 = o.env ?? process.env;
38153
+ const fetch2 = o.lsRemote ?? ((url2) => lsRemoteFetch(o.repoDir, url2, env2));
38154
+ for (const url2 of urls) {
38155
+ const map2 = fetch2(url2);
38156
+ truth.remoteLabels.push(urlForMessage(url2));
38157
+ for (const ref of resolvable) {
38158
+ const ep = map2.get(ref) ?? map2.get(`${ref}^{}`);
38159
+ if (ep === void 0) continue;
38160
+ if (!/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(ep)) {
38161
+ throw new Error(`identity: remote ${urlForMessage(url2)} reports a malformed endpoint sha for ${ref} \u2014 refusing to interpret (fail closed)`);
38162
+ }
38163
+ if (ref.startsWith("refs/tags/")) {
38164
+ const localObj = revParse(o.repoDir, ref);
38165
+ const localPeel = revParse(o.repoDir, `${ref}^{}`);
38166
+ if (map2.get(ref) === localObj || map2.get(`${ref}^{}`) === localPeel) truth.publishedTags.add(ref);
38167
+ continue;
38115
38168
  }
38116
- for (let i = 0; i < fresh.length; i += REV_BATCH) {
38117
- collectCommits(s, ["--no-walk", ...fresh.slice(i, i + REV_BATCH)]);
38169
+ if (!revExists(o.repoDir, ep)) {
38170
+ throw new Error(
38171
+ `identity: remote ${urlForMessage(url2)} points ${ref} at ${ep.slice(0, 12)}, which is not an object in this repository \u2014 the transmitted set cannot be computed; run 'git fetch ${urlForMessage(url2)}' and retry (fail closed)`
38172
+ );
38118
38173
  }
38174
+ const fresh = new Set(runGit2(o.repoDir, ["rev-list", `${ep}..${ref}`]).split("\n").filter((s) => s !== ""));
38175
+ for (const sha of walkShas) if (!fresh.has(sha)) truth.published.add(sha);
38119
38176
  }
38120
38177
  }
38178
+ return truth;
38121
38179
  }
38122
38180
  function scanTags(repoDir, authors) {
38123
38181
  const entries = /* @__PURE__ */ new Map();
@@ -38155,35 +38213,61 @@ function mergeEntries(target, extra) {
38155
38213
  }
38156
38214
  }
38157
38215
  }
38216
+ var IDENTITY_PUBLIC_RULE = "identity-already-public";
38217
+ function locationsSuffix(objects) {
38218
+ const locations = objects.map(([obj, roles]) => `${roles} of ${obj}`);
38219
+ const shownList = locations.slice(0, MAX_LISTED_LOCATIONS);
38220
+ const more = locations.length - shownList.length;
38221
+ return { shown: shownList.join("; "), suffix: more > 0 ? ` (+${String(more)} more)` : "" };
38222
+ }
38158
38223
  function scanIdentity(o) {
38159
38224
  const authors = o.cfg.rules.authors;
38160
38225
  const s = { repoDir: o.repoDir, authors, entries: /* @__PURE__ */ new Map(), seen: /* @__PURE__ */ new Set() };
38161
- if (o.refSet.length > 0) {
38162
- collectCommits(s, o.refSet);
38163
- scanTransmitted(s, o.refSet, o.cfg.targets.git.remotes);
38164
- }
38226
+ if (o.refSet.length > 0) collectCommits(s, o.refSet);
38165
38227
  mergeEntries(s.entries, scanTags(o.repoDir, authors));
38228
+ const truth = s.entries.size > 0 ? remoteTruth(o, s.seen) : { published: /* @__PURE__ */ new Set(), publishedTags: /* @__PURE__ */ new Set(), remoteLabels: [] };
38166
38229
  const findings = [];
38167
38230
  for (const entry of s.entries.values()) {
38168
- const locations = [...entry.objects].map(([obj, roles]) => `${roles} of ${obj}`);
38169
- const shown = locations.slice(0, MAX_LISTED_LOCATIONS);
38170
- const more = locations.length - shown.length;
38171
- const suffix = more > 0 ? ` (+${String(more)} more)` : "";
38231
+ const freshObjects = [];
38232
+ const publicObjects = [];
38233
+ for (const [obj, roles] of entry.objects) {
38234
+ const isCommit = /^[0-9a-f]{40}$/.test(obj);
38235
+ const published = isCommit ? truth.published.has(obj) : truth.publishedTags.has(obj);
38236
+ (published ? publicObjects : freshObjects).push([obj, roles]);
38237
+ }
38172
38238
  const identity = `${entry.name} <${entry.email}>`;
38173
- const firstCommit = [...entry.objects.entries()].find(([obj]) => /^[0-9a-f]{40}$/.test(obj));
38174
38239
  const { valueDigest, snippet } = redact(identity);
38175
- const raw = `'${identity}' is not allowlisted in rules.authors \u2014 push would expose this git identity forever: ${shown.join("; ")}${suffix}. Remediation: use git filter-repo manually (out of scope for border; border never rewrites history).`;
38176
- const finding = {
38177
- rule: IDENTITY_RULE,
38178
- severity: "CRITICAL",
38179
- target: IDENTITY_TARGET,
38180
- engine: IDENTITY_ENGINE,
38181
- message: o.sanitizer?.sanitize(raw) ?? raw,
38182
- valueDigest,
38183
- snippet
38184
- };
38185
- if (firstCommit !== void 0) finding.commit = firstCommit[0];
38186
- findings.push(finding);
38240
+ const firstCommit = [...freshObjects, ...publicObjects].find(([obj]) => /^[0-9a-f]{40}$/.test(obj));
38241
+ if (freshObjects.length > 0) {
38242
+ const { shown, suffix } = locationsSuffix(freshObjects);
38243
+ const raw = `'${identity}' is not allowlisted in rules.authors \u2014 push would expose this git identity forever: ${shown}${suffix}. Remediation: use git filter-repo manually (out of scope for border; border never rewrites history).`;
38244
+ const finding = {
38245
+ rule: IDENTITY_RULE,
38246
+ severity: "CRITICAL",
38247
+ target: IDENTITY_TARGET,
38248
+ engine: IDENTITY_ENGINE,
38249
+ message: o.sanitizer?.sanitize(raw) ?? raw,
38250
+ valueDigest,
38251
+ snippet
38252
+ };
38253
+ if (firstCommit !== void 0) finding.commit = firstCommit[0];
38254
+ findings.push(finding);
38255
+ }
38256
+ if (publicObjects.length > 0) {
38257
+ const { shown, suffix } = locationsSuffix(publicObjects);
38258
+ const raw = `'${identity}' is not allowlisted in rules.authors \u2014 but every object carrying it is already public on the configured remote(s) (${truth.remoteLabels.join(", ") || "remotes probed"}): ${shown}${suffix}. Nothing new leaves this machine with this push; recorded as fact for housekeeping (add it to rules.authors, or keep it out of new commits). border detects, never erases \u2014 rewriting published history is not offered.`;
38259
+ const publishedFinding = {
38260
+ rule: IDENTITY_PUBLIC_RULE,
38261
+ severity: "INFO",
38262
+ target: IDENTITY_TARGET,
38263
+ engine: IDENTITY_ENGINE,
38264
+ message: o.sanitizer?.sanitize(raw) ?? raw,
38265
+ valueDigest: redact(`${identity}:already-public`).valueDigest,
38266
+ snippet
38267
+ };
38268
+ if (firstCommit !== void 0) publishedFinding.commit = firstCommit[0];
38269
+ findings.push(publishedFinding);
38270
+ }
38187
38271
  }
38188
38272
  return findings;
38189
38273
  }
@@ -39404,12 +39488,12 @@ function pruneRunDirs(repoDir, key8, keep = KEEP_RUN_DIRS_PER_KEY8) {
39404
39488
  async function computeFingerprint(repoDir, cfg, configDigest, effectiveTargets, o = {}) {
39405
39489
  const envOpt = o.env !== void 0 ? { env: o.env } : {};
39406
39490
  const ctx = await gatherContext(repoDir, envOpt);
39407
- const probe = await probeEngines(cfg, {
39491
+ const probe2 = await probeEngines(cfg, {
39408
39492
  ...envOpt,
39409
39493
  ...o.requireOverride !== void 0 ? { requireOverride: o.requireOverride } : {}
39410
39494
  });
39411
39495
  const rulesHash = await computeCheckRulesHash({
39412
- engineVersions: probe.engineVersions,
39496
+ engineVersions: probe2.engineVersions,
39413
39497
  configDigest,
39414
39498
  ...envOpt
39415
39499
  });
@@ -39425,7 +39509,7 @@ async function computeFingerprint(repoDir, cfg, configDigest, effectiveTargets,
39425
39509
  rulesHash,
39426
39510
  exposureSet: [...exposureSet(cfg, { cwd: repoDir })]
39427
39511
  };
39428
- return { ctx, fp, probeDegraded: probe.degraded };
39512
+ return { ctx, fp, probeDegraded: probe2.degraded };
39429
39513
  }
39430
39514
  async function consultSkipLedger(q) {
39431
39515
  let ctx;
@@ -40156,26 +40240,6 @@ function gitTargetId(remote, index) {
40156
40240
  }
40157
40241
 
40158
40242
  // src/pushstate.ts
40159
- var PushStateError = class extends Error {
40160
- exitCode = 2;
40161
- name = "PushStateError";
40162
- };
40163
- var SHA_SHAPE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
40164
- function parseLsRemote(text, targetId) {
40165
- const out = /* @__PURE__ */ new Map();
40166
- for (const line of text.split("\n")) {
40167
- if (line.trim() === "") continue;
40168
- const fields = line.split(" ");
40169
- const sha = fields[0];
40170
- const ref = fields[1];
40171
- const shapeOk = fields.length === 2 && sha !== void 0 && SHA_SHAPE.test(sha) && (ref === "HEAD" || ref !== void 0 && ref.startsWith("refs/"));
40172
- if (!shapeOk) {
40173
- throw new PushStateError(`target '${targetId}': malformed 'git ls-remote' output line '${line.slice(0, 72)}' \u2014 refusing to interpret (fail closed)`);
40174
- }
40175
- if (ref !== "HEAD") out.set(ref, sha);
40176
- }
40177
- return out;
40178
- }
40179
40243
  function localRefCommits(repoDir, refSet, env2) {
40180
40244
  const commits = /* @__PURE__ */ new Map();
40181
40245
  if (refSet.length === 0) return commits;
@@ -42332,6 +42396,46 @@ function resolveBuildConsent(o) {
42332
42396
  return { allowed: false, reason: WITHHELD_REASON };
42333
42397
  }
42334
42398
 
42399
+ // src/check/ignoredSurface.ts
42400
+ init_define_BORDER_BUILD();
42401
+ var IGNORED_SURFACE_NOTE = " \u2014 path is .gitignore'd and untracked: git add honours .gitignore, so it cannot reach a push; informational surface, never blocking";
42402
+ function nulList(stdout) {
42403
+ return new Set(stdout.split("\0").filter((p) => p !== ""));
42404
+ }
42405
+ function ignoredHit(rel, ignored) {
42406
+ if (ignored.has(rel)) return true;
42407
+ let cut = rel.indexOf("/");
42408
+ while (cut > 0) {
42409
+ if (ignored.has(`${rel.slice(0, cut)}/`)) return true;
42410
+ cut = rel.indexOf("/", cut + 1);
42411
+ }
42412
+ return false;
42413
+ }
42414
+ function relFacetPath(f, repoDir) {
42415
+ if (f.path === void 0 || f.commit !== void 0) return null;
42416
+ if (f.target !== "git" && f.target !== "tree") return null;
42417
+ const outer = f.path.split("!")[0] ?? f.path;
42418
+ const prefix = `${repoDir}/`;
42419
+ return outer.startsWith(prefix) ? outer.slice(prefix.length) : outer;
42420
+ }
42421
+ function demoteIgnoredUntracked(findings, repoDir, o = {}) {
42422
+ const candidates = findings.filter((f) => f.severity !== "INFO" && relFacetPath(f, repoDir) !== null);
42423
+ if (candidates.length === 0) return 0;
42424
+ const tracked = nulList(runGitChecked(repoDir, ["ls-files", "-z"], o));
42425
+ const ignored = nulList(runGitChecked(repoDir, ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], o));
42426
+ if (ignored.size === 0) return 0;
42427
+ let demoted = 0;
42428
+ for (const f of findings) {
42429
+ if (f.severity === "INFO") continue;
42430
+ const rel = relFacetPath(f, repoDir);
42431
+ if (rel === null || !ignoredHit(rel, ignored) || tracked.has(rel)) continue;
42432
+ f.severity = "INFO";
42433
+ f.message = `${f.message}${IGNORED_SURFACE_NOTE}`;
42434
+ demoted += 1;
42435
+ }
42436
+ return demoted;
42437
+ }
42438
+
42335
42439
  // src/check/exfilTreeScan.ts
42336
42440
  init_define_BORDER_BUILD();
42337
42441
  import { readFileSync as readFileSync17, statSync as statSync2 } from "node:fs";
@@ -42583,7 +42687,7 @@ function revExists2(run2, rev) {
42583
42687
  return false;
42584
42688
  }
42585
42689
  }
42586
- function trackingEndpoint2(run2, remoteName, ref) {
42690
+ function trackingEndpoint(run2, remoteName, ref) {
42587
42691
  const candidates = [`refs/remotes/${remoteName}/${ref}`];
42588
42692
  const short = ref.replace(/^refs\/(?:heads|tags)\//, "");
42589
42693
  if (short !== ref) candidates.push(`refs/remotes/${remoteName}/${short}`);
@@ -42606,7 +42710,7 @@ function willPublishShas(run2, refSet, remotes) {
42606
42710
  for (const remote of remotes) {
42607
42711
  const name = remote.name;
42608
42712
  if (name === void 0) continue;
42609
- const endpoint = trackingEndpoint2(run2, name, ref);
42713
+ const endpoint = trackingEndpoint(run2, name, ref);
42610
42714
  if (endpoint !== null) ranges.push(`${endpoint}..${ref}`);
42611
42715
  }
42612
42716
  if (ranges.length === 0) collect([ref]);
@@ -43136,13 +43240,13 @@ async function runPipeline(o, ctx, lockWarning) {
43136
43240
  const envOpt = o.env !== void 0 ? { env: o.env } : {};
43137
43241
  const findings = [];
43138
43242
  const guard = trackedBorderStateFinding(repoDir, ctx.headSha, o.env);
43139
- const probe = await probeEngines(o.cfg, {
43243
+ const probe2 = await probeEngines(o.cfg, {
43140
43244
  ...envOpt,
43141
43245
  ...o.requireOverride !== void 0 ? { requireOverride: o.requireOverride } : {}
43142
43246
  });
43143
- findings.push(...probe.findings);
43247
+ findings.push(...probe2.findings);
43144
43248
  if (guard !== null) findings.push(guard);
43145
- const broken = new Set(probe.findings.map((f) => f.engine));
43249
+ const broken = new Set(probe2.findings.map((f) => f.engine));
43146
43250
  const sanitizer = new TextSanitizer();
43147
43251
  findings.push(...filterBorderStateFindings(detectHostileConfig({ repoDir, target: "git", ...envOpt }), repoDir));
43148
43252
  if (!broken.has("gitleaks")) {
@@ -43187,6 +43291,7 @@ async function runPipeline(o, ctx, lockWarning) {
43187
43291
  findings.push(f);
43188
43292
  }
43189
43293
  }
43294
+ demoteIgnoredUntracked(findings, repoDir, o.env !== void 0 ? { env: o.env } : {});
43190
43295
  const ledgerArtifacts = [];
43191
43296
  const buildToolchains = [];
43192
43297
  for (const channel of publishChannels()) {
@@ -43235,7 +43340,7 @@ async function runPipeline(o, ctx, lockWarning) {
43235
43340
  }
43236
43341
  }
43237
43342
  const rulesHash = await computeCheckRulesHash({
43238
- engineVersions: probe.engineVersions,
43343
+ engineVersions: probe2.engineVersions,
43239
43344
  configDigest: o.configDigest,
43240
43345
  ...o.env !== void 0 ? { env: o.env } : {}
43241
43346
  });
@@ -43295,7 +43400,7 @@ async function runPipeline(o, ctx, lockWarning) {
43295
43400
  return {
43296
43401
  report,
43297
43402
  ctx,
43298
- degraded: probe.degraded,
43403
+ degraded: probe2.degraded,
43299
43404
  sanitizedSummary: sanitizeSummary(report, sanitizer),
43300
43405
  lockWarning,
43301
43406
  artifacts: ledgerArtifacts.length > 0 ? ledgerArtifacts : null,
@@ -43326,12 +43431,12 @@ async function runCheck(ctx) {
43326
43431
  effective = { kind: "loaded", config: load.explicit.config, warnings: [], source: load.explicit.source, security: load.explicit.security };
43327
43432
  } else {
43328
43433
  if (ctx.flags.requireEngine !== void 0 && ctx.flags.requireEngine.length > 0) {
43329
- const probe = await probeEngines(NO_TARGETS_PROBE_CFG, {
43434
+ const probe2 = await probeEngines(NO_TARGETS_PROBE_CFG, {
43330
43435
  requireOverride: ctx.flags.requireEngine,
43331
43436
  ...env2 !== void 0 ? { env: env2 } : {}
43332
43437
  });
43333
- if (probe.degraded) {
43334
- for (const finding of probe.findings) ctx.stderr(`border: ${finding.message}`);
43438
+ if (probe2.degraded) {
43439
+ for (const finding of probe2.findings) ctx.stderr(`border: ${finding.message}`);
43335
43440
  return EXIT_ERROR;
43336
43441
  }
43337
43442
  }
@@ -43849,13 +43954,13 @@ async function runLlmRequest(ctx) {
43849
43954
 
43850
43955
  // src/commands/opencode.ts
43851
43956
  init_define_BORDER_BUILD();
43852
- import { existsSync as existsSync23, lstatSync, mkdirSync as mkdirSync12, readFileSync as readFileSync23, rmSync as rmSync14, writeFileSync as writeFileSync8 } from "node:fs";
43957
+ import { existsSync as existsSync24, lstatSync, mkdirSync as mkdirSync12, readFileSync as readFileSync23, rmSync as rmSync14, writeFileSync as writeFileSync8 } from "node:fs";
43853
43958
  import { dirname as dirname17, join as join36 } from "node:path";
43854
43959
  import { fileURLToPath as fileURLToPath7 } from "node:url";
43855
43960
 
43856
43961
  // src/opencode/inspect.ts
43857
43962
  init_define_BORDER_BUILD();
43858
- import { existsSync as existsSync22, readFileSync as readFileSync22 } from "node:fs";
43963
+ import { existsSync as existsSync23, readFileSync as readFileSync22 } from "node:fs";
43859
43964
  import { join as join35 } from "node:path";
43860
43965
 
43861
43966
  // src/opencode/aspects/cacheVersion.ts
@@ -44105,6 +44210,10 @@ function verifyDualRoute(facts) {
44105
44210
  return { id, verdict: "PASS", detail: "neither route registered \u2014 no border plugin load claimed on the V1 surface" };
44106
44211
  }
44107
44212
 
44213
+ // src/opencode/aspects/globalResolution.ts
44214
+ init_define_BORDER_BUILD();
44215
+ import { existsSync as existsSync22 } from "node:fs";
44216
+
44108
44217
  // src/opencode/aspects/identity.ts
44109
44218
  init_define_BORDER_BUILD();
44110
44219
  import { execFile } from "node:child_process";
@@ -44161,11 +44270,11 @@ async function verifyIdentity(deps) {
44161
44270
  const reasons = [];
44162
44271
  let anySpawned = false;
44163
44272
  for (const candidate of candidates) {
44164
- const probe = await runner(candidate, HANDSHAKE_ARGV);
44165
- if (passesHandshake(probe)) {
44273
+ const probe2 = await runner(candidate, HANDSHAKE_ARGV);
44274
+ if (passesHandshake(probe2)) {
44166
44275
  return { id, verdict: "PASS", detail: `${candidate.label} passed the identity handshake` };
44167
44276
  }
44168
- if (probe.status !== null) {
44277
+ if (probe2.status !== null) {
44169
44278
  anySpawned = true;
44170
44279
  reasons.push(`${candidate.label}: spawned but --help did not answer as border (impostor?)`);
44171
44280
  } else {
@@ -44178,51 +44287,6 @@ async function verifyIdentity(deps) {
44178
44287
  return { id, verdict: "CANNOT", detail: `no border binary reachable \u2014 tried: ${reasons.join("; ")}` };
44179
44288
  }
44180
44289
 
44181
- // src/opencode/aspects/routeA.ts
44182
- init_define_BORDER_BUILD();
44183
- function verifyRouteAParity(facts) {
44184
- const id = "route-a-parity";
44185
- const { routeAPlugin, routeAInstalled } = facts;
44186
- if (!routeAInstalled) {
44187
- return { id, verdict: "PASS", detail: `${routeAPlugin}: absent \u2014 Route A not installed, nothing to be stale` };
44188
- }
44189
- if (facts.packagedPlugin === null) {
44190
- return { id, verdict: "CANNOT", detail: "the packaged plugin asset is unreadable \u2014 the border install itself looks broken, parity cannot be judged" };
44191
- }
44192
- const installed = facts.routeABytes;
44193
- if (installed === null) {
44194
- return { id, verdict: "CANNOT", detail: `${routeAPlugin}: present but unreadable \u2014 parity cannot be judged` };
44195
- }
44196
- const installedSha = sha256Hex3(installed);
44197
- const packagedSha = sha256Hex3(facts.packagedPlugin);
44198
- if (installedSha === packagedSha) {
44199
- return { id, verdict: "PASS", detail: `${routeAPlugin}: up-to-date installed=${installedSha} packaged=${packagedSha}` };
44200
- }
44201
- const ours = firstLine(installed).startsWith(PLUGIN_MARKER);
44202
- const shape = ours ? "outdated (marker ours, bytes differ)" : "foreign (first line lacks the marker)";
44203
- return { id, verdict: "FAIL", detail: `${routeAPlugin}: ${shape} installed=${installedSha} packaged=${packagedSha}` };
44204
- }
44205
-
44206
- // src/opencode/aspects/scope.ts
44207
- init_define_BORDER_BUILD();
44208
- var SCOPE_ID = "scope";
44209
- var SCOPE_NAME = "v1-load-surface";
44210
- var SCOPE_UNCOVERED = [
44211
- "V2 project-local .opencode/plugins/",
44212
- "V2 npm-prefix/node_modules installs",
44213
- "V2 lazy activation state"
44214
- ];
44215
- function verifyScope() {
44216
- return {
44217
- id: SCOPE_ID,
44218
- verdict: "PASS",
44219
- detail: `verdict scope = ${SCOPE_NAME}; NOT covered (declared blind, separate ledger item): ${SCOPE_UNCOVERED.join(", ")}`
44220
- };
44221
- }
44222
-
44223
- // src/opencode/aspects/specFreshness.ts
44224
- init_define_BORDER_BUILD();
44225
-
44226
44290
  // src/opencode/registry.ts
44227
44291
  init_define_BORDER_BUILD();
44228
44292
  var isRecord3 = (v) => typeof v === "object" && v !== null;
@@ -44276,7 +44340,110 @@ async function fetchRegistryVersion(fetcher, url2, timeoutMs) {
44276
44340
  return parsed["version"];
44277
44341
  }
44278
44342
 
44343
+ // src/opencode/aspects/globalResolution.ts
44344
+ var VERSION_ARGV = ["--version"];
44345
+ function parseVersionStamp(stdout) {
44346
+ const m = /^border\s+(\d[^\s]*)/m.exec(stdout);
44347
+ return m?.[1] ?? null;
44348
+ }
44349
+ async function probe(runner, target) {
44350
+ const out = await runner(target, VERSION_ARGV);
44351
+ if (out.status === null) return "unspawnable";
44352
+ return parseVersionStamp(out.stdout) ?? "no version stamp";
44353
+ }
44354
+ async function verifyGlobalResolution(deps) {
44355
+ const id = "global-resolution";
44356
+ if ((deps.env["BORDER_INSPECT_OFFLINE"] ?? "") === "1") {
44357
+ return { id, verdict: "WARN", detail: "skipped (BORDER_INSPECT_OFFLINE=1) \u2014 freshness not evaluated; an unevaluated face is never reported as fresh" };
44358
+ }
44359
+ const runner = deps.runner ?? defaultInspectRunner;
44360
+ const override = deps.env["BORDER_BIN"];
44361
+ const hasOverride = override !== void 0 && override.length > 0;
44362
+ const faces = [];
44363
+ let active = null;
44364
+ if (hasOverride) {
44365
+ const stamp = await probe(runner, { bin: override, prefix: [], label: `BORDER_BIN=${override}` });
44366
+ faces.push({ label: `env BORDER_BIN=${override}`, stamp });
44367
+ active = stamp;
44368
+ } else {
44369
+ if (existsSync22(deps.distEntry)) {
44370
+ const packaged = { bin: deps.distEntry, prefix: [], label: "packaged dist" };
44371
+ let stamp = await probe(runner, packaged);
44372
+ if (stamp === "unspawnable") {
44373
+ stamp = await probe(runner, { bin: "node", prefix: [deps.distEntry], label: "packaged dist" });
44374
+ }
44375
+ faces.push({ label: "packaged dist", stamp });
44376
+ }
44377
+ const pathStamp = await probe(runner, { bin: "border", prefix: [], label: "border on PATH" });
44378
+ faces.push({ label: "border on PATH", stamp: pathStamp });
44379
+ active = pathStamp === "unspawnable" ? null : pathStamp;
44380
+ }
44381
+ const faceLine = faces.map((f) => `${f.label}: ${f.stamp}`).join("; ");
44382
+ const fetcher = deps.fetcher ?? defaultRegistryFetcher;
44383
+ let latest;
44384
+ try {
44385
+ latest = await fetchRegistryVersion(fetcher, npmTagUrl(deps.pkg, "latest", deps.registryBase), deps.timeoutMs ?? REGISTRY_TIMEOUT_MS2);
44386
+ } catch (err) {
44387
+ const message = err instanceof Error ? err.message : String(err);
44388
+ return { id, verdict: "CANNOT", detail: `registry unreachable for global-resolution: ${message}; ${faceLine}` };
44389
+ }
44390
+ if (active === null) {
44391
+ return { id, verdict: "PASS", detail: `no active CLI face (no BORDER_BIN, no PATH border); registry latest ${latest}; ${faceLine}` };
44392
+ }
44393
+ const activeCore = active.split("+")[0];
44394
+ if (activeCore === latest) {
44395
+ return { id, verdict: "PASS", detail: `active face matches registry latest ${latest}; ${faceLine}` };
44396
+ }
44397
+ if (active === "unspawnable" || active === "no version stamp") {
44398
+ return { id, verdict: "WARN", detail: `active CLI face carries no version stamp (registry latest ${latest}) \u2014 informational: identity may be fine, age is unknown; ${faceLine}` };
44399
+ }
44400
+ return { id, verdict: "WARN", detail: `active CLI face is ${active}, registry latest is ${latest} \u2014 informational only: pinned consumption is a legitimate steady state, the decision to update stays with the human; ${faceLine}` };
44401
+ }
44402
+
44403
+ // src/opencode/aspects/routeA.ts
44404
+ init_define_BORDER_BUILD();
44405
+ function verifyRouteAParity(facts) {
44406
+ const id = "route-a-parity";
44407
+ const { routeAPlugin, routeAInstalled } = facts;
44408
+ if (!routeAInstalled) {
44409
+ return { id, verdict: "PASS", detail: `${routeAPlugin}: absent \u2014 Route A not installed, nothing to be stale` };
44410
+ }
44411
+ if (facts.packagedPlugin === null) {
44412
+ return { id, verdict: "CANNOT", detail: "the packaged plugin asset is unreadable \u2014 the border install itself looks broken, parity cannot be judged" };
44413
+ }
44414
+ const installed = facts.routeABytes;
44415
+ if (installed === null) {
44416
+ return { id, verdict: "CANNOT", detail: `${routeAPlugin}: present but unreadable \u2014 parity cannot be judged` };
44417
+ }
44418
+ const installedSha = sha256Hex3(installed);
44419
+ const packagedSha = sha256Hex3(facts.packagedPlugin);
44420
+ if (installedSha === packagedSha) {
44421
+ return { id, verdict: "PASS", detail: `${routeAPlugin}: up-to-date installed=${installedSha} packaged=${packagedSha}` };
44422
+ }
44423
+ const ours = firstLine(installed).startsWith(PLUGIN_MARKER);
44424
+ const shape = ours ? "outdated (marker ours, bytes differ)" : "foreign (first line lacks the marker)";
44425
+ return { id, verdict: "FAIL", detail: `${routeAPlugin}: ${shape} installed=${installedSha} packaged=${packagedSha}` };
44426
+ }
44427
+
44428
+ // src/opencode/aspects/scope.ts
44429
+ init_define_BORDER_BUILD();
44430
+ var SCOPE_ID = "scope";
44431
+ var SCOPE_NAME = "v1-load-surface";
44432
+ var SCOPE_UNCOVERED = [
44433
+ "V2 project-local .opencode/plugins/",
44434
+ "V2 npm-prefix/node_modules installs",
44435
+ "V2 lazy activation state"
44436
+ ];
44437
+ function verifyScope() {
44438
+ return {
44439
+ id: SCOPE_ID,
44440
+ verdict: "PASS",
44441
+ detail: `verdict scope = ${SCOPE_NAME}; NOT covered (declared blind, separate ledger item): ${SCOPE_UNCOVERED.join(", ")}`
44442
+ };
44443
+ }
44444
+
44279
44445
  // src/opencode/aspects/specFreshness.ts
44446
+ init_define_BORDER_BUILD();
44280
44447
  var isExactVersion2 = (spec) => /^\d+\.\d+\.\d+([-+].*)?$/.test(spec);
44281
44448
  async function verifySpecFreshness(facts, deps = {}) {
44282
44449
  const id = "spec-freshness";
@@ -44333,6 +44500,7 @@ function inspectExit(verdict) {
44333
44500
  case "CANNOT":
44334
44501
  return EXIT_ERROR;
44335
44502
  case "PASS":
44503
+ case "WARN":
44336
44504
  return EXIT_PASS;
44337
44505
  }
44338
44506
  }
@@ -44348,7 +44516,7 @@ function gatherFacts(env2, cwd, deps) {
44348
44516
  const pluginDrop = routeAPluginPath(env2);
44349
44517
  const cacheRoot = deps.cacheRoot ?? opencodeCacheRoot(env2);
44350
44518
  const globalPath = deps.globalConfigPath ?? join35(opencodeConfigRoot(env2), "opencode", "opencode.jsonc");
44351
- const routeAInstalled = existsSync22(pluginDrop);
44519
+ const routeAInstalled = existsSync23(pluginDrop);
44352
44520
  return {
44353
44521
  pkg,
44354
44522
  env: env2,
@@ -44363,6 +44531,7 @@ function gatherFacts(env2, cwd, deps) {
44363
44531
  }
44364
44532
  async function runInspectCore(ctx, deps = {}) {
44365
44533
  const facts = gatherFacts(ctx.env, ctx.cwd, deps);
44534
+ const distEntry = deps.distEntry ?? packagedDistEntry(import.meta.url);
44366
44535
  const aspects = [
44367
44536
  verifyCacheVersion(facts),
44368
44537
  await verifySpecFreshness(facts, {
@@ -44371,12 +44540,20 @@ async function runInspectCore(ctx, deps = {}) {
44371
44540
  }),
44372
44541
  await verifyIdentity({
44373
44542
  ...deps.runner !== void 0 ? { runner: deps.runner } : {},
44374
- distEntry: deps.distEntry ?? packagedDistEntry(import.meta.url),
44543
+ distEntry,
44375
44544
  env: facts.env
44376
44545
  }),
44377
44546
  verifyRouteAParity(facts),
44378
44547
  verifyDualRoute(facts),
44379
- verifyScope()
44548
+ verifyScope(),
44549
+ await verifyGlobalResolution({
44550
+ ...deps.runner !== void 0 ? { runner: deps.runner } : {},
44551
+ distEntry,
44552
+ env: facts.env,
44553
+ pkg: facts.pkg,
44554
+ ...deps.registryFetcher !== void 0 ? { fetcher: deps.registryFetcher } : {},
44555
+ ...deps.registryBase !== void 0 ? { registryBase: deps.registryBase } : {}
44556
+ })
44380
44557
  ];
44381
44558
  const verdict = aggregateVerdict(aspects);
44382
44559
  const report = { schemaVersion: 1, scope: "v1", verdict, aspects };
@@ -44400,7 +44577,7 @@ function resolvePluginAsset(moduleUrl, fileName) {
44400
44577
  join36(here, "..", "..", "plugin", fileName)
44401
44578
  // src mode: <root>/plugin/<f>
44402
44579
  ];
44403
- return candidates.find((p) => existsSync23(p)) ?? candidates[1];
44580
+ return candidates.find((p) => existsSync24(p)) ?? candidates[1];
44404
44581
  }
44405
44582
  function targetsFor(env2) {
44406
44583
  const root = join36(opencodeConfigRoot(env2), "opencode");
@@ -44972,7 +45149,7 @@ import { createHash as createHash20 } from "node:crypto";
44972
45149
  import { basename as basename5 } from "node:path";
44973
45150
  var SCAN_MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
44974
45151
  var SCAN_REQUEST_TIMEOUT_MS = 6e4;
44975
- var SCAN_USER_AGENT = "border-customs/0.8.3 (+https://github.com/TachikomaGundam/Border-Customs; border scan)";
45152
+ var SCAN_USER_AGENT = "border-customs/0.8.5 (+https://github.com/TachikomaGundam/Border-Customs; border scan)";
44976
45153
  var CRATES_DOWNLOAD_HOST = "https://static.crates.io";
44977
45154
  var defaultScanFetcher = async (url2, init) => {
44978
45155
  if (!/^https?:\/\//.test(url2)) throw new EngineRunError(`border scan refuses non-http(s) URL: ${url2}`, null);
@@ -45143,7 +45320,7 @@ function parseScanSpec(raw) {
45143
45320
 
45144
45321
  // src/roundtrip/localArtifact.ts
45145
45322
  init_define_BORDER_BUILD();
45146
- import { createReadStream, existsSync as existsSync24, statSync as statSync3 } from "node:fs";
45323
+ import { createReadStream, existsSync as existsSync25, statSync as statSync3 } from "node:fs";
45147
45324
  import { createHash as createHash21 } from "node:crypto";
45148
45325
  import { basename as basename6, resolve as resolve20 } from "node:path";
45149
45326
  var EXT_TABLE = /* @__PURE__ */ new Map([
@@ -45192,7 +45369,7 @@ var GRAMMAR_HINT = {
45192
45369
  };
45193
45370
  function classifyLocalInput(raw, cwd, parsesAsRegistrySpec) {
45194
45371
  const abs = resolve20(cwd, raw);
45195
- if (existsSync24(abs)) {
45372
+ if (existsSync25(abs)) {
45196
45373
  const st = statSync3(abs);
45197
45374
  if (st.isDirectory()) {
45198
45375
  bad3(`'${raw}' resolves to a directory (${abs}) \u2014 local mode needs a single artifact FILE, not a directory`);
@@ -45742,13 +45919,13 @@ async function recordRoundtripProof(ctx, artifactSha256, rows, verdict) {
45742
45919
  return;
45743
45920
  }
45744
45921
  const repoDir = resolveRepoDir(ctx.cwd, { ...env2 !== void 0 ? { env: env2 } : {} });
45745
- const probe = await probeEngines(effective.config, { ...env2 !== void 0 ? { env: env2 } : {} });
45746
- if (probe.degraded) {
45922
+ const probe2 = await probeEngines(effective.config, { ...env2 !== void 0 ? { env: env2 } : {} });
45923
+ if (probe2.degraded) {
45747
45924
  notice("engine probe degraded \u2014 cannot mint the rulesHash this proof must match; proof NOT recorded");
45748
45925
  return;
45749
45926
  }
45750
45927
  const rulesHash = await computeCheckRulesHash({
45751
- engineVersions: probe.engineVersions,
45928
+ engineVersions: probe2.engineVersions,
45752
45929
  configDigest: computeConfigDigest(effective),
45753
45930
  ...env2 !== void 0 ? { env: env2 } : {}
45754
45931
  });
@@ -45773,7 +45950,7 @@ var runRoundtrip = (ctx) => runRoundtripCore(ctx);
45773
45950
  init_define_BORDER_BUILD();
45774
45951
  var import_yaml2 = __toESM(require_dist(), 1);
45775
45952
  import { spawnSync as spawnSync20 } from "node:child_process";
45776
- import { existsSync as existsSync25, mkdirSync as mkdirSync13, readFileSync as readFileSync24, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync10 } from "node:fs";
45953
+ import { existsSync as existsSync26, mkdirSync as mkdirSync13, readFileSync as readFileSync24, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync10 } from "node:fs";
45777
45954
  import { dirname as dirname19, join as join39 } from "node:path";
45778
45955
  var realIo = {
45779
45956
  read: (p) => readFileSync24(p, "utf8"),
@@ -45785,7 +45962,7 @@ var realIo = {
45785
45962
  mkdir0700: (p) => {
45786
45963
  let cur = p;
45787
45964
  const stack = [];
45788
- while (!existsSync25(cur) && cur !== "/" && cur !== ".") {
45965
+ while (!existsSync26(cur) && cur !== "/" && cur !== ".") {
45789
45966
  stack.push(cur);
45790
45967
  cur = dirname19(cur);
45791
45968
  }
@@ -45793,7 +45970,7 @@ var realIo = {
45793
45970
  mkdirSync13(dir, { mode: 448 });
45794
45971
  }
45795
45972
  },
45796
- exists: (p) => existsSync25(p),
45973
+ exists: (p) => existsSync26(p),
45797
45974
  remove: (p) => unlinkSync2(p),
45798
45975
  remotes: (cwd) => {
45799
45976
  const r = spawnSync20("git", ["-C", cwd, "remote", "-v"], { encoding: "utf8" });
@@ -45973,7 +46150,7 @@ function synthesizeScanConfig(eco) {
45973
46150
 
45974
46151
  // src/scan/materialize.ts
45975
46152
  init_define_BORDER_BUILD();
45976
- import { existsSync as existsSync26, mkdirSync as mkdirSync14, readdirSync as readdirSync13, readFileSync as readFileSync25, rmSync as rmSync17, writeFileSync as writeFileSync11 } from "node:fs";
46153
+ import { existsSync as existsSync27, mkdirSync as mkdirSync14, readdirSync as readdirSync13, readFileSync as readFileSync25, rmSync as rmSync17, writeFileSync as writeFileSync11 } from "node:fs";
45977
46154
  import { join as join40 } from "node:path";
45978
46155
  var CRATES_ENVELOPE_FILES = ["Cargo.toml.orig", ".cargo-ok", ".cargo_vcs_info.json"];
45979
46156
  var fail3 = (why) => {
@@ -45996,7 +46173,7 @@ function unwrapSingleDir(dir) {
45996
46173
  return dir;
45997
46174
  }
45998
46175
  function requireManifest(repoDir, ecosystem, candidates) {
45999
- if (!candidates.some((rel) => existsSync26(join40(repoDir, rel)))) {
46176
+ if (!candidates.some((rel) => existsSync27(join40(repoDir, rel)))) {
46000
46177
  fail3(`${ecosystem} package tree has none of [${candidates.join(", ")}] at its root`);
46001
46178
  }
46002
46179
  }
@@ -46016,7 +46193,7 @@ function materializePackage(o) {
46016
46193
  const outer = repoDir;
46017
46194
  const dataCandidates = findNativeMissArchives(outer).filter((f) => f.endsWith("data.tar.gz"));
46018
46195
  const inner = dataCandidates[0] ?? join40(outer, "data.tar.gz");
46019
- if (!existsSync26(inner)) fail3(".gem archive contains no data.tar.gz");
46196
+ if (!existsSync27(inner)) fail3(".gem archive contains no data.tar.gz");
46020
46197
  const gemTree = join40(o.baseDir, "gem-tree");
46021
46198
  mkdirSync14(gemTree, { recursive: true });
46022
46199
  extractFlat(inner, gemTree);
@@ -46029,7 +46206,7 @@ function materializePackage(o) {
46029
46206
  requireManifest(repoDir, o.ecosystem, ["Cargo.toml"]);
46030
46207
  for (const file2 of CRATES_ENVELOPE_FILES) {
46031
46208
  const path4 = join40(repoDir, file2);
46032
- if (!existsSync26(path4)) continue;
46209
+ if (!existsSync27(path4)) continue;
46033
46210
  const original = readFileSync25(path4);
46034
46211
  rmSync17(path4, { force: true });
46035
46212
  note(`border scan: crates envelope normalized: ${file2} sha256=${sha256Hex4(original)}`);
@@ -46142,6 +46319,7 @@ function runStatus(ctx) {
46142
46319
  return EXIT_ERROR;
46143
46320
  }
46144
46321
  for (const w of ledger.warnings) ctx.stderr(`border: WARNING ${w}`);
46322
+ ctx.stdout(`engine: ${engineBuild()}`);
46145
46323
  try {
46146
46324
  const drift = remoteTrackingDrift(repoDir);
46147
46325
  const names = drift.configured.length > 0 ? drift.configured.join(", ") : "(none configured \u2014 nothing can ever be pushed)";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "//": "publint is a runtime dep (exact pin): the npm-stage engine is invoked via bin resolution against the installed layout, so consumers get the publint-fail leg out of the box; the dev suite resolves the same node_modules/.bin/publint — no devDependencies entry is needed for it to stay green.",
5
5
  "description": "Fail-closed git push gate: secret scanning + supply-chain checks before any remote leaves the machine",
6
6
  "type": "module",
package/plugin/border.ts CHANGED
@@ -1,4 +1,4 @@
1
- // border-opencode-plugin v0.8.3
1
+ // border-opencode-plugin v0.8.5
2
2
  // Official opencode plugin adapter for the border fail-closed push gate.
3
3
  // Registers ONE agent tool, `border`, that drives the border CLI — argv-only
4
4
  // (node:child_process execFile, never a shell), top-level commands restricted
@@ -46,7 +46,7 @@
46
46
  // cannot-answer, never a fallback to some other binary.
47
47
 
48
48
  import { execFile } from "node:child_process";
49
- import { existsSync } from "node:fs";
49
+ import { existsSync, readFileSync } from "node:fs";
50
50
  import { fileURLToPath } from "node:url";
51
51
  import { z } from "zod";
52
52
 
@@ -209,25 +209,47 @@ function isBorderHelp(result: CliResult): boolean {
209
209
  }
210
210
 
211
211
  type Resolution =
212
- | { readonly ok: true; readonly resolution: BinResolution }
212
+ | { readonly ok: true; readonly resolution: BinResolution; readonly stamp: string }
213
213
  | { readonly ok: false; readonly reasons: readonly string[] };
214
214
 
215
+ /** Twin of parseVersionStamp in src/opencode/aspects/globalResolution.ts; the
216
+ * digit anchor keeps the banner (`border — fail-closed…`) from parsing as a
217
+ * version. Duplicated on purpose: this file must stay import-free (Route A
218
+ * drops it as a single standalone file). */
219
+ function parseVersion(stdout: string): string | null {
220
+ const m = /^border\s+(\d[^\s]*)/m.exec(stdout);
221
+ return m?.[1] ?? null;
222
+ }
223
+
224
+ /** The version this plugin module was packaged with (the ./package.json export).
225
+ * Route A drops (bare file in plugins/) have no sibling package.json: null. */
226
+ function packagedVersion(): string | null {
227
+ try {
228
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")) as { version?: unknown };
229
+ return typeof pkg.version === "string" ? pkg.version : null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
215
235
  /** Positive handshakes are cached per BORDER_BIN key for the host process's
216
- * life; failures are never cached, so a mid-session install recovers on the
217
- * next call. */
218
- let verified: { readonly key: string; readonly resolution: BinResolution } | null = null;
236
+ * life (stamp included); failures are never cached, so a mid-session install
237
+ * recovers on the next call. */
238
+ let verified: { readonly key: string; readonly resolution: BinResolution; readonly stamp: string } | null = null;
219
239
 
220
240
  async function resolveVerified(): Promise<Resolution> {
221
241
  const key = process.env["BORDER_BIN"] ?? "";
222
242
  if (verified !== null && verified.key === key) {
223
- return { ok: true, resolution: verified.resolution };
243
+ return { ok: true, resolution: verified.resolution, stamp: verified.stamp };
224
244
  }
225
245
  const reasons: string[] = [];
226
246
  for (const candidate of candidateList()) {
227
247
  const probe = await runCli(candidate, ["--help"], PROBE_TIMEOUT_MS);
228
248
  if (isBorderHelp(probe)) {
229
- verified = { key, resolution: candidate };
230
- return { ok: true, resolution: candidate };
249
+ const version = await runCli(candidate, ["--version"], PROBE_TIMEOUT_MS);
250
+ const stamp = version.status === 0 ? (parseVersion(version.stdout) ?? "unknown") : "unknown";
251
+ verified = { key, resolution: candidate, stamp };
252
+ return { ok: true, resolution: candidate, stamp };
231
253
  }
232
254
  reasons.push(
233
255
  probe.status === 0
@@ -367,11 +389,26 @@ async function buildToolResult(argv: readonly string[]): Promise<string> {
367
389
  "--- stderr ---\n(empty)",
368
390
  ].join("\n");
369
391
  }
392
+ const shell = `$ border ${argv.join(" ")}`;
393
+ if ((process.env["BORDER_REQUIRE_PKG_VERSION"] ?? "") === "1") {
394
+ const own = packagedVersion();
395
+ const stampCore = resolution.stamp.split("+")[0];
396
+ if (own === null || own !== stampCore) {
397
+ return [
398
+ shell,
399
+ "exit: 2",
400
+ `note: cannot-answer — BORDER_REQUIRE_PKG_VERSION=1 demands the resolved CLI report this package's own version (${own ?? "unreadable"}); the resolved face (${resolution.resolution.label}) reported '${resolution.stamp}'. Nothing was run.`,
401
+ "--- stdout ---\n(empty)",
402
+ "--- stderr ---\n(empty)",
403
+ ].join("\n");
404
+ }
405
+ }
370
406
  const result = await runCli(resolution.resolution, argv, TIMEOUT_MS);
371
407
  if (result.spawnFailed) verified = null;
372
408
  const sections = [
373
- `$ border ${argv.join(" ")}`,
409
+ shell,
374
410
  `exit: ${String(result.status)}`,
411
+ `face: ${resolution.resolution.label} version=${resolution.stamp}`,
375
412
  ];
376
413
  if (result.note.length > 0) sections.push(`note: ${result.note}`);
377
414
  sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);