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 +43 -308
- package/dist/assets/package-lock.json +2 -2
- package/dist/index.js +324 -146
- package/package.json +1 -1
- package/plugin/border.ts +47 -10
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,
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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,
|
|
899
|
-
|
|
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
|
|
904
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
+
"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.
|
|
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.
|
|
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
|
|
30372
|
-
const script = `${
|
|
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
|
|
37535
|
-
if (
|
|
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
|
|
38098
|
-
|
|
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
|
-
|
|
38104
|
-
|
|
38105
|
-
|
|
38106
|
-
|
|
38107
|
-
if (
|
|
38108
|
-
|
|
38109
|
-
|
|
38110
|
-
|
|
38111
|
-
|
|
38112
|
-
|
|
38113
|
-
|
|
38114
|
-
|
|
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
|
-
|
|
38117
|
-
|
|
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
|
|
38169
|
-
const
|
|
38170
|
-
const
|
|
38171
|
-
|
|
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
|
|
38176
|
-
|
|
38177
|
-
|
|
38178
|
-
|
|
38179
|
-
|
|
38180
|
-
|
|
38181
|
-
|
|
38182
|
-
|
|
38183
|
-
|
|
38184
|
-
|
|
38185
|
-
|
|
38186
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
43243
|
+
const probe2 = await probeEngines(o.cfg, {
|
|
43140
43244
|
...envOpt,
|
|
43141
43245
|
...o.requireOverride !== void 0 ? { requireOverride: o.requireOverride } : {}
|
|
43142
43246
|
});
|
|
43143
|
-
findings.push(...
|
|
43247
|
+
findings.push(...probe2.findings);
|
|
43144
43248
|
if (guard !== null) findings.push(guard);
|
|
43145
|
-
const broken = new Set(
|
|
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:
|
|
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:
|
|
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
|
|
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 (
|
|
43334
|
-
for (const finding of
|
|
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
|
|
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
|
|
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
|
|
44165
|
-
if (passesHandshake(
|
|
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 (
|
|
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 =
|
|
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
|
|
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) =>
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
45746
|
-
if (
|
|
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:
|
|
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
|
|
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 (!
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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 (!
|
|
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 (!
|
|
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
|
+
"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.
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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}`);
|