border-customs 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -608,38 +608,10 @@ dropped file to stderr with the sha256 of its original bytes
608
608
  (`border scan: crates envelope normalized: <file> sha256=<hex>`); the rebuild regenerates
609
609
  them from the normalized manifest, so no scanned byte is silently lost.
610
610
 
611
- ### Engines in consumer installs
612
-
613
- `dist/index.js` is one self-contained esbuild bundle and every runtime asset (vendored
614
- gitleaks rules, prompt template, the secretlint fingerprint snapshot) ships inside `dist/`,
615
- so after `npm i -D border-customs` (or `-g`) the engine surface is:
616
-
617
- - **publint** — installed automatically: it is an exact-pinned runtime dependency of the
618
- package, invoked via bin resolution (package-local `node_modules/.bin`, the hoisted
619
- project-level `node_modules/.bin`, then `PATH`, then `~/.local/bin`). The
620
- `publint-fail` leg of the npm artifact stage works out of the box in a consumer install.
621
- - **secretlint** — no consumer action, ever: the default lint runs **in-process** with the
622
- `@secretlint/*` rule modules already declared as border's own runtime dependencies
623
- (`src/engines/secretlint.ts` header). The `mode: "cli"` fallback that spawns a
624
- `secretlint` binary is border-internal (test transport); it is not reachable from
625
- `border.yaml` or any flag, so installing `secretlint` into your project changes nothing
626
- for border. If the engine probe cannot read the bundled lock snapshot
627
- (`dist/assets/package-lock.json`), the run fails closed as `DEGRADED-ENGINE` exit 2 —
628
- never a silent pass.
629
- - **gitleaks** — stays external, same posture as `git`: border vendors the 8.30.1 rule
630
- config but the binary is yours (on `PATH` or `~/.local/bin/gitleaks`; see the
631
- requirements paragraph at the top and `src/engines/ADAPTER-CONTRACT.md`). Missing
632
- gitleaks means `border check` records a CRITICAL `DEGRADED-ENGINE` finding and exits 2,
633
- and `border scan` exits 2 with `engine binary 'gitleaks' not found; border fails
634
- closed`. Neither command degrades to a skip.
635
- - **trufflehog** — optional third engine, external binary, same posture as gitleaks once
636
- enabled: `engines.trufflehog: true` puts it in the required set, and an absent binary
637
- degrades the run to exit 2 exactly like a missing gitleaks.
638
-
639
- There is no "engine absent ⇒ leg silently skipped" state anywhere: missing required
640
- engines are exit 2 (`2` = the gate could not answer, see
641
- [Exit codes](#exit-codes-the-contract)), and the ledger refuses to write a PASS for a
642
- degraded run.
611
+ ## Engines
612
+
613
+ Engine posture per scanner — vendored vs external, version matrices,
614
+ consumer-install behavior: [docs/engines.md](docs/engines.md).
643
615
 
644
616
  ## Configuration
645
617
 
@@ -839,7 +811,7 @@ plugin entry ships as `exports["./server"]`):
839
811
  ```jsonc
840
812
  {
841
813
  // pin the exact version — an @latest channel hits the registry on every cold start
842
- "plugin": ["border-customs@0.8.2"]
814
+ "plugin": ["border-customs@0.8.4"]
843
815
  }
844
816
  ```
845
817
 
@@ -969,262 +941,9 @@ and every pull request, plus version single-source, typecheck, build, and audit
969
941
 
970
942
  ## Changelog
971
943
 
972
- ### 0.8.2 (2026-11-01)
973
- - Production dependency graph now measures ZERO advisories (`npm audit --omit=dev`): the
974
- pattern rule is vendored (`src/engines/borderPatternRule.ts`, MIT attribution; drops the
975
- unpatchable braces/micromatch + fast-uri chain) and `tool` — an identity function — is
976
- vendored in `plugin/border.ts`, moving `@opencode-ai/plugin` (and its effect-beta → toml
977
- chain) to devDependencies; live-host probe tests keep the activation proof. The CI audit
978
- gate tightens from critical to HIGH so this cannot rot.
979
- - `border sandbox init|pin|status|unpin` — the opt-in pin helper from the Q1 ruling
980
- (machine store only, 0600 atomic writes, §3 self-verification with rollback of its own
981
- writes; deliberately absent from the plugin's agent-reachable command set).
982
- - Security tier for execution-deciding keys (`src/tier.ts`, sibyl §3⁗/§3⁖/§3‡):
983
- `sandboxImage`/`allowBuilds` honor only from fd-chain-qualified machine-store or out-of-repo
984
- `--config` sources; repo declarations, lax-mode files, and hijacked `XDG_CONFIG_HOME` are
985
- named refusals (`sandbox-config-shadowed`/`sandbox-config-unqualified`); the
986
- `BORDER_ALLOW_BUILDS` environment channel is REMOVED (never released, so no migration);
987
- passwd-derived canonical store root — `$HOME` spoofing cannot move it.
988
- - Sandboxed artifact builds: optional `targets.<ch>.sandboxImage` (digest-pinned, tags fail
989
- closed at load) runs consented npm/pypi/crates/rubygems builds — and freshness re-packs —
990
- inside `docker run` with `--network none` proved by an in-container DNS+TCP probe, a
991
- read-only repo mount, tmpfs build, `--cap-drop ALL`, scrubbed env, and the ledger record
992
- bound to the exact image digest (a rotated image invalidates cached PASS). Missing docker
993
- with an image configured is a named exit 2, never a host fallback. New `test/sandbox.test.ts`
994
- pins the docker argv contract token by token.
995
- - Hardened the npm pack-phase guard (sibyl must_fix): ALL package.json manifests in the tree
996
- are scanned, not just the root (a workspaces child's `prepare` is now refused on npm < 11),
997
- and unreadable manifests or a walk-cap hit count as a hook — unknown is never assumed safe.
998
- - Machine-side config (HR decoupling handoff 2026-10-23): resolution chain is now
999
- `--config > $BORDER_CONFIG (fail-closed) > machine store (XDG/border/machines, keyed by
1000
- canonical remote URL) > ./border.yaml > git-remote inference`, so gate config and waiver
1001
- lists live on the checking machine and never need to be committed into a product repo.
1002
- Allow entries gained `values` (fixture-set waivers that never cover a new value), `until`
1003
- (UTC sunset, expired entries echoed never silently honored), `\!` archive-member escaping,
1004
- and `rules.aliases` (rule renames no longer strand waivers). Border state was already
1005
- porcelain-invisible (`.border/.gitignore` self-ignore; verified 2026-10-23).
1006
- - Provenance: every check and push record carries `engineBuild`
1007
- (`<version>+<commit>[.dirty]`, the string `border --version` prints), so a verdict names
1008
- the exact border build that produced it. Motivated by a machine where every gate ran off
1009
- an unpinned worktree bundle and no record said so; worktree use is now visible on the
1010
- record face (see "Pinned consumption"). Bundles are stamped by `tools/build.mjs`.
1011
- - Security (breaking): artifact build stages now require operator consent. A hostile clone can
1012
- ship its own border.yaml declaring pypi/rubygems targets, and `border check` built them —
1013
- PEP 517 backends and gemspecs ARE code (`python3 -m build --no-isolation` and `gem build`
1014
- markers reproduced 2026-10-23). Default runs are now scan-only (zero build spawns, pinned by
1015
- `test/build.execMarkers.test.ts`); consent comes only from outside the audited tree:
1016
- `--allow-builds` or `BORDER_ALLOW_BUILDS=1`. A scan-only PASS records no artifact digests and
1017
- therefore can never back a registry publish; `border scan` of third-party artifacts requires
1018
- the same consent (named exit 2 without it).
1019
- - Security: refuse npm < 11 packages that declare `prepack`/`prepare`/`postpack` before
1020
- spawning `npm pack`. npm 10 executes `prepare` even with `--ignore-scripts` (reproduced on
1021
- 10.9.7; npm 11.19.1 is clean), so the old defense could execute code from the audited repo.
1022
- The probe also runs before freshness re-packs. New regression tests pin the refusal and the
1023
- never-fires hook marker.
1024
- - Fix: the Windows drive-letter path pattern no longer fires on JS escapes in prose
1025
- (`raw-repoPath:\n`); the UNC pattern no longer spans newlines.
1026
- - Docs: the `--json` findings schema is stated explicitly (`path`, no `file` alias);
1027
- npm-target prerequisites now name the npm 11 boundary.
1028
-
1029
- ### 0.8.1 (2026-10-06)
1030
- - Maintenance release: certificate-chain rebuild off the scrubbed lineage (INCIDENT-20261006
1031
- identity-exposure cleanup). No functional change vs 0.8.0; 0.8.0's tarball is byte-intact but
1032
- its OIDC provenance pin moved with the history rewrite, so a fresh tag + publish restores a
1033
- verifiable chain. Rotation: package.json + lock roots + plugin marker + scan UA + README pins +
1034
- this entry.
1035
-
1036
- ### 0.8.0 (2026-09-30)
1037
- - Add: **`border opencode inspect [--json]`** — the load-surface self-audit becomes a gate-owned
1038
- surface: six read-only aspects (cache self-consistency, spec freshness vs pin/registry, identity
1039
- handshake replay, Route A byte parity, Route A∧B dual-route collision, scope declaration),
1040
- fail-closed aggregation FAIL 1 > CANNOT 2 > PASS 0. `--json` pins `scope:"v1"` — V2 load surfaces
1041
- are a declared blind spot, not silently covered. Zero writes; `--purge` stays in the cron shell.
1042
- - Change: **`exfil` joins the plugin's closed command list** (owner ruling R1 under the four-question
1043
- eligibility gate — no mutation, no credentials, no foreign-byte execution, no local egress;
1044
- packet: `.omo/evidence/F-EXFIL-ALLOWLIST-RULING-2026-09-30.md`). `border opencode …` verbs stay
1045
- terminal-only: they mutate `$XDG_CONFIG_HOME` and fail the gate's first question.
1046
- - Change: `tools/plugin-drift-watch.mjs` retires to a thin shell over `border opencode inspect --json`
1047
- (single source of verdicts); the cron rc contract is byte-stable — FRESH 0, DRIFT 1, NOT-CACHED 0
1048
- (special-cased per ruling B2), CANNOT 2 — and the crontab line is unchanged.
1049
-
1050
- ### 0.7.1 (2026-09-30)
1051
- - Fix: **release-gate test ruler race** (`test/push.integration.test.ts` I4) — the
1052
- "dry-run made ZERO verdaccio requests" invariant counted lines in verdaccio's stdout
1053
- log, an eventually-consistent view: on a saturated CI runner the gate-leg probe lines
1054
- of an earlier phase land after the snapshot, inflating the dry-run delta (v0.7.0 CI
1055
- run 36671401844 failed `6 !== 2` while the identical bytes pass locally). The suite
1056
- now takes every snapshot through a FIFO drain barrier (sentinel request whose log line
1057
- must be visible before counting; sentinel traffic is excluded from the count). The
1058
- zero-network dry-run contract itself is unchanged — verified request-free on both sides
1059
- of the barrier; only the measuring instrument was repaired.
1060
-
1061
- ### 0.7.0 (2026-09-28)
1062
- - Add: **OpenCode 2 (beta) plugin compatibility** — the plugin module is dual-shaped
1063
- (`{id, setup}` for V2 hosts, frozen V1 `server()`), sharing the pre-spawn
1064
- allowlist/`--yes` gates and the 0.5.1 identity handshake across both shapes;
1065
- refusal/handshake/rendering byte-parity between edges is pinned by
1066
- `test/opencode.v2shape.test.ts` while the V1 path stays 16/16 on unmodified
1067
- tests. Root `index.ts` re-export shipped for V2 directory discovery (which
1068
- bypasses the exports map; live-proven on `@opencode-ai/cli` beta-19271).
1069
- - Fix: the live V1 probe is anti-vacuous — opting in (`BORDER_OPENCODE_PROBE=1`)
1070
- without a resolvable binary or built dist now FAILS naming the remedy instead
1071
- of skipping green-adjacent (`BORDER_OPENCODE_BIN` override added).
1072
- - Docs: support matrix (V1 object entry ≥ 1.18.29; V2 project-local verified;
1073
- `/border` command V1-only pending the V2 delivery API).
1074
-
1075
- ### 0.6.0 (2026-09-25)
1076
- - Add: **the exfil lens** — five rules (`exfil-rfc1918`, `exfil-ssh-target`,
1077
- `exfil-home-path`, `exfil-cred-location`, `exfil-host-profile`, `exfil-machine-binding`) that flag
1078
- exfil-shaped literals on about-to-be-public surfaces, the gap classic secret
1079
- scanners leave open because an internal IP is not a credential. The observed
1080
- severity of every (rule × facet × channel) cell lives in one machine-readable
1081
- home, `src/exfil/severity.ts`, rendered — never re-decided — by the check
1082
- pipeline, the new read-only `border exfil <rev|url>` CLI (tip tree by default,
1083
- `--deep` history, URL modes into a destroyed temp clone) and the README table,
1084
- each cell pinned by a docs guard. The tree HIGH family ships as vendored
1085
- twins (gitleaks TOML + secretlint patterns, ids byte-equal, verdict parity
1086
- tested); the commit-message facet is native-exclusive with a `:message` id
1087
- family so allow-list waivers can never cross facets; tag notes stay with the
1088
- pre-existing `tag-message-secret` engine leg. All exfil sources are
1089
- fingerprinted into `rulesHash`.
1090
- - Add: **post-push landing verification** — after git legs move, the remote tip
1091
- is re-observed and scanned with the same machinery; clean appends an
1092
- append-only `t:"landing"` record and preserves the exit, a blocking hit exits
1093
- 1 ("ALREADY PUBLIC; border detects, never erases"), an unobservable tip exits
1094
- 2 with no record minted. Executed-push rows are never mutated; DRY-RUN lands
1095
- nothing.
1096
- - Add: **fixture band + assembly doctrine** — 10.200.0.0/16 is reserved as the
1097
- always-exempt-on-tree test range (exemption mirrored natively in both twins,
1098
- message facet deliberately not exempt), and the test corpus assembles every
1099
- other leak shape at runtime (`assembleOctet`/`assembleHost`) so checked-out
1100
- bytes carry no complete leak tokens: border's own lens is green against
1101
- border's own corpus. Golden-suite coverage includes an opt-in truth lane
1102
- (`BORDER_EXFIL_TRUTH=1`) with a documented red-anchor lifetime clause.
1103
- - Note: this release line traces to the 0.5.1 impersonation-wave audit; that
1104
- wave's H-3 follow-up — scrubbing this repo's own historical tip surfaces —
1105
- landed here as byte edits with the extracted values withheld from public
1106
- records by design (see the sanitized `.omo/evidence/` trail, not this log).
1107
-
1108
- ### 0.5.1 (2026-09-24)
1109
- - Fix: **plugin impersonation — the `border` tool could spawn the host binary instead of the
1110
- gate.** On Route B the 0.5.0 adapter resolved its packaged CLI as `process.execPath +
1111
- dist/index.js`, but inside the opencode plugin host `process.execPath` is the opencode
1112
- (bun-compiled) binary — sessions saw opencode's banner for `--help` and opencode usage
1113
- errors for every other command, while the tool's schema still promised border's
1114
- fail-closed contract. The resolution order is now: `BORDER_BIN` (wins alone; a broken
1115
- override never silently falls back) → packaged `dist/index.js` exec'd directly via its
1116
- shebang, else under `PATH` `node` → `border` on `PATH`. `process.execPath` is removed from
1117
- the adapter, pinned by a source-level regression test, and **every candidate must pass an
1118
- identity handshake** (`--help` answering with border's banner + `usage: border`, exit 0,
1119
- 20 s cap) before any user argv is spawned — failure reports `cannot-answer` at exit 2 and
1120
- runs nothing else (spawn failures now map to 2, not 127, aligning the tool with the CLI's
1121
- exit-code contract). Regression tests cover the unspawnable-override and the
1122
- opencode-style-impostor fixtures; the impostor logs prove user argv never reaches it.
1123
- Honest boundary: the handshake catches mistaken identity (host binaries, PATH accidents),
1124
- not a deliberately forged banner.
1125
-
1126
- ### 0.5.0 (2026-09-12)
1127
- - Add: OpenCode plugin adapter (`exports["./server"]` + `/border` + `border` tool) loadable
1128
- two ways with zero manual install — config-declared `"plugin": ["border-customs@0.5.0"]`
1129
- (opencode downloads the package; the plugin spawns its own packaged CLI via a
1130
- package-relative `dist/index.js` resolution, `BORDER_BIN` override, PATH fallback) or
1131
- `border opencode install` (marker-identified idempotent file drop, foreign files refused,
1132
- never overwritten). Trigger: an agent session drives the gate without a shell-out or an
1133
- install ritual; `push --yes` stays terminal-only by design — the tool refuses it.
1134
-
1135
- ### 0.4.2 (2026-09-12)
1136
- - Fix: `npm publish` no longer prints the misleading "bin[border] script name
1137
- dist/index.js was invalid and removed" warning — package.json now carries the
1138
- canonical bare-relative bin form npm normalizes to anyway (mechanism traced to
1139
- @npmcli/package-json publish-side; artifacts were never broken; an always-on
1140
- test locks the form).
1141
- - Change: RubyGems freshness (skip-ledger) parity compares a normalized CONTENT
1142
- digest instead of raw file bytes — `gem build` stamps the building toolchain
1143
- (RubyGems <3.6 embeds the build second in gzip headers), so raw-sha repack
1144
- parity could never be honored on those machines. The ledger digest and the
1145
- publish-time same-bytes re-hash stay RAW: what gets uploaded is still exactly
1146
- the certified bytes, and any unparseable artifact fails closed to a full
1147
- re-check.
1148
-
1149
- ### 0.4.1 (2026-09-11)
1150
- - Add: release-coherence rule family — every version source inside a published artifact
1151
- must agree (pyproject vs MODULE vs filename, gemspec vs gem metadata, Cargo.toml vs
1152
- crate filename, package.json self-consistency; the git-tag half is a publish-workflow
1153
- hard gate). Trigger: the aihr incident where a wheel labeled 0.2.2 shipped modules
1154
- self-reporting 0.2.1 — every pinned consumer silently got old behavior.
1155
- - Add: `border roundtrip` local-artifact input — an argument resolving to an existing
1156
- file (.whl/.tgz/.crate/.gem, closed table) proves unpublished bytes end-to-end; the
1157
- streaming sha256 of those exact bytes is the ledger identity and the report carries
1158
- `source:local:<abs>` provenance. Honest boundary documented: the local PyPI-wheel lane
1159
- is residue-INERT by construction (pip fully tracks and prunes; verified live on pip
1160
- 24.0 AND 25.0.1) — planted-detection demos belong to the hook-executing lanes
1161
- (npm postinstall, gem extconf, crates build.rs).
1162
- - Change: pypi roundtrip lane dep-closure calibration — pip's resolver report demotes
1163
- dependency-storm files to LOW `dep-owned` rows, so genuine orphans stand alone; the
1164
- demotion requires a legitimate claimant dist-info (pin Name+Version match, canonical
1165
- dirname, unique, own site-packages root, not the target) after an independent verifier
1166
- proved a shadow `*-9.9.9.dist-info` could otherwise launder hostile writes.
1167
-
1168
- ### 0.4.0 (2026-09-10)
1169
- - Add: `border roundtrip <[ecosystem:]name@version>` — fact-proof valve. Installs the
1170
- package inside a throwaway Docker container, content-hashes the whole filesystem before
1171
- and after uninstall, and reports exactly what the uninstaller left behind. Persistence
1172
- surfaces (rc/profile mutations) => CRITICAL; orphan files => HIGH. Verdicts record to
1173
- the ledger by default (`--no-record` opts out); Docker absent => fail-closed, never clean.
1174
- - Add: `residue.requireProof` — when on, a residue-capable artifact only passes `border
1175
- check` with a fresh recorded roundtrip proof (bound to rulesHash; flipping the config
1176
- invalidates cached verdicts). Missing proof => CRITICAL `roundtrip-proof-missing`.
1177
- - Opt-in test legs: BORDER_ROUNDTRIP_DOCKER=1 (8 real container legs, 4 ecosystems).
1178
-
1179
- ### 0.3.2 (2026-09-10)
1180
- - Fix: CLI invoked through the npm `.bin` shim silently exited 0 without running
1181
- (entrypoint detection now realpath-canonicalizes both sides). Regression-locked by a
1182
- pack -> install -> shim-invocation test (`BORDER_PACK_TEST=1`, wired into CI).
1183
- - Fix: `publint` is an exact-pinned runtime dependency, so `border scan`/npm checks work
1184
- out of the box in consumer installs; README documents the engine posture truthfully.
1185
- - Add: release-coherence seed test pinning the scan User-Agent version to package.json.
1186
-
1187
- ### 0.3.1 (2026-09-10)
1188
- - Add: `border scan [ecosystem:]name@version` — static residue inspection of third-party
1189
- registry packages (npm/PyPI/crates.io/RubyGems), ledger-free by design.
1190
- - Docs: README scan section; crates envelope normalization now logs scrubbed-file hashes.
1191
-
1192
- ### 0.3.0 (2026-09-09)
1193
-
1194
- - **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
1195
- family of seven closed `residue-*` rules across tiers T1–T4 plus cross-manager, pairing,
1196
- and gem-unmatched lanes (see "The residue scan"): a hook matching the closed T1 safe-shape
1197
- signature drops to MEDIUM; every unknown shape keeps the original CRITICAL row verbatim.
1198
- Pairing (`# BEGIN <id>` marker blocks) is **detected only** — `residue-pairing-missing`
1199
- fires on the orphaned WRITE, a verified pair still pays its T3 HIGH; the waiver downgrade
1200
- path is the 0.4.0 roundtrip valve. Detection classes: C5 pairing discipline, C9 indirection
1201
- spellings, C10 comment/quote boundaries — evidence frozen in `src/artifacts/RESIDUE-CONTRACT.md`.
1202
- - New `residue: { enabled }` config key (default on; strict; the ONLY skip path for the family,
1203
- and flipping it invalidates cached PASS rows because the toggle rides the fingerprint). The
1204
- residue rule table and classifier sources are now part of the ledger `rulesHash`: editing a
1205
- signature forces a re-check even at an unchanged commit and tree.
1206
- - Boundary stated plainly: static analysis proves *capability*, not *fact*; border is not a
1207
- malware sandbox and never executes what it reads.
1208
-
1209
- ### 0.2.0 (2026-09-05)
1210
-
1211
- - Two new push channels, same doctrine end to end: **crates** (public crates.io only;
1212
- literal `Cargo.toml` coordinates; pre-publish repackage digest-assert) and **rubygems**
1213
- (rubygems.org or any `targets.rubygems.host` gemcutter-compatible host; literal-only
1214
- gemspecs; `s.date` rejection).
1215
- - Platforms are now descriptors in a channel registry: each channel owns its config schema,
1216
- env expansion, coordinate reading, probe, artifact stage, publish argv, and ledger fields
1217
- in one file. The git/npm/pypi legs share the same publish core they already used; their
1218
- behavior is unchanged.
1219
- - Ledger `confirmedVia` gained `crates-json` and `rubygems-json`; 0.1.x ledgers parse
1220
- unchanged.
1221
- - New prerequisites when those targets are configured: `cargo` on `PATH` (measured against
1222
- 1.93.1 via rustup) and `gem` (measured against RubyGems 3.6.7 on ruby 3.3.8); other
1223
- versions are untested by the gate's same-bytes proofs.
1224
-
1225
- ### 0.1.0
1226
-
1227
- - Initial release: git, npm, and PyPI push channels behind the fingerprint PASS gate.
944
+ Full per-version history with the motivating incident for each release:
945
+ [CHANGELOG.md](CHANGELOG.md). Release receipts (build provenance, gate transcripts):
946
+ [docs/release/](docs/release/).
1228
947
 
1229
948
  ## License
1230
949
 
@@ -31,6 +31,11 @@ export type RuleOptions = {
31
31
  readonly hosts?: readonly string[];
32
32
  /** exemption-band override (production default DEFAULT_EXEMPT_BANDS; [] forces in-range arms red in unit tests). */
33
33
  readonly exemptBands?: readonly string[];
34
+ /** source path (tree facet only) — drives the syntax-aware credential-location
35
+ * guard (handoff b61b42a): JS/TS sources classify .env tokens by literal spans. */
36
+ readonly filePath?: string;
37
+ /** one-shot diagnostic sink (parse-failure notes; wired to stderr by the tree leg). */
38
+ readonly onNote?: (line: string) => void;
34
39
  };
35
40
 
36
41
  // ---------------------------------------------------------------- IPv4 machinery
@@ -4,6 +4,7 @@
4
4
  // Self-stealth: no absolute workspace paths and no dotted quads in this file
5
5
  // (see rules.ts header for the 0.5.1 dogfood lesson this obeys).
6
6
  import type { RuleHit, RuleOptions } from "./rules.ts";
7
+ import { inSpan, isJsTsPath, literalSpans, type LiteralSpan } from "./tsSpans.ts";
7
8
 
8
9
  // ---------------------------------------------------------------- exfil-home-path
9
10
 
@@ -52,20 +53,48 @@ function envTokenIsLocation(tok: string): boolean {
52
53
  * prefix, or has a real file-stem receiver (≥3 chars, never the process/os
53
54
  * API names); the JS optional-chain read `x?.env` never counts.
54
55
  */
55
- export function matchCredLocation(text: string, _o: RuleOptions): RuleHit[] {
56
+ export function matchCredLocation(text: string, o: RuleOptions): RuleHit[] {
56
57
  const hits: RuleHit[] = [];
57
58
  const push = (matched: string, line: number): void => {
58
59
  hits.push({ rule: "exfil-cred-location", matched, line, message: "Reference to a credential-bearing location (env file / pypirc / SSHPASS); the location itself, value not required to fire." });
59
60
  };
60
- text.split("\n").forEach((body, idx) => {
61
- for (const m of body.matchAll(PYPIRC_RE)) push(m[0] ?? "", idx + 1);
62
- for (const m of body.matchAll(SSHPASS_RE)) push(m[0] ?? "", idx + 1);
61
+ // Syntax-aware guard (handoff b61b42a §3): in JS/TS sources a .env token is a
62
+ // path candidate ONLY inside a string-like literal span. Property accesses
63
+ // (opts.env), short receivers and comment text are syntactically not paths —
64
+ // no lexical rule over receiver names can tell them apart (read-aloud proof
65
+ // in the handoff). Parse failure ⇒ old behavior + one stderr note, exit code
66
+ // untouched. pypirc/SSHPASS tokens are untouched by the guard, in any file.
67
+ const filePath = o.filePath ?? "";
68
+ let spans: LiteralSpan[] | null | undefined;
69
+ const spansFor = (): LiteralSpan[] | null => {
70
+ if (spans === undefined) {
71
+ spans = literalSpans(filePath, text);
72
+ if (spans === null) o.onNote?.(`${filePath}: JS/TS parse unavailable — credential-location matches reported by the pre-0.8.3 text rule for this file`);
73
+ }
74
+ return spans;
75
+ };
76
+ const guarded = isJsTsPath(filePath);
77
+ let lineOffset = 0;
78
+ for (const [idx, body] of text.split("\n").entries()) {
79
+ const line = idx + 1;
80
+ for (const m of body.matchAll(PYPIRC_RE)) push(m[0] ?? "", line);
81
+ for (const m of body.matchAll(SSHPASS_RE)) push(m[0] ?? "", line);
63
82
  for (const m of body.matchAll(ENVFILE_RE)) {
64
83
  const tok = m[0] ?? "";
65
84
  if ((body[(m.index ?? 0) - 1] ?? "") === "?") continue;
66
- if (envTokenIsLocation(tok)) push(tok, idx + 1);
85
+ if (!envTokenIsLocation(tok)) continue;
86
+ if (guarded) {
87
+ const sp = spansFor();
88
+ // token starts one char past the match (leading '.'); comment and
89
+ // identifier positions sit outside every literal span.
90
+ const offset = lineOffset + (m.index ?? 0) + 1;
91
+ if (sp === null) { /* parse-failure fallback: report (old behavior) */ }
92
+ else if (!inSpan(sp, offset)) continue;
93
+ }
94
+ push(tok, line);
67
95
  }
68
- });
96
+ lineOffset += body.length + 1;
97
+ }
69
98
  return hits;
70
99
  }
71
100
 
@@ -69,6 +69,12 @@ export type ExfilScanInput = {
69
69
  readonly source: string;
70
70
  /** operator's rules.hosts (config.ts:102) for the ssh-target predicate. */
71
71
  readonly hosts?: readonly string[];
72
+ /** source file path (tree facet) — threaded to predicates for the syntax-aware
73
+ * credential-location guard; message facets keep it undefined (a commit message
74
+ * is not JS source). */
75
+ readonly filePath?: string;
76
+ /** parse-failure diagnostic sink for the tree leg. */
77
+ readonly onNote?: (line: string) => void;
72
78
  /**
73
79
  * Ingest hook mirroring the redact.ts adapter contract ("every adapter
74
80
  * registers every value it ingests"): the check pipeline passes
@@ -111,7 +117,12 @@ const PREDICATES: Readonly<Record<ExfilRuleId, (text: string, o: RuleOptions) =>
111
117
  */
112
118
  function scanFacet(input: ExfilScanInput, facet: Extract<ExfilFacet, "tree" | "message">): ExfilFinding[] {
113
119
  const channel: ExfilChannel = "native";
114
- const options: RuleOptions = { facet, ...(input.hosts !== undefined ? { hosts: input.hosts } : {}) };
120
+ const options: RuleOptions = {
121
+ facet,
122
+ ...(input.hosts !== undefined ? { hosts: input.hosts } : {}),
123
+ ...(input.filePath !== undefined ? { filePath: input.filePath } : {}),
124
+ ...(input.onNote !== undefined ? { onNote: input.onNote } : {}),
125
+ };
115
126
  const findings: ExfilFinding[] = [];
116
127
  for (const rule of emitRulesFor(facet, channel)) {
117
128
  const severity = observedSeverity(rule, facet, channel);
@@ -0,0 +1,88 @@
1
+ // provenance: Abathur handoff 2026-11-01 (rule-class-3 false positives, commit b61b42a).
2
+ //
3
+ // `.env` is a disk location inside a string literal and a property access inside
4
+ // code — the same characters, a SYNTACTIC distinction no receiver-shaped regex can
5
+ // draw (opts vs secrets are both plain identifiers; proven by the handoff's
6
+ // read-aloud round). This module answers one question for JS/TS sources: at which
7
+ // byte spans does the file contain string-like literal text?
8
+ //
9
+ // Contract (handoff §3, decided there):
10
+ // * string literals, no-substitution template literals, and template fragments
11
+ // (head/middle/tail) are literal spans — the ${...} regions are INCLUDED,
12
+ // deliberately conservative: a location inside an interpolation is still the
13
+ // author writing a location into source text;
14
+ // * comments are NOT literal spans (the AST visitor never yields them);
15
+ // * parse diagnostics ⇒ null ⇒ the CALLER keeps the old behavior and emits the
16
+ // required stderr diagnostic. This module stays silent and side-effect free.
17
+ // typescript is a real dependency resolved at runtime (esbuild keeps it external;
18
+ // the createRequire banner in the bundle resolves it from the installed package).
19
+ import { createRequire } from "node:module";
20
+
21
+ const JS_TS_KINDS: Readonly<Record<string, string>> = {
22
+ ".js": "JS", ".jsx": "JSX", ".mjs": "JS", ".cjs": "JS",
23
+ ".ts": "TS", ".tsx": "TSX", ".mts": "TS", ".cts": "TS",
24
+ };
25
+
26
+ export type LiteralSpan = { readonly start: number; readonly end: number };
27
+
28
+ export function isJsTsPath(filePath: string): boolean {
29
+ const dot = filePath.lastIndexOf(".");
30
+ return dot >= 0 && JS_TS_KINDS[filePath.slice(dot).toLowerCase()] !== undefined;
31
+ }
32
+
33
+ let tsApi: any; // eslint-disable-line @typescript-eslint/no-explicit-any -- compiler API is structural here
34
+ function loadTs(): any {
35
+ if (tsApi === undefined) {
36
+ try {
37
+ tsApi = createRequire(import.meta.url)("typescript");
38
+ } catch {
39
+ tsApi = null; // resolution failure behaves like a parse failure: caller keeps old behavior + diagnostic
40
+ }
41
+ }
42
+ return tsApi;
43
+ }
44
+
45
+ /** Literal spans for a JS/TS source; null when no tree can be trusted. */
46
+ export function literalSpans(filePath: string, text: string): LiteralSpan[] | null {
47
+ const ts = loadTs();
48
+ if (ts === null) return null;
49
+ const dot = filePath.lastIndexOf(".");
50
+ const kind = JS_TS_KINDS[filePath.slice(dot).toLowerCase()] ?? "TS";
51
+ const scriptKind = ts.ScriptKind[kind as keyof typeof ts.ScriptKind];
52
+ let sf: any; // eslint-disable-line @typescript-eslint/no-explicit-any
53
+ try {
54
+ sf = ts.createSourceFile(filePath, text, scriptKind, /* setParentNodes */ true);
55
+ } catch {
56
+ return null;
57
+ }
58
+ const parseDiagnostics: unknown[] = sf.parseDiagnostics ?? [];
59
+ if (parseDiagnostics.length > 0) return null;
60
+ const spans: LiteralSpan[] = [];
61
+ const visit = (node: any): void => { // eslint-disable-line @typescript-eslint/no-explicit-any
62
+ const k = node.kind as number;
63
+ if (
64
+ k === ts.SyntaxKind.StringLiteral
65
+ || k === ts.SyntaxKind.NoSubstitutionTemplateLiteral
66
+ || k === ts.SyntaxKind.TemplateHead
67
+ || k === ts.SyntaxKind.TemplateMiddle
68
+ || k === ts.SyntaxKind.TemplateTail
69
+ ) {
70
+ spans.push({ start: node.getStart(sf), end: node.getEnd() });
71
+ }
72
+ node.forEachChild(visit);
73
+ };
74
+ visit(sf);
75
+ spans.sort((a: LiteralSpan, b: LiteralSpan) => a.start - b.start);
76
+ return spans;
77
+ }
78
+
79
+ /** Membership: is offset inside any span? Linear with early exit — literal
80
+ * spans nest (template head inside full template), and nested intervals
81
+ * defeat a plain binary walk (a right sibling can shadow its left ancestor). */
82
+ export function inSpan(spans: readonly LiteralSpan[], offset: number): boolean {
83
+ for (const s of spans) {
84
+ if (s.start > offset) break;
85
+ if (offset < s.end) return true;
86
+ }
87
+ return false;
88
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "border-customs",
9
- "version": "0.8.2",
9
+ "version": "0.8.4",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@secretlint/core": "13.0.5",
@@ -15,8 +15,9 @@
15
15
  "@secretlint/secretlint-rule-preset-recommend": "13.0.5",
16
16
  "@textlint/regexp-string-matcher": "2.0.2",
17
17
  "publint": "0.3.24",
18
+ "typescript": "5.9.3",
18
19
  "yaml": "2.9.0",
19
- "zod": "4.5.4"
20
+ "zod": "4.6.7"
20
21
  },
21
22
  "bin": {
22
23
  "border": "dist/index.js"
@@ -27,7 +28,6 @@
27
28
  "@types/node": "22.19.0",
28
29
  "esbuild": "0.25.12",
29
30
  "secretlint": "13.0.5",
30
- "typescript": "5.9.3",
31
31
  "verdaccio": "6.10.2"
32
32
  },
33
33
  "engines": {
@@ -5760,7 +5760,6 @@
5760
5760
  "version": "5.9.3",
5761
5761
  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
5762
5762
  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
5763
- "dev": true,
5764
5763
  "license": "Apache-2.0",
5765
5764
  "bin": {
5766
5765
  "tsc": "bin/tsc",
@@ -6105,9 +6104,9 @@
6105
6104
  }
6106
6105
  },
6107
6106
  "node_modules/zod": {
6108
- "version": "4.5.4",
6109
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
6110
- "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
6107
+ "version": "4.6.7",
6108
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
6109
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
6111
6110
  "license": "MIT",
6112
6111
  "funding": {
6113
6112
  "url": "https://github.com/sponsors/colinhacks"