border-customs
v0.5.0
Published
Fail-closed git push gate: secret scanning + supply-chain checks before any remote leaves the machine
Readme
border
A fail-closed pre-push gate for git, npm, PyPI, crates.io, and RubyGems. It scans your repo for secrets and supply-chain risk, then refuses to let anything leave the machine unless a fresh, unbroken check passed for exactly the state you are about to publish.
npm install -g border-customs
cd /path/to/your/repo
border checkRequires Node >= 22 and the gitleaks binary on PATH (border vendors the rule config for
gitleaks 8.30.1; the binary itself is yours to install, same as git). secretlint runs
in-process as a bundled dependency. Each registry leg additionally shells out to its platform's
own publisher, which must be on PATH whenever that target is configured; a missing publisher
binary is exit 2, never a skipped leg:
| Registry target | Publisher border runs | Version the same-bytes proofs were measured on |
| --- | --- | --- |
| npm | npm | (unchanged since 0.1.0) |
| pypi | python3 -m build / python3 -m twine | (unchanged since 0.1.0) |
| crates | cargo | cargo 1.93.1 (rustup) |
| rubygems | gem | RubyGems 3.6.7 on ruby 3.3.8 |
Other toolchain versions of cargo and gem are untested by those byte-determinism proofs.
Optional engines are listed in Configuration.
The problem it solves
The usual secret workflow is leak-then-repent: git push, notice the AWS key in the diff
review (or not), then git filter-repo, force-push, revoke the credential, and email the
security list. Post-hoc scrubbing fails for two mechanical reasons. Force-pushing a public
branch is history rewriting: clones, forks, CI caches, and package mirrors already hold the
bad objects. And published package versions are effectively forever: npm's unpublish policy
forbids reusing a name@version once it was ever used, and PyPI yanking is non-destructive.
The only cheap fix is to not cross the boundary, and the only boundary that matters is the
one between this machine and the world.
Git's own pre-commit and pre-push hooks help, but they gate the command, not the
state, and they trust whatever ran last. border inverts that: a push is only allowed when
a check passed for the exact fingerprint of what would be pushed, where "what would be
pushed" includes the six things people forget:
- Untracked and dirty files are inputs too. History scanning alone misses the
.envthat was never committed but will ride along in agit add -A. border fingerprints the fullgit status --porcelaindigest and scans the working tree directly. - Deleted-but-pushed blobs. A secret removed in a later commit still ships in the history of the ref being pushed. The history leg scans everything the push would transmit, not just HEAD.
- Archives. Tarballs and zips inside the repo are unpacked and scanned with
<archive>!<inner-path>attribution. - The published bytes, not the source tree.
npm pack,python -m build,cargo package, andgem buildrewrite what ships. border packs/builds once into.border/dist/, scans those exact bytes, and re-hashes them again at publish time. - The registry. Pushing
package.jsonat version1.2.3when1.2.3already exists on the registry is a supply-chain event (silent stale artifact, or squatted name). border fails the check on "version exists, bump required" and on foreign ownership of your name. - Identity. A repo whose pushed commits are authored by an identity outside your
allow-list is on fire, and
PASSfrom an earlier state does not vouch for it at push time; border re-scans identity on the transmit set before every real push.
Principle
One sentence: a PASS is a statement about a fingerprint, not about the past.
checkKey = sha256( headSha
⊕ porcelainDigest # every tracked/untracked/staged byte-change
⊕ rulesHash # config digest + vendored rules + engine versions + prompt template
⊕ exposureSet # sanitized remote URLs + npm/pypi/crates/rubygems name@version
⊕ refSet # branch being pushed + every local tag
⊕ effectiveTargets ) # git, npm, pypi, crates, rubygems as configured for this runMove any of those six and the key moves, the stored PASS no longer covers the state, and
border makes you re-check. Committing, editing an untracked file, renaming a remote,
bumping a version, adding a tag, editing border.yaml, or an engine version bump all
invalidate. There is no time-based expiry and no "looks the same": the check either covers
this exact state or it does not.
Everything else in the design follows from making that statement unbreakable: the ledger is append-only and corruption-tolerant, degraded tools poison the verdict instead of hiding it, and the two registry-facing legs (pre-flight and publish) both have to agree.
Architecture: what border check runs, in order
- Config load.
border.yamlparsed with zod in strict mode. Unknown keys, bad types, or unset${ENV}placeholders are hard errors (exit 2), never defaults. If no config exists but git remotes do, border infers agit-only config and says so on stderr. A hidden local overlay.border/config.local.yamlis deep-merged for machine-local pins. - Engine policy probe. Every required engine (default
gitleaks+secretlint, overridable with--require-engine) must answer a version probe. A missing or broken probe yields aDEGRADED-ENGINECRITICAL finding and forces exit 2 with the verdict marked UNTRUSTWORTHY: border refuses to emit a PASS it cannot mechanically justify. An engine whose version output cannot be parsed also exits 2 rather than assuming a current tool. - Hostile-config guard. Before trusting any scan, the HEAD tree is checked for a
committed
.gitleaksignoreor.gitleaks.toml. Those files make gitleaks itself obey the repo it should be auditing (verified empirically: one committed ignore file turned 1 finding into 0). Their mere presence is a CRITICALrepo-self-ignores-findingsfinding, because refusing to trust them is the only fail-closed option. border always passes its own vendored config explicitly, so repo-shipped rules cannot replace it. - gitleaks legs. History scan over the refs a push would transmit (secrets in
commits unreachable from HEAD still count), working-tree directory scan (archive depth
2, plus border's own extractor for formats gitleaks misses such as
.tgz), and a tag message leg (annotation bodies are pushed with the tag and are not covered by file scans). Findings inside extracted archives are re-attributed toarchive!<inner>. - secretlint leg. Runs in-process on the git-listed file set with the recommended
preset,
no-homedir,no-dotenv, plusborder.yamlrules turned into patterns (hosts, IPs, paths). The upstream AWS access-key ID rule ships force-enabled, and every secretlint hit's echoed raw value is stripped from messages before persistence. - Native rules. AI-session artifact detection on a closed matcher list
(
.omo/**,**/transcripts/**,*.session.jsonl,opencode.json(c),.opencode/**, env files except.env.example/.env.sample, junk likeprobe*/*.rej/node_modules/**, plus yourrules.pathPatternsthrough the same compiler). Identity allow-listing over commit authors, merge committers, and annotated-tag taggers, checked against the history of every ref and against the object set a remote does not already have (the transmit set). Oversized-file, checked-in-binary, and notebook-output rules round it out. - Registry pre-flight (npm, PyPI, crates.io, and RubyGems targets). Three outcomes per
target: version already published ⇒ CRITICAL
version-exists("bump version required"); name owned by someone outside yourrules.authorsallow-list ⇒ CRITICALname-foreign-owner; ambiguous ownership signals ⇒ also CRITICAL, because guessing wrong is how squats ship. Critically, silence never means absent: an empty stdout fromnpm view, a non-200 non-404 PyPI response, a crates.io 403 (the API rejects requests without a self-identifying User-Agent; border sends one, and a 403 still stops the gate instead of being read as "not published"), a rubygems 404 whose body text does not match one of that API's exact absent messages (its 404s are text, not JSON, and an unrecognized one is never guessed), a timeout, or unparseable JSON all fail closed as exit 2. An unreachable registry blocks the push instead of letting it run blind. - Artifact stage. If registry targets are configured, packages are built once into
.border/dist/(npm pack --ignore-scripts,python -m build --no-isolation,cargo package --allow-dirty --no-verify,gem build <name>.gemspec -o .border/dist/), that exact byte-stream is scanned (gitleaks + secretlint over the extracted contents), manifest-diffed (lifecyclepreinstall/install/postinstall/preparehooks are CRITICAL; entries outside thefileswhitelist are HIGH; PyPI sdists get asdist-unexpected-fileHIGH because setuptools builds from the working tree and is.gitignore-blind), the npm and PyPI artifacts are additionally checked withpublint/twine check --strict, and everything is recorded as{file, sha256, bytes}in the ledger. At publish time the same bytes are re-hashed: any mismatch is exit 2, before the wire. The crates leg carries one extra assertion becausecargo publishrepackages from the manifest instead of uploading the staged file: immediately before the spawn, border re-runs the check's exactcargo packageinto a throwaway directory and refuses to publish if the fresh digest differs from the certified.crate.gem pushuploads the recorded.gembyte-for-byte, so it needs no such re-assertion. - Report. Findings carry
valueDigest(sha256 of the matched value) and a masked snippet: fully blocked for short values, elsefirst4…last4. Raw secret bytes never leave process memory. A per-runTextSanitizerholds the digest-to-value registry and replaces every flagged literal in all rendered text, including agent-written strings in the LLM layer, so a secret cannot reappear inside a message about itself.report.json(canonical, byte-stable for machines) andreport.mdland under.border/runs/. - Ledger record. A PASS is appended to
.border/ledger.jsonltogether with the key and artifact digests. Degraded and NO-OP runs can never write a PASS.
$ border check # full scan, ~seconds on a mid-size repo
$ border check # identical state: no re-scan
SKIP 35ffb3cb — PASS 2026-09-04T18:11:44.976Z report .border/runs/35ffb3cb-.../report.jsonThe skip-ledger is what makes the gate cheap enough to run on every push: a repeat
border check on an unchanged fingerprint replays the recorded verdict in well under a
second. The replay is not blind trust. Before honoring a skip border re-derives the live
fingerprint (including engine probes), refuses skips recorded in the other LLM mode, and
re-packs npm, crates, and rubygems artifacts to prove the recorded digests still match fresh
byte-for-byte (a dirty tree, a drifted package.json, or a changed npm version all force a
full re-check; cargo package is deterministic per HEAD and stamps the sha into
.cargo_vcs_info.json, and gem build is byte-deterministic only because RubyGems embeds a
fixed default date, which is why a gemspec that assigns s.date is rejected outright rather
than certified; PyPI builds are not byte-reproducible, so their skip proof is head+porcelain
equality, and publish still re-hashes the exact dist files). A corrupt ledger line is dropped with a loud
warning, never a crash, and never a silently empty history.
The residue scan (0.3.0)
The artifact stage no longer treats every install-time hook as one blanket CRITICAL. Packed
and built bytes are additionally run through a closed signature table that classifies what
an installer does, per tier — the split behind opencode-ai's in-tree postinstall.mjs
(legit) vs. a curl … | bash rc-appender (not):
| Tier | Rule | Severity | One-liner |
| --- | --- | --- | --- |
| T0 | (no finding) | — | Silent install: recorded in the ledger, never flagged. |
| T1 | residue-in-tree-hook | MEDIUM | Hook that only touches its own package tree and matches one closed safe-shape signature (the opencode-ai anatomy). Unknown hook shapes keep the original CRITICAL row verbatim — T1 is the ONLY downgrade in 0.3.0. |
| T2 | residue-install-download | HIGH | Install-time network fetch: bytes that never passed registry review run on every consumer install. |
| T3 | residue-out-of-tree-write | HIGH | Writes outside the package dir: shell rc files, profile.d, git config --global, authorized_keys, %APPDATA%, PATH-carrier lines (setx /F PATH, HKCU\Environment, …). |
| T4 | residue-persistence-primitive | CRITICAL | Any reference to cron / systemctl --user / launchctl / XDG autostart / schtasks / registry Run keys in install-time code or anything it reaches. Malware class: no legitimate pass exists. |
| cross | residue-cross-manager-write | HIGH | A lifecycle/build script spawning a foreign package manager (pip install --user from npm…): files land on another ledger and reclaim by this ledger is impossible by construction. |
| pairing | residue-pairing-missing | HIGH | A # BEGIN <id> marker-block WRITE whose symmetric REMOVE path or CLI inverse is absent. Detection only: a verified pair still pays the T3 HIGH — waiver downgrade is 0.4.0's roundtrip valve. |
| gem | residue-gem-unmatched-extension | MEDIUM | A Ruby extensions:/build hook the classifier cannot resolve — surfaced, never silently clean. |
The pairing classes behind these rows (multi-channel PASS C5 subset/ordering discipline; C9 interpolated-and-indirected root spellings; C10 comment/quote-boundary shapes that once silent-passed) are frozen with their evidence in src/artifacts/RESIDUE-CONTRACT.md.
Fail-closed: a residue scan whose rule table or classifier sources cannot be read is exit 2,
never a silent pass — and the classifier source bytes are part of the ledger rulesHash, so
editing any signature invalidates every cached PASS even at the same commit.
Opt-out: residue: { enabled: false } in border.yaml (strict; unknown sibling keys exit 2)
hides exactly the rows above from the report and verdict and nothing else; flipping it changes
the fingerprint and forces a re-check rather than honoring a stale PASS.
Boundary honesty. Static analysis proves capability, not fact: a T2 row says the installer can fetch and run remote bytes at install time, not that it shipped malware, and a clean residue scan is an absence of matched signatures, never a certificate of benign intent. The signature table is deliberately closed, and the list of shapes it cannot see — content-writes, handle-variants, dead spellings, interpolation-then-slash, span-quirk over-blocks, IO-instance writers, user-shadowed bare verbs — lives in RESIDUE-CONTRACT.md §8; this README points, it does not restate. The empirical roundtrip (install → snapshot → uninstall → rc-delta-zero) is the 0.4.0 valve. border is NOT a malware sandbox: it never executes, intercepts, or sandboxes the code it reads; it matches text and blocks the push.
Proof valve: residue.requireProof (0.4.0)
Static analysis stops at capability. residue: { requireProof: true } (strict; default off)
raises the bar for publish channels: when the residue scan flags a blocking-capable finding on a
staged artifact, border check can no longer PASS that channel on signatures alone — the
artifact's sha256 must also carry a fresh empirical roundtrip verdict in the ledger.
The check gate NEVER runs Docker itself (there is nothing to trust it to run); the proof is
pre-supplied out-of-band by border roundtrip <spec-or-local-file>, which installs the real
bytes — fetched from a registry or read from a local artifact (W2.4(b)) — diffs the
filesystem, and appends a t:"roundtrip" ledger record — {artifactSha256, verdict:
clean|residue, rulesHash, ts, rows} — keyed by the exact bytes it proved. A residue verdict
still counts as a proof-of-fact: the valve demands evidence someone looked, and the finding
itself keeps blocking until it stops tripping the scan.
- Missing proof ⇒ new native CRITICAL
roundtrip-proof-missing; proof whoserulesHashno longer matches (classifier, engine, or config changed — including the flag flip itself) ⇒roundtrip-proof-stale. Freshness is exactly the fingerprint pattern: rotating the policy invalidates cached verdicts, PASSes included. border roundtriprecords by default (--recordis the default; opt out with--no-record).- Both new rules are ordinary findings: the allow-list can waive them and every waiver is
enumerated in
allowHits— no hidden channel.
Release coherence (0.4.1)
Founding case — aihr: the wheel published on PyPI under version 0.2.2 carried
__init__.__version__ = "0.2.1" while pyproject.toml declared 0.2.2. The artifact's name,
its metadata and its actual module behaviour disagreed, and every consumer who pinned ==0.2.2
silently installed the older behaviour. The same drift class bit this repo twice: the published
npm tarball's User-Agent string said 0.3.0 while package.json said 0.3.1, and
package-lock.json's root version sat at 0.1.0 through two releases. Version truth must AGREE
across every source inside the artifact before anything ships — so the publish-stage artifact
scanners cross-check those sources statically. No second source of truth outside the artifact is
consulted: no registry, no git tag.
| Rule | Severity | Fires when |
| --- | --- | --- |
| release-coherence-version-drift | CRITICAL | two version sources packed in one artifact disagree: package.json vs a force-packed package-lock.json root or packages[""] (npm); wheel filename vs *-*.dist-info directory vs METADATA Version: vs a literal __version__ in any __init__.py; sdist pyproject.toml [project].version vs PKG-INFO / setup.cfg / setup.py string literals vs __init__; Cargo.toml [package].version vs the Cargo.lock entry when the lock is packed; .gemspec first-literal vs the built gem's metadata.gz name/version; dist filename vs the packed root PKG-INFO (sdist) |
| release-coherence-unverifiable-source | MEDIUM | a second version source is present but not statically parseable — a computed/dynamic __version__, a setup.cfg attr:/interpolation, a dynamic = ["version"] pyproject, a malformed lockfile, a missing Cargo.lock entry, an unreadable gemspec block. Fail-closed beats silent-clean: the gate refuses to call uncheckable "clean" |
| release-coherence-twin-drift | CRITICAL | opt-in release.twin pairs (see Configuration): a configured PyPI↔npm twin pair publishes different versions. This is the one cross-manager equality that is statically enforceable — both artifacts are already in .border/dist/ in the same run |
The absent-source boundary is load-bearing: a missing package-lock.json/Cargo.lock/PKG-INFO
means nothing was packed — not that anything drifted — and no row is emitted. Publishing
gatekeepers that cry wolf train users to pass --force; these rules fire only on evidence inside
the shipped bytes. Like the residue rule table, this family's source folds into rulesHash, so
editing a matcher invalidates every cached PASS.
Running border roundtrip
border roundtrip <[ecosystem:]name@version | local-artifact-file> fetches the real registry
bytes — or, since W2.4(b), accepts a LOCAL artifact file (border roundtrip
dist/foo-1.0.0-py3-none-any.whl) — installs them in a throwaway Docker container
(per-ecosystem image, whole-filesystem content-hash manifest before install and after the
manager's own uninstall), prints the residue manifest, and records the verdict.
The local lane exists so unpublished/hardened artifacts can be proven pre-publish. Its rules
are deliberately narrow: the ecosystem comes from the extension alone, a closed table
(.whl→pypi, .tgz→npm, .crate→crates, .gem→rubygems); anything else exits 2, because
content-sniffing would grade an artifact through a guessed manager's install/uninstall contract.
Local mode touches no network: the file passes one streaming sha256 pass (the 200 MiB cap fails
closed mid-stream), the exact bytes are staged and docker cp'd, and every later leg — closure
resolution, m1/m2/m3, residue rows, ledger proof — runs the identical registry pipeline.
Provenance stays honest: human and --json output stamp source: "local:<abs-path>" plus the
artifactSha256 — the digest is the identity, and the requireProof valve consumes a local proof
by the same digest it uses for a fetched one. A directory argument, or a path-shaped argument
that does not resolve to an existing file, exits 2 before anything is fetched.
Wheel-lane boundary, stated honestly: the LOCAL PyPI-wheel lane is residue-INERT by
construction — pip fully records what it installs and prunes its own directories, so even a
wheel planted with hostile .data/data/... entries leaves a zero survivor set after uninstall
(verified live on pip 25.0.1 AND 24.0, 2026-09-11, verifier harness); a PASS on such a plant is
pip's contract working, not a missed detection. The local wheel roundtrip still proves
installability, benign-clean, and exact-bytes identity — but planted-detection demos belong to
the lanes whose managers execute install hooks: npm (.tgz postinstall EXECUTES under
npm install -g --foreground-scripts), gem (extconf custom writes) and crates (build.rs).
Known fidelity envelope, from the W2.0 spike and the W2.3 registrar-chain demo:
- Docker is required; absence or any step failure ⇒ exit 2, never a silent
clean. - A local run proves its own bytes and says so:
source/artifactSha256in the report and thet:"roundtrip"ledger record all key on the streamed digest of the file given — never phrased as registry-verified. - npm/gem/crates lanes diff exactly; the pypi lane calibrates pip's left-behind transitive
dependencies (W2.4): before install,
pip install --dry-run --reportresolves the closure in a throwaway resolver container, and post-uninstall rows still claimed by a still-installed closure dist demote to LOWresidue-roundtrip-dep-owned— non-blocking, owner named, still printed. A claim only counts from a legitimate claimant: its dist-info must match the resolver's name-version pin exactly, be canonically named and the unique dist-info for that name, and may only claim paths inside its own site-packages root — a shadow or forgedX-Y.dist-infoplanted by install code launders nothing. What the demotion cannot see stays blocking, deliberately: dependency writes outside their own RECORD, paths ambiguously claimed by two dists, every MODIFIED row (the W2.3 founding run's genuine/usr/local/share/aihrorphan is exactly this shape), and if the closure itself cannot be resolved the run exits 2 — cannot-verify is never clean. - Only manager-lifecycle surfaces are observed: persistence performed by explicitly-invoked
bins (not npm hooks) is outside what the roundtrip watches — W2.3 measured exactly this
blind spot on our own registrar and documented its
{"plugin": []}config residue. - A
cleanverdict is proof-of-fact about one artifact version on one machine class, not a security guarantee.
Architecture: border push
border push is a state machine over per-target states, recomputed from live git queries,
never from cache:
| State | Meaning |
| --- | --- |
| PUSHED | remote already holds every ref at the local value (checked via git ls-remote, full-ref compare, tags compared peeled) |
| PENDING | this target needs to move, and the current fingerprint has a PASS behind it |
| BLOCKED | no PASS for this state (run border check), or a registry finding says stop |
- Bare
border pushis a DRY-RUN: it prints the exactgit push --dry-run/npm publish/twine upload/cargo publish/gem pushlines it would run, refuses to act, and exits with the gate's verdict.--yesis what executes. The real line shapes per channel are in Whatborder pushactually runs. - Multi-remote git push is all-or-nothing before anything moves: every remote × every
ref must be a fast-forward, checked up front. If any remote diverged, border prints
DIVERGEDwith the offending shas and pushes nothing. It never force-pushes; there is no flag for it. - Registry legs go through the same gate as push: a PASS record for the current key, then
the artifact re-hash match against
.border/dist/, then an immediate version-exists re-probe (the registry is allowed to change between check and publish), then the upload withstdio: inheritsonpm,twine,cargo, andgemOTP and credential prompts reach you untouched. border never reads, stores, or handles registry tokens (includingCARGO_REGISTRY_TOKENandGEM_HOST_API_KEY, which the publisher tools own). Published versions are not retried on failure because a half-published version can never be republished. - If one remote of several succeeded before a failure, border says so explicitly (PARTIAL)
and exits 1; a rerun of
border push --yespicks up only the still-PENDING targets.
What border push actually runs
Every DRY-RUN line is rendered from the same argv source as the real publish spawn (one line
per recorded artifact row, execution order git remotes, then npm, PyPI, crates, rubygems).
For a repo configured like the five-target border.yaml in
Configuration, the plan prints as:
$ border push
border DRY-RUN: no --yes, so nothing runs — this is the plan (m-R5-a) contract
DRY-RUN: git push --dry-run origin --follow-tags (https://github.com/acme/widgets.git)
DRY-RUN: npm publish .border/dist/widgets-1.2.3.tgz --registry https://registry.npmjs.org
DRY-RUN: twine upload --repository-url https://pypi.org .border/dist/widgets-1.2.3-py3-none-any.whl .border/dist/widgets-1.2.3.tar.gz
DRY-RUN: cargo publish --allow-dirty --no-verify
DRY-RUN: gem push .border/dist/widgets-1.2.3.gem --host https://gems.acme.examplecargo publishis the one line with no filename: the command takes no positional.crate(verified against cargo 1.93.1--help; it always repackages from the manifest), so the pre-publish repackage digest-assert described in step 8 of the check pipeline is what pins the upload back to the certified bytes. Divergence is exit 2 before anything is sent.twine uploadis a single row carrying every recorded.whl/.tar.gz;npm publishandgem pushget one row per staged file.- A
--registry/--repository-url/--hostflag appears only when the config sets it. Withtargets.npm.registry,targets.pypi.repository, andtargets.rubygems.hostunset, the lines are the barenpm publish <file>,twine upload <files...>, andgem push <file>, and the tool's own defaults decide where bytes go. - If the fingerprint has no PASS covering a target, dry-run cannot list its artifacts and
prints
DRY-RUN: <target> registry leg: run 'border check --force' first — dry-run cannot list artifactsin place of the command lines.
Architecture: the LLM layer (optional, agent-executed)
border itself never calls any model API. The --llm layer is a two-file handoff with
the operator's own agent (Claude, Codex, a human with a chat tab):
border check # must pass for the current state first
border llm-request # writes .border/runs/<key8>-.../llm-request.json
# → your agent reads the bundle, follows the embedded prompt template,
# and authors findings.json (a strict array; [] is a valid PASS statement)
border llm-ingest findings.json # validates, re-scrubs, recomputes the verdictThe request bundle is a masked review context: the diff versus the remote tip (or a
stated full-tree diff on first push, truncated over 10 MiB with an explicit marker), the
deterministic findings, artifact digests, and the review prompt with its sha256. Values the
deterministic engines flagged appear only as [REDACTED:<sha8>]. The ingest side validates
every agent finding against the schema, rejects any finding whose path is not in the
bundle (an agent inventing locations is a hard error, exit 2), forces engine: "agent",
computes digests itself rather than trusting agent-supplied ones, re-runs the sanitizer over
agent free text, and records an llm: true PASS that plain checks cannot ride and vice
versa. The residual risk is stated in the bundle itself: masking only covers values the
deterministic layers already found, so an undiscovered secret in diff context can reach
your LLM endpoint. That is the accepted boundary of this optional layer; the deterministic
layers remain the actual gate.
Commands
border <command> [options]| Command | Purpose |
| --- | --- |
| check | run the secret + supply-chain gate on the pending scope |
| push | gate-verified push; DRY-RUN unless --yes |
| status | newest gate records per target |
| llm-request | emit the masked review bundle for LLM-authored commits |
| llm-ingest <findings.json> | validate agent findings and record the combined verdict |
| scan <[ecosystem:]name@version> | inspect a third-party published artifact for residue before installing it |
| opencode <install\|status\|uninstall> | install/status/uninstall the official OpenCode plugin adapter |
Every subcommand accepts the same global flags (verified against border <cmd> --help):
| Flag | Effect |
| --- | --- |
| --config <path> | config file; default ./border.yaml |
| --targets <list> | comma-separated git,npm,pypi,crates,rubygems subset restricting this run's scope; a named target that is not configured is exit 2 |
| --force | ignore the skip-ledger, re-run the full check |
| --yes | execute mutations (push only); without it a push is always DRY-RUN |
| --require-engine <list> | replaces the required-engine set from config; unknown or unprobeable names degrade the run (exit 2) |
| --llm | opt this check into the LLM review layer (a plain check can never satisfy an llm-recorded skip) |
| --json | machine-readable report on stdout (check, scan) |
| --help, -h | usage table |
Exit codes (the contract)
| Code | Meaning |
| --- | --- |
| 0 | PASS (or no-op): MEDIUM / LOW / INFO findings are allowed and listed |
| 1 | gate-blocked: HIGH or CRITICAL findings; or a push refused (BLOCKED targets); or a partial push |
| 2 | the gate could not answer: config error, missing/degraded engine, unreachable registry, malformed input, concurrent lock holder |
The 2 class matters as much as 1: a tool error never exits 0, and no exit-0 run ever
rests on a leg that silently skipped. Engine exit codes are translated against a closed
matrix (gitleaks 0/1, trufflehog 0/183, secretlint 0/1); anything else, including a
126 from gitleaks, is exit 2, never "clean".
border scan — inspect a third-party package before installing it
border scan [email protected] # npm is the default ecosystem
border scan pypi:[email protected]
border scan crates:[email protected]
border scan rubygems:[email protected]border scan <[ecosystem:]name@version> fetches that exact published artifact from its
registry, materializes it in a throwaway temp directory, and runs the same engine stack
check runs — secrets plus the T0–T4 residue classifiers — over the bytes you are about
to install. --json emits the standard machine-readable report.
Capability, not fact. A scan verdict proves what the package can do — persist past uninstall, write outside the install tree, run hooks on every consumer install — because those shapes are readable in the published bytes. Proof that a package did persist is the uninstall-roundtrip valve, and it ships in 0.4.0; until then treat a scan as pre-install risk triage, never as post-mortem evidence.
Scan touches neither side of your work: it reads no ledger and writes no skip records (every
scan is a full scan), and it creates no .border/ in your repo — the fetched artifact and
its temp git tree live in the sandbox and are destroyed when the run ends. Exit codes follow
the same contract: 0 clean, 1 blocking (HIGH/CRITICAL) findings, 2 the scan could not
genuinely run — unreachable registry, malformed spec, a tree shape the engines cannot
consume. A scan that could not run never prints a clean verdict.
crates artifacts get one envelope normalization before staging: a registry .crate
ships three files — Cargo.toml.orig, .cargo-ok, .cargo_vcs_info.json — that
cargo package rejects as reserved, so scan drops them before the rebuild and logs each
dropped file to stderr with the sha256 of its original bytes
(border scan: crates envelope normalized: <file> sha256=<hex>); the rebuild regenerates
them from the normalized manifest, so no scanned byte is silently lost.
Engines in consumer installs
dist/index.js is one self-contained esbuild bundle and every runtime asset (vendored
gitleaks rules, prompt template, the secretlint fingerprint snapshot) ships inside dist/,
so after npm i -D border-customs (or -g) the engine surface is:
- publint — installed automatically: it is an exact-pinned runtime dependency of the
package, invoked via bin resolution (package-local
node_modules/.bin, the hoisted project-levelnode_modules/.bin, thenPATH, then~/.local/bin). Thepublint-failleg of the npm artifact stage works out of the box in a consumer install. - secretlint — no consumer action, ever: the default lint runs in-process with the
@secretlint/*rule modules already declared as border's own runtime dependencies (src/engines/secretlint.tsheader). Themode: "cli"fallback that spawns asecretlintbinary is border-internal (test transport); it is not reachable fromborder.yamlor any flag, so installingsecretlintinto your project changes nothing for border. If the engine probe cannot read the bundled lock snapshot (dist/assets/package-lock.json), the run fails closed asDEGRADED-ENGINEexit 2 — never a silent pass. - gitleaks — stays external, same posture as
git: border vendors the 8.30.1 rule config but the binary is yours (onPATHor~/.local/bin/gitleaks; see the requirements paragraph at the top andsrc/engines/ADAPTER-CONTRACT.md). Missing gitleaks meansborder checkrecords a CRITICALDEGRADED-ENGINEfinding and exits 2, andborder scanexits 2 withengine binary 'gitleaks' not found; border fails closed. Neither command degrades to a skip. - trufflehog — optional third engine, external binary, same posture as gitleaks once
enabled:
engines.trufflehog: trueputs it in the required set, and an absent binary degrades the run to exit 2 exactly like a missing gitleaks.
There is no "engine absent ⇒ leg silently skipped" state anywhere: missing required
engines are exit 2 (2 = the gate could not answer, see
Exit codes), and the ledger refuses to write a PASS for a
degraded run.
Configuration
Everything border can be told lives in border.yaml at the repo root. Unknown keys are
rejected at load (a typo in a gate config is a gate you did not ask for). ${VAR}
expansion applies only to remote URLs and registry/repository/host values, and an unset variable
is a hard error, never an empty string.
Minimal, a git-only repo:
version: 1
targets:
git:
remotes:
- name: origin
url: [email protected]:acme/widgets.git
rules:
authors:
emails: [[email protected]] # identity allow-list: commits authored by
names: [Acme Dev] # anyone else are CRITICAL findings
hosts: [] # extra detection patterns (internal hostnames…)
ips: []
pathPatterns: []rules is required by design: a gate with silent defaults is a gate you did not ask for.
A repo that publishes a package, with the full surface (every one of the five channels, new in
0.2.0; a config without crates/rubygems behaves exactly as it did on 0.1.0):
version: 1
targets:
git:
remotes:
- name: origin
url: https://github.com/acme/widgets.git
npm:
registry: https://registry.npmjs.org # optional; any npm-protocol registry works
pypi:
repository: https://pypi.org # optional; TestPyPI or private indexes work
crates: # optional; public crates.io only — there is
name: widgets # deliberately no url/host key; overrides
# the Cargo.toml [package] name when set
rubygems: # optional; exactly one *.gemspec at HEAD,
name: widgets # this only disambiguates multi-gemspec repos
host: "${GEM_HOST}" # optional; any gemcutter-compatible push host,
# ${VAR} expansion allowed on this field only
rules:
authors: # identity allow-list
emails: [[email protected]]
names: [Acme CI]
allowBots: true
hosts: ["git.internal.acme.example"] # turned into detection patterns
ips: ["10.20.30.40"]
pathPatterns: ["/Users/*", "*.pfx"]
maxFileKB: 500 # oversized-file threshold (default)
allow: # enumerated suppressions, never blanket
- { rule: "junk-artifact", match: "*", file: "test/fixtures/**" }
engines:
require: [gitleaks, secretlint] # trufflehog: true adds the third-party engine
residue:
enabled: true # default; false hides ONLY the residue-* rows
release:
twin: [] # opt-in [{pypi, npm}] pairs — see Release coherenceNotes that change behavior:
targets.git.remotes: [](explicit, empty) is a deliberate repo-local scope: all history/tree/identity/artifact legs still run, only the exposure set is empty. A completely missing config with real remotes falls back to config-from-git remotewith a loud stderr warning.targets.cratesreads its coordinates fromgit show HEAD:Cargo.tomland accepts only a literal[package] version; a workspace-inherited or computed version is exit 2 telling you to set a literal one. There is no crates.io host/registry key by design: the channel is pinned to public crates.io, and an unknown key there is rejected at load rather than quietly ignored (or silently trusted as a private-registry config the pre-flight cannot probe).targets.rubygemsdiscovers*.gemspecfiles at HEAD; exactly one may exist unlesstargets.rubygems.nameselects one, ands.name/s.versionmust be literals (computed versions are exit 2, border never evaluates gemspec code to find out). A gemspec assignings.dateis rejected wholesale:gem buildis byte-deterministic precisely because it embeds a fixed default date, so an explicits.datebreaks the digest proof border would otherwise be certifying.hostredirects both the probes andgem push --host, and is the only${VAR}-expandable field of the two new targets.- Every
allowentry must be scoped (afilepin);{rule: "*", match: "*"}with no file is rejected at load. Suppressed findings are enumerated in the report'sallow-hitssection, so an exit 0 never hides what it hid. rules.hosts/ips/pathPatternsfeed the same matcher pipeline as the built-ins, so your patterns get the same archive attribution and the same allow-listing semantics.residue.enabled(0.3.0) is the only sanctioned skip of the residue scan; its default istrue, an unknown sibling underresidue:is a typed exit 2, and — unlike anallowentry, which suppresses findings while listing them — turning it off changes the fingerprint, so no PASS certified with residue on can ever skip a run that has it off.- 0.2.0 adds the
cratesandrubygemschannels; existing configs are unchanged. Channels are opt-in by the presence of theirtargets.<id>section, and the ledger's append-only format means old records keep parsing:confirmedViagained two enum members (crates-json,rubygems-json), 0.1.x ledgers are accepted as-is and never rewritten.
Integration
Git hook. The gate is one command, so the classic one-liner is exactly:
printf '#!/bin/sh\nexec border check --force\n' > .git/hooks/pre-push && chmod +x .git/hooks/pre-pushcheck exits nonzero whenever the push it would gate is not covered, so any push through
the hook must first pass. Drop --force to get sub-second skip-ledger replay instead of a
re-scan on every git push.
CI. border check --json emits the stable report object; gate on the exit code, parse
verdict/counts for dashboards. In CI you typically want --force on a cache-less
runner, plus your gitleaks binary provisioned in the image (a degraded run exits 2, which
most CI maps to red; that is the point).
Agents. skills/border/SKILL.md ships with the repo: it teaches an operator agent (the
same one that wrote the commit) how to run the five subcommands, how to produce a valid
llm-ingest findings file, and the two standing rules for agents driving pushes: never pass
--yes without a visible human go-ahead, and never push over an exit-2 gate.
OpenCode plugin (install-free). Since 0.5.0 the package ships an adapter for the official
OpenCode plugin system, loadable two ways with zero manual setup. The primary route is
config-declared: one line in opencode.jsonc, and opencode downloads the package itself (the
plugin entry ships as exports["./server"]):
{
// pin the exact version — an @latest channel hits the registry on every cold start
"plugin": ["[email protected]"]
}No global install and no PATH entry are required for it: the plugin resolves its CLI from the
package's own dist/index.js sitting next to the plugin module, with BORDER_BIN as an
explicit override that wins first and border on PATH as the file-drop route's fallback.
The alternative is the file-drop installer, border opencode install|status|uninstall, which
writes plugins/border.ts and commands/border.md into $XDG_CONFIG_HOME/opencode (with an
HOME/.config fallback) under marker identity: a foreign file at either path is refused with
exit 2 and never overwritten, uninstall removes only marker-bearing files, and identical bytes
report up to date. Either route, restart opencode afterwards — tools and commands are
scanned at startup.
What loads is one agent tool border plus a /border slash command. The tool spawns the CLI
argv-only (no shell; 300 s cap, 64 KiB per stream) restricted to the closed command list
check, push, status, llm-request, llm-ingest, scan, roundtrip, --help, and
renders the exit-code contract unchanged: 0 pass, 1 gate-blocked or partial push, 2 the gate
could not answer. The tool REFUSES --yes — a real push is the human gate, terminal-only, and
a bare border push from the session is DRY-RUN by the CLI's own contract anyway. /border
self-registers via the plugin config hook with ??=, so a user file command of the same
name — including a Route-A commands/border.md — stays authoritative.
Boundary honesty. The command allowlist is fool-proofing (UX), not a security boundary: an
opencode plugin runs with the opencode process's rights, and the gate's teeth are the CLI and
its ledger, never the adapter. Loading is verified by the opt-in sandboxed-HOME probe
(BORDER_OPENCODE_PROBE=1): a temp HOME/XDG, opencode serve on a control config, and two
HTTP assertions — the border tool id present, /border present exactly once (a duplicate
fails); plugin load failures are silent, so judge by the HTTP responses, never by the exit
code.
What this is not
- Not a vault or secret manager. It detects credentials on their way out; it does not store, rotate, or inject them.
- Not a history rewriter. If a secret is already in a pushed commit, the fix
(
git filter-repoplus credential rotation) is explicitly out of scope and named in the finding text. border refuses rather than rewrites. - Not an escape hatch. There is no force-push flag, no
--i-know-what-im-doing, no per-run expiry of a missing PASS. The refusal paths are the product. If a check cannot be trusted (engine gone, registry unreachable), the result is stop, not proceed. - Not a config runner. You cannot define a custom channel or override what border
shells out to (a
push.command-style escape hatch is deliberately absent). A user-defined command would void the two properties the gate exists to provide: the same-bytes proof (only a built-in channel descriptor knows which artifact bytes the check certified and can re-hash exactly those before the wire) and the fail-closed registry pre-flight (only a built-in knows each registry's response polarity: which status and which body string mean absent, and that everything else is silence, which never means absent). Free-form commands would turn "a PASS covers this exact state" into "a PASS covers whatever your script happened to run": a config runner wearing a gate's clothes. Channels are built-in only; adding one means shipping a descriptor whose probe, packaging, and byte-determinism were measured against the real registry first. - Not a replacement for review or branch protection. It gates this machine's push surface; server-side controls still own everything else.
Security posture
- Redaction is the default output channel. Findings, reports, status lines, and agent
text all flow through masked snippets (
valueDigest+first4…last4, full block for short values) and the run-scoped sanitizer; raw matched bytes never leave memory. - Fail-closed doctrine. "Silence never means absent" is applied mechanically: empty registry stdout, unparsed versions, non-translatable exit codes, unresolvable ownership, corrupt-but-present rules inputs, all become exit 2 or a CRITICAL, never a pass.
- No telemetry, no callbacks.
border checktouches the network only for the registry pre-flight, against the URLs the channels define:registry.npmjs.org,pypi.org, andrubygems.orgare overridable (private indexes;targets.rubygems.host), and the crates.io leg is pinned to publiccrates.iowith no override by design. - No credential handling. Subprocess publishes inherit stdio so tokens and OTP prompts
go between you and
npm/twine/cargo/gem, never through border. - State is inert. Everything border writes lives in
.border/(ledger, run archives, dist, lock), which carries its own.gitignorewith*so agit add -Acannot stage it, and which every scan leg refuses to treat as a finding source or an allow-list target. A repo that tracks.border/**is itself a CRITICAL finding. A single-writer lock makes concurrent runs exit 2 instead of racing.
Changelog
0.5.0 (2026-09-12)
- Add: OpenCode plugin adapter (
exports["./server"]+/border+bordertool) loadable two ways with zero manual install — config-declared"plugin": ["[email protected]"](opencode downloads the package; the plugin spawns its own packaged CLI via a package-relativedist/index.jsresolution,BORDER_BINoverride, PATH fallback) orborder opencode install(marker-identified idempotent file drop, foreign files refused, never overwritten). Trigger: an agent session drives the gate without a shell-out or an install ritual;push --yesstays terminal-only by design — the tool refuses it.
0.4.2 (2026-09-12)
- Fix:
npm publishno longer prints the misleading "bin[border] script name dist/index.js was invalid and removed" warning — package.json now carries the canonical bare-relative bin form npm normalizes to anyway (mechanism traced to @npmcli/package-json publish-side; artifacts were never broken; an always-on test locks the form). - Change: RubyGems freshness (skip-ledger) parity compares a normalized CONTENT
digest instead of raw file bytes —
gem buildstamps the building toolchain (RubyGems <3.6 embeds the build second in gzip headers), so raw-sha repack parity could never be honored on those machines. The ledger digest and the publish-time same-bytes re-hash stay RAW: what gets uploaded is still exactly the certified bytes, and any unparseable artifact fails closed to a full re-check.
0.4.1 (2026-09-11)
- Add: release-coherence rule family — every version source inside a published artifact must agree (pyproject vs MODULE vs filename, gemspec vs gem metadata, Cargo.toml vs crate filename, package.json self-consistency; the git-tag half is a publish-workflow hard gate). Trigger: the aihr incident where a wheel labeled 0.2.2 shipped modules self-reporting 0.2.1 — every pinned consumer silently got old behavior.
- Add:
border roundtriplocal-artifact input — an argument resolving to an existing file (.whl/.tgz/.crate/.gem, closed table) proves unpublished bytes end-to-end; the streaming sha256 of those exact bytes is the ledger identity and the report carriessource:local:<abs>provenance. Honest boundary documented: the local PyPI-wheel lane is residue-INERT by construction (pip fully tracks and prunes; verified live on pip 24.0 AND 25.0.1) — planted-detection demos belong to the hook-executing lanes (npm postinstall, gem extconf, crates build.rs). - Change: pypi roundtrip lane dep-closure calibration — pip's resolver report demotes
dependency-storm files to LOW
dep-ownedrows, so genuine orphans stand alone; the demotion requires a legitimate claimant dist-info (pin Name+Version match, canonical dirname, unique, own site-packages root, not the target) after an independent verifier proved a shadow*-9.9.9.dist-infocould otherwise launder hostile writes.
0.4.0 (2026-09-10)
- Add:
border roundtrip <[ecosystem:]name@version>— fact-proof valve. Installs the package inside a throwaway Docker container, content-hashes the whole filesystem before and after uninstall, and reports exactly what the uninstaller left behind. Persistence surfaces (rc/profile mutations) => CRITICAL; orphan files => HIGH. Verdicts record to the ledger by default (--no-recordopts out); Docker absent => fail-closed, never clean. - Add:
residue.requireProof— when on, a residue-capable artifact only passesborder checkwith a fresh recorded roundtrip proof (bound to rulesHash; flipping the config invalidates cached verdicts). Missing proof => CRITICALroundtrip-proof-missing. - Opt-in test legs: BORDER_ROUNDTRIP_DOCKER=1 (8 real container legs, 4 ecosystems).
0.3.2 (2026-09-10)
- Fix: CLI invoked through the npm
.binshim silently exited 0 without running (entrypoint detection now realpath-canonicalizes both sides). Regression-locked by a pack -> install -> shim-invocation test (BORDER_PACK_TEST=1, wired into CI). - Fix:
publintis an exact-pinned runtime dependency, soborder scan/npm checks work out of the box in consumer installs; README documents the engine posture truthfully. - Add: release-coherence seed test pinning the scan User-Agent version to package.json.
0.3.1 (2026-09-10)
- Add:
border scan [ecosystem:]name@version— static residue inspection of third-party registry packages (npm/PyPI/crates.io/RubyGems), ledger-free by design. - Docs: README scan section; crates envelope normalization now logs scrubbed-file hashes.
0.3.0 (2026-09-09)
- Residue gate (artifact stage). The blanket install-hook CRITICAL becomes a classified
family of seven closed
residue-*rules across tiers T1–T4 plus cross-manager, pairing, and gem-unmatched lanes (see "The residue scan"): a hook matching the closed T1 safe-shape signature drops to MEDIUM; every unknown shape keeps the original CRITICAL row verbatim. Pairing (# BEGIN <id>marker blocks) is detected only —residue-pairing-missingfires on the orphaned WRITE, a verified pair still pays its T3 HIGH; the waiver downgrade path is the 0.4.0 roundtrip valve. Detection classes: C5 pairing discipline, C9 indirection spellings, C10 comment/quote boundaries — evidence frozen insrc/artifacts/RESIDUE-CONTRACT.md. - New
residue: { enabled }config key (default on; strict; the ONLY skip path for the family, and flipping it invalidates cached PASS rows because the toggle rides the fingerprint). The residue rule table and classifier sources are now part of the ledgerrulesHash: editing a signature forces a re-check even at an unchanged commit and tree. - Boundary stated plainly: static analysis proves capability, not fact; border is not a malware sandbox and never executes what it reads.
0.2.0 (2026-09-05)
- Two new push channels, same doctrine end to end: crates (public crates.io only;
literal
Cargo.tomlcoordinates; pre-publish repackage digest-assert) and rubygems (rubygems.org or anytargets.rubygems.hostgemcutter-compatible host; literal-only gemspecs;s.daterejection). - Platforms are now descriptors in a channel registry: each channel owns its config schema, env expansion, coordinate reading, probe, artifact stage, publish argv, and ledger fields in one file. The git/npm/pypi legs share the same publish core they already used; their behavior is unchanged.
- Ledger
confirmedViagainedcrates-jsonandrubygems-json; 0.1.x ledgers parse unchanged. - New prerequisites when those targets are configured:
cargoonPATH(measured against 1.93.1 via rustup) andgem(measured against RubyGems 3.6.7 on ruby 3.3.8); other versions are untested by the gate's same-bytes proofs.
0.1.0
- Initial release: git, npm, and PyPI push channels behind the fingerprint PASS gate.
License
MIT, see LICENSE.
中文概要
border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:npm install -g border-customs 安装,在仓库里跑 border check。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI/crates/RubyGems 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 PASS 记录时才允许 border push --yes 放行。指纹是 sha256(head、porcelain 摘要、规则哈希、暴露面、ref 集合、有效目标)六元组,任何一处变动,旧的 PASS 立即失效,必须重查。流水线:gitleaks(历史+工作区+tag,内置 8.30.1 规则,仓库自带的 ignore 文件直接判 CRITICAL)+ secretlint(进程内,AWS Key 规则强制开启)+ 原生规则(AI 会话产物闭集、提交身份白名单含传输对象检查)+ 注册表预检(版本已存在=必须 bump,名称被外人占有=拒绝;空响应/超时/解析失败一律 exit 2,沉默绝不等于不存在)。构件只构建一次进 .border/dist/,扫描的就是发布的字节,发布时再哈希比对,不一致直接拒发。跳过台账让重复检查不到 1 秒,但回放前先重算指纹并重新 pack 验证新鲜度。报告只输出掩码片段(sha256 摘要 + 前4…后4)。push 是多目标状态机,多 remote 先做全有或全无的 fast-forward 预检,永不 force-push;npm/twine/cargo/gem 的凭据经 stdio 透传,border 从不触碰。0.2.0 新增 crates.io 与 RubyGems 通道(公开 crates.io 固定、rubygems 可用 host 覆盖私有镜像),既有配置行为不变。0.3.0 新增残留扫描(residue gate):发布字节里的安装期钩子按 T0-T4 闭集签名表分类(闭集 T1 树内钩子降为 MEDIUM,未知形态原样保留 CRITICAL),外加跨包管理器写入、配对标记缺失(# BEGIN 块只检测不豁免)与 gem 不可解析扩展共七条 residue-* 规则;residue.enabled 是唯一豁免开关且改动指纹使旧 PASS 失效,规则表与分类器源码进入 rulesHash,改一个签名即强制重查。静态分析只证明"能力"不证明"事实",border 不是恶意软件沙箱,从不执行被读代码。0.4.0 新增证明阀
residue.requireProof(默认关,strict):开启后,发布构件若触发阻断级 residue-* 发现,通道 PASS
还要求账本里存在按其 sha256 索引且 rulesHash 新鲜的 border roundtrip 实测记录——check 自身从不
运行 Docker,证据由 border roundtrip(注册表 spec 或本地构件文件,W2.4b)离线写入(默认记账,--no-record 关闭),clean 与 residue
两种裁决都算"事实已在";缺记录判 roundtrip-proof-missing、rulesHash 过期判
roundtrip-proof-stale(均 CRITICAL/native,与普通发现同受白名单管辖并在 allowHits 枚举),翻转
该配置即轮换 rulesHash,所有缓存 PASS 自动失效。border roundtrip 本身保真边界:Docker 必需、
缺失即 exit 2 绝不假装干净;本地构件输入(W2.4b)——参数解析为已存在的文件即走本地模式,生态仅由扩展名闭表判定
(.whl→pypi、.tgz→npm、.crate→crates、.gem→rubygems),表外一律 exit 2:内容嗅探等于猜测某个管理器的装卸契约,
按猜测定级不予采纳;本地模式全程不触网,文件流式过一遍 sha256(200 MiB 上限在流内超限即闭),同一批字节暂存后
docker cp,闭包解析、m1/m2/m3、残留行与账本证明走的都是与注册表模式完全相同的流水线;出处保持诚实:人机与
--json 输出都盖上 source: "local:<绝对路径>" 与 artifactSha256——摘要即身份,requireProof 阀按同一摘要原样
消费本地证明,本地证明绝不表述为注册表已验证;目录参数、或形如路径却并不存在的参数,在任何拉取发生之前即 exit 2;
wheel 通道诚实边界:本地 PyPI-wheel 通道天然残留惰性(residue-INERT)——pip 完整登记所装文件并清理自建目录,
即便往 wheel 里种入恶意 .data/data/... 条目,卸载后幸存集也为空(2026-09-11 在 pip 25.0.1 与 24.0 上实机验证,
验证器工装);对这种种植判 PASS 是 pip 契约在起作用,不是漏检。本地 wheel 往返仍证明可安装性、benign-clean
与字节级同一性,但种植检测演示属于会执行安装钩子的通道:npm(.tgz 的 postinstall 在 npm install -g
--foreground-scripts 下真实执行)、gem(extconf 自定义写入)与 crates(build.rs);
pypi 通道已做目标收窄校准(W2.4)——安装前先在一次性解析容器里用
pip install --dry-run --report 解析依赖闭包,卸载后仍被闭包内在册依赖认领的残留行降级为 LOW
residue-roundtrip-dep-owned(非阻断,注明归属依赖,仍照常打印);认领只对合法主张者生效:其 dist-info 必须与
解析器的 name-version 钉值完全一致、目录名规范且为该名字唯一发行版,且只能认领自身 site-packages 根内的路径——
安装代码私设的伪影 shadow dist-info 洗白不了任何残留;分类器看不见的刻意继续阻断:
依赖写在自身 RECORD 之外的路径、被两个发行包同时认领的歧义路径、一切 MODIFIED 行(W2.3 首轮那条真实孤儿
/usr/local/share/aihr 正是此类),闭包解析失败则整轮 exit 2,不可验证绝不等于干净;
只有包管理器生命周期触发的写入被观测,显式调用的 bin 自写配置不在射程——W2.3 在
自家 registrar 链上实测到该盲区并留下了 {"plugin": []} 残留证据。clean 是"某一版本构件在某类
机器上"的事实证明,不是安全担保。可选 LLM 层 border 自身从不调用模型 API:llm-request 导出掩码审阅包,llm-ingest 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
发布一致性(0.4.1):起因是 aihr 事故——PyPI 上标为 0.2.2 的 wheel 里 __init__.__version__ 却写着 0.2.1,构件名、元数据与实际模块行为三者不一致,所有 ==0.2.2 的用户静默装上了旧行为;同类漂移在本仓库也出现过两次(npm tarball 的 User-Agent 停在 0.3.0 而 package.json 是 0.3.1;package-lock.json 根版本两个 release 一直躺在 0.1.0)。发布阶段扫描器因此在构件内部逐源交叉核对版本号:release-coherence-version-drift 为 CRITICAL(package.json 与被强制打包的 package-lock、wheel 文件名对 dist-info 目录对 METADATA Version: 对 __init__ 字面量、sdist 的 pyproject 对 PKG-INFO/setup 系字面量、Cargo.toml 对已打包的 Cargo.lock、.gemspec 对 metadata.gz、dist 文件名对 sdist 内 PKG-INFO);版本源存在但无法静态解析(动态 __version__、attr:、dynamic = ["version"]、坏 lockfile)判 MEDIUM release-coherence-unverifiable-source——不可核查绝不静默算干净;第二版本源根本没被打包时不发现在内,缺源不是漂移,这条边界是承重的,误报的门禁会把用户训练成 --force。唯一可静态强制的跨包管理器声明是 opt-in release.twin({pypi, npm} 严格 zod 对列表,未知键 exit 2):同一 run 的 dist 里孪生版本不等 ⇒ CRITICAL release-coherence-twin-drift,消息点名两个构件。规则源文件与 residue 指纹表一样并入 rulesHash,改一个匹配器即令全部缓存 PASS 失效。
OpenCode 插件适配层(0.5.0):零手工装载两条路线——主路是在 opencode.jsonc 声明 "plugin": ["[email protected]"](建议钉具体版本,@latest 每次冷启动都打注册表),opencode 自行下载构件,插件入口走 exports["./server"];插件自带 CLI 解析,顺序为 BORDER_BIN 覆盖 > 包内同侧 dist/index.js > PATH 上的 border,免全局安装、免 PATH 配置。备路是 border opencode install|status|uninstall 文件投递,落盘 $XDG_CONFIG_HOME/opencode 下的 plugins/border.ts 与 commands/border.md(无 XDG 回退 HOME/.config):受管文件凭 marker 行识别身份,同名外人文件 exit 2 拒绝、绝不覆盖,卸载只删自己带 marker 的文件,字节相同即 up to date。装载面只有一个 border 工具(argv-only 生成、无 shell、300 秒与每流 64 KiB 上限,命令闭集 check/push/status/llm-request/llm-ingest/scan/roundtrip/--help)与一条 /border 斜杠命令(config 钩子以 ??= 自注册,用户同名文件命令保持权威)。边界如实说:allowlist 是 UX/防呆而非安全边界——插件进程与 opencode 进程同权,门禁的牙齿在 CLI 与账本,不在适配层;push --yes 按设计留在终端人审,工具端机械拒绝,会话内裸 push 本就是 CLI 合同的 DRY-RUN。装载验证走 opt-in 探针 BORDER_OPENCODE_PROBE=1(临时 HOME/XDG 起 opencode serve 对照组,断言 border 工具 id 与恰好一条 border 命令,重复即 FAIL;插件加载失败是静默的,只认 HTTP 响应不认退出码)。两条路线装载后都需重启 opencode:工具与命令在启动时扫描。
