border-customs 0.5.0 → 0.5.1

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
@@ -638,13 +638,20 @@ plugin entry ships as `exports["./server"]`):
638
638
  ```jsonc
639
639
  {
640
640
  // pin the exact version — an @latest channel hits the registry on every cold start
641
- "plugin": ["border-customs@0.5.0"]
641
+ "plugin": ["border-customs@0.5.1"]
642
642
  }
643
643
  ```
644
644
 
645
645
  No global install and no PATH entry are required for it: the plugin resolves its CLI from the
646
- package's own `dist/index.js` sitting next to the plugin module, with `BORDER_BIN` as an
647
- explicit override that wins first and `border` on `PATH` as the file-drop route's fallback.
646
+ package's own `dist/index.js` sitting next to the plugin module — exec'd directly through its
647
+ shebang, or under `node` from `PATH` if that is not runnable — with `BORDER_BIN` as an explicit
648
+ override that wins alone (a broken override is a loud error, never a silent fallback) and
649
+ `border` on `PATH` as the file-drop route's fallback. Every candidate must first pass an
650
+ **identity handshake**: the adapter spawns `--help` and requires the border banner and
651
+ `usage: border` line before any user argv is handed over; the host binary is never used as a
652
+ runtime (0.5.0 spawned `process.execPath`, which inside a plugin host is opencode itself —
653
+ see the 0.5.1 changelog). Nothing that fails the handshake runs, and no fallback to a
654
+ different binary exists.
648
655
  The alternative is the file-drop installer, `border opencode install|status|uninstall`, which
649
656
  writes `plugins/border.ts` and `commands/border.md` into `$XDG_CONFIG_HOME/opencode` (with an
650
657
  `HOME/.config` fallback) under marker identity: a foreign file at either path is refused with
@@ -656,10 +663,12 @@ What loads is one agent tool `border` plus a `/border` slash command. The tool s
656
663
  argv-only (no shell; 300 s cap, 64 KiB per stream) restricted to the closed command list
657
664
  `check`, `push`, `status`, `llm-request`, `llm-ingest`, `scan`, `roundtrip`, `--help`, and
658
665
  renders the exit-code contract unchanged: 0 pass, 1 gate-blocked or partial push, 2 the gate
659
- could not answer. The tool REFUSES `--yes` — a real push is the human gate, terminal-only, and
660
- a bare `border push` from the session is DRY-RUN by the CLI's own contract anyway. `/border`
661
- self-registers via the plugin `config` hook with `??=`, so a user file command of the same
662
- name — including a Route-A `commands/border.md` — stays authoritative.
666
+ could not answer — which since 0.5.1 also covers "no border CLI found or verified": the tool
667
+ answers exit 2 cannot-answer and spawns nothing else. The tool REFUSES `--yes` — a real push
668
+ is the human gate, terminal-only, and a bare `border push` from the session is DRY-RUN by the
669
+ CLI's own contract anyway. `/border` self-registers via the plugin `config` hook with `??=`, so
670
+ a user file command of the same name — including a Route-A `commands/border.md` — stays
671
+ authoritative.
663
672
 
664
673
  **Boundary honesty.** The command allowlist is fool-proofing (UX), not a security boundary: an
665
674
  opencode plugin runs with the opencode process's rights, and the gate's teeth are the CLI and
@@ -715,6 +724,24 @@ code.
715
724
 
716
725
  ## Changelog
717
726
 
727
+ ### 0.5.1 (2026-09-24)
728
+ - Fix: **plugin impersonation — the `border` tool could spawn the host binary instead of the
729
+ gate.** On Route B the 0.5.0 adapter resolved its packaged CLI as `process.execPath +
730
+ dist/index.js`, but inside the opencode plugin host `process.execPath` is the opencode
731
+ (bun-compiled) binary — sessions saw opencode's banner for `--help` and opencode usage
732
+ errors for every other command, while the tool's schema still promised border's
733
+ fail-closed contract. The resolution order is now: `BORDER_BIN` (wins alone; a broken
734
+ override never silently falls back) → packaged `dist/index.js` exec'd directly via its
735
+ shebang, else under `PATH` `node` → `border` on `PATH`. `process.execPath` is removed from
736
+ the adapter, pinned by a source-level regression test, and **every candidate must pass an
737
+ identity handshake** (`--help` answering with border's banner + `usage: border`, exit 0,
738
+ 20 s cap) before any user argv is spawned — failure reports `cannot-answer` at exit 2 and
739
+ runs nothing else (spawn failures now map to 2, not 127, aligning the tool with the CLI's
740
+ exit-code contract). Regression tests cover the unspawnable-override and the
741
+ opencode-style-impostor fixtures; the impostor logs prove user argv never reaches it.
742
+ Honest boundary: the handshake catches mistaken identity (host binaries, PATH accidents),
743
+ not a deliberately forged banner.
744
+
718
745
  ### 0.5.0 (2026-09-12)
719
746
  - Add: OpenCode plugin adapter (`exports["./server"]` + `/border` + `border` tool) loadable
720
747
  two ways with zero manual install — config-declared `"plugin": ["border-customs@0.5.0"]`
@@ -855,4 +882,4 @@ border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install
855
882
 
856
883
  发布一致性(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 失效。
857
884
 
858
- OpenCode 插件适配层(0.5.0):零手工装载两条路线——主路是在 `opencode.jsonc` 声明 `"plugin": ["border-customs@0.5.0"]`(建议钉具体版本,@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:工具与命令在启动时扫描。
885
+ OpenCode 插件适配层(0.5.0):零手工装载两条路线——主路是在 `opencode.jsonc` 声明 `"plugin": ["border-customs@0.5.0"]`(建议钉具体版本,@latest 每次冷启动都打注册表),opencode 自行下载构件,插件入口走 `exports["./server"]`;插件自带 CLI 解析,顺序为 `BORDER_BIN` 覆盖(独占,坏了就报错、绝不静默回退)> 包内同侧 `dist/index.js`(shebang 直接执行,不可执行则回退 PATH 上的 `node` 承载)> PATH 上的 `border`,免全局安装、免 PATH 配置;0.5.1 起任何候选都必须先过**身份握手**(以 `--help` 应答出 border 横幅与 `usage: border` 才算数),通过之前绝不投递用户 argv,握手不过即 cannot-answer exit 2、不运行任何其他二进制——0.5.0 曾用 `process.execPath` 承载包内 dist,而在插件宿主里 execPath 是 opencode 本体,边检门被静默冒充为宿主(2026-09-22 事故,见 0.5.1 变更日志)。备路是 `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:工具与命令在启动时扫描。
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "border-customs",
9
- "version": "0.5.0",
9
+ "version": "0.5.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": "^1.17.4",
package/dist/index.js CHANGED
@@ -44162,7 +44162,7 @@ import { createHash as createHash16 } from "node:crypto";
44162
44162
  import { basename as basename5 } from "node:path";
44163
44163
  var SCAN_MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
44164
44164
  var SCAN_REQUEST_TIMEOUT_MS = 6e4;
44165
- var SCAN_USER_AGENT = "border-customs/0.5.0 (+https://github.com/sumteclab/border; border scan)";
44165
+ var SCAN_USER_AGENT = "border-customs/0.5.1 (+https://github.com/sumteclab/border; border scan)";
44166
44166
  var CRATES_DOWNLOAD_HOST = "https://static.crates.io";
44167
44167
  var defaultScanFetcher = async (url2, init) => {
44168
44168
  if (!/^https?:\/\//.test(url2)) throw new EngineRunError(`border scan refuses non-http(s) URL: ${url2}`, null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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.5.0
1
+ // border-opencode-plugin v0.5.1
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
@@ -22,6 +22,17 @@
22
22
  // On this route the CLI is spawned FROM THE PACKAGE ITSELF (../dist/index.js
23
23
  // lives inside the same tarball), so no global install or PATH entry is
24
24
  // needed — that is what makes Route B genuinely install-free.
25
+ //
26
+ // NEVER spawn the host: this module runs inside the opencode process, whose
27
+ // process.execPath is the opencode (bun-compiled) binary, not node. The 0.5.0
28
+ // packaged-sibling route used process.execPath + dist/index.js as argv — on
29
+ // Route B that spawned opencode with the dist path as a subcommand: the gate
30
+ // was silently replaced by the host (impersonation incident, 2026-09-22; the
31
+ // node --test seam was blind because there execPath really is node). Since
32
+ // 0.5.1 the packaged dist is exec'd DIRECTLY (shebang) or under PATH `node`,
33
+ // and every candidate must pass an identity handshake (`--help` answering as
34
+ // border) before any user argv is ever spawned. Unverifiable ⇒ exit 2
35
+ // cannot-answer, never a fallback to some other binary.
25
36
 
26
37
  import { execFile } from "node:child_process";
27
38
  import { existsSync } from "node:fs";
@@ -30,6 +41,8 @@ import { tool, type Config } from "@opencode-ai/plugin";
30
41
 
31
42
  /** Spawn cap: a CLI call that outlives this is killed and reported, never awaited forever. */
32
43
  const TIMEOUT_MS = 300_000;
44
+ /** Identity-handshake cap: a `--help` needing longer than this is not a border CLI. */
45
+ const PROBE_TIMEOUT_MS = 20_000;
33
46
  /** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
34
47
  const MAX_OUTPUT_BYTES = 64 * 1024;
35
48
 
@@ -47,24 +60,36 @@ const ALLOWED_COMMANDS: readonly string[] = [
47
60
  "--help",
48
61
  ];
49
62
 
50
- /** How to reach the CLI: a binary name/absolute path, optionally under node itself. */
63
+ /** How to reach the CLI: a binary name/absolute path, optionally a script prefix. */
51
64
  interface BinResolution {
52
65
  readonly bin: string;
53
- /** argv to prepend before the CLI's own argv (the dist entry when hosted by node). */
66
+ /** argv to prepend before the CLI's own argv (the dist entry when hosted by PATH node). */
54
67
  readonly prefix: readonly string[];
68
+ /** Human-readable candidate identity, named in the cannot-answer note. */
69
+ readonly label: string;
55
70
  }
56
71
 
57
72
  /**
58
- * Resolution order: BORDER_BIN (non-empty) wins on every route; else the dist
59
- * bundle sitting next to this module in the package/repo layout (Route B and
60
- * dev checkout, spawned under node); else the `border` bin from PATH (Route A).
73
+ * Candidate order: BORDER_BIN (non-empty) wins alone on every route — a broken
74
+ * explicit override is a loud error, never a silent fallback; else the dist
75
+ * bundle sitting next to this module, exec'd directly through its shebang and,
76
+ * if that is not runnable, under PATH `node` (Route B + dev checkout); else the
77
+ * `border` bin from PATH (Route A). process.execPath is deliberately absent:
78
+ * inside a plugin host it is the HOST binary, not a JS runtime.
61
79
  */
62
- function resolveBin(): BinResolution {
80
+ function candidateList(): readonly BinResolution[] {
63
81
  const configured = process.env["BORDER_BIN"];
64
- if (configured !== undefined && configured.length > 0) return { bin: configured, prefix: [] };
82
+ if (configured !== undefined && configured.length > 0) {
83
+ return [{ bin: configured, prefix: [], label: `BORDER_BIN=${configured}` }];
84
+ }
65
85
  const distEntry = fileURLToPath(new URL("../dist/index.js", import.meta.url));
66
- if (existsSync(distEntry)) return { bin: process.execPath, prefix: [distEntry] };
67
- return { bin: "border", prefix: [] };
86
+ const candidates: BinResolution[] = [];
87
+ if (existsSync(distEntry)) {
88
+ candidates.push({ bin: distEntry, prefix: [], label: "packaged dist/index.js (shebang exec)" });
89
+ candidates.push({ bin: "node", prefix: [distEntry], label: "packaged dist/index.js under PATH node" });
90
+ }
91
+ candidates.push({ bin: "border", prefix: [], label: "border on PATH" });
92
+ return candidates;
68
93
  }
69
94
 
70
95
  interface CliResult {
@@ -72,6 +97,8 @@ interface CliResult {
72
97
  readonly stdout: string;
73
98
  readonly stderr: string;
74
99
  readonly note: string;
100
+ /** The child could not be spawned at all (ENOENT/EACCES/ENOEXEC) — never a gate verdict. */
101
+ readonly spawnFailed: boolean;
75
102
  }
76
103
 
77
104
  /** The subset of the execFile error shape this adapter reads (node sets `code` on failures). */
@@ -82,29 +109,27 @@ interface SpawnError extends Error {
82
109
  }
83
110
 
84
111
  /** Never rejects: every spawn failure becomes a structured result the agent can read. */
85
- function runCli(bin: string, prefix: readonly string[], argv: readonly string[]): Promise<CliResult> {
112
+ function runCli(resolution: BinResolution, argv: readonly string[], timeoutMs: number): Promise<CliResult> {
86
113
  return new Promise((resolve) => {
87
114
  execFile(
88
- bin,
89
- [...prefix, ...argv],
90
- { timeout: TIMEOUT_MS, maxBuffer: MAX_OUTPUT_BYTES, shell: false },
115
+ resolution.bin,
116
+ [...resolution.prefix, ...argv],
117
+ { timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES, shell: false },
91
118
  (error, stdout, stderr) => {
92
119
  const out = stdout.slice(0, MAX_OUTPUT_BYTES);
93
120
  const errOut = stderr.slice(0, MAX_OUTPUT_BYTES);
94
121
  if (error === null) {
95
- resolve({ status: 0, stdout: out, stderr: errOut, note: "" });
122
+ resolve({ status: 0, stdout: out, stderr: errOut, note: "", spawnFailed: false });
96
123
  return;
97
124
  }
98
125
  const failure = error as SpawnError;
99
- if (failure.code === "ENOENT") {
126
+ if (failure.code === "ENOENT" || failure.code === "EACCES" || failure.code === "ENOEXEC") {
100
127
  resolve({
101
- status: 127,
128
+ status: 2,
102
129
  stdout: out,
103
130
  stderr: errOut,
104
- note:
105
- "the border CLI could not be spawned. Three remedies: set BORDER_BIN to a " +
106
- "real border binary, or run 'border opencode install' (route A), or " +
107
- "npm i -g border-customs (route A, PATH entry).",
131
+ spawnFailed: true,
132
+ note: `cannot-answer: the border CLI could not be spawned (${String(failure.code)}).`,
108
133
  });
109
134
  return;
110
135
  }
@@ -114,6 +139,7 @@ function runCli(bin: string, prefix: readonly string[], argv: readonly string[])
114
139
  stdout: out,
115
140
  stderr: errOut,
116
141
  note: `output exceeded ${MAX_OUTPUT_BYTES} bytes per stream and was truncated.`,
142
+ spawnFailed: false,
117
143
  });
118
144
  return;
119
145
  }
@@ -123,9 +149,10 @@ function runCli(bin: string, prefix: readonly string[], argv: readonly string[])
123
149
  stdout: out,
124
150
  stderr: errOut,
125
151
  note:
126
- `killed after ${TIMEOUT_MS / 1000}s timeout (signal ${failure.signal ?? "SIGTERM"}). ` +
152
+ `killed after ${timeoutMs / 1000}s timeout (signal ${failure.signal ?? "SIGTERM"}). ` +
127
153
  "border check and roundtrip can be long — if you need a long roundtrip, " +
128
154
  "prefer running it in a terminal.",
155
+ spawnFailed: false,
129
156
  });
130
157
  return;
131
158
  }
@@ -135,12 +162,56 @@ function runCli(bin: string, prefix: readonly string[], argv: readonly string[])
135
162
  stderr: errOut,
136
163
  note:
137
164
  typeof failure.code === "number" ? "" : `spawn failed: ${failure.message}`,
165
+ spawnFailed: false,
138
166
  });
139
167
  },
140
168
  );
141
169
  });
142
170
  }
143
171
 
172
+ /**
173
+ * border's --help contract (src/cli.ts): exit 0, banner line starts with
174
+ * `border`, usage line names `usage: border`. An impostor answering the spawn
175
+ * slot (the 2026-09-22 incident: the opencode host banner) fails this test, so
176
+ * user argv is never handed to a binary that cannot prove it is the gate.
177
+ * Honest boundary: this catches mistaken identity, not a forged banner.
178
+ */
179
+ function isBorderHelp(result: CliResult): boolean {
180
+ if (result.status !== 0) return false;
181
+ const firstLine = (result.stdout.match(/^\s*(.*)/) ?? ["", ""])[1] ?? "";
182
+ return firstLine.startsWith("border") && result.stdout.includes("usage: border");
183
+ }
184
+
185
+ type Resolution =
186
+ | { readonly ok: true; readonly resolution: BinResolution }
187
+ | { readonly ok: false; readonly reasons: readonly string[] };
188
+
189
+ /** Positive handshakes are cached per BORDER_BIN key for the host process's
190
+ * life; failures are never cached, so a mid-session install recovers on the
191
+ * next call. */
192
+ let verified: { readonly key: string; readonly resolution: BinResolution } | null = null;
193
+
194
+ async function resolveVerified(): Promise<Resolution> {
195
+ const key = process.env["BORDER_BIN"] ?? "";
196
+ if (verified !== null && verified.key === key) {
197
+ return { ok: true, resolution: verified.resolution };
198
+ }
199
+ const reasons: string[] = [];
200
+ for (const candidate of candidateList()) {
201
+ const probe = await runCli(candidate, ["--help"], PROBE_TIMEOUT_MS);
202
+ if (isBorderHelp(probe)) {
203
+ verified = { key, resolution: candidate };
204
+ return { ok: true, resolution: candidate };
205
+ }
206
+ reasons.push(
207
+ probe.status === 0
208
+ ? `${candidate.label}: spawned but --help did not answer as border (impostor?)`
209
+ : `${candidate.label}: probe exit ${String(probe.status)}${probe.note === "" ? "" : ` — ${probe.note}`}`,
210
+ );
211
+ }
212
+ return { ok: false, reasons };
213
+ }
214
+
144
215
  /**
145
216
  * The /border slash-command template. ALSO published verbatim (after the marker
146
217
  * line) as plugin/border-command.md — test/opencode.test.ts pins the byte-mirror.
@@ -186,7 +257,10 @@ export default {
186
257
  "llm-request, llm-ingest, scan, roundtrip, --help) and every remaining argv " +
187
258
  "token in `extra`. The call is spawned argv-only (no shell) with a 300s " +
188
259
  "timeout; the result always ends with the CLI exit code: 0 pass, 1 " +
189
- "gate-blocked or partial push, 2 gate could not answer. Honest privilege " +
260
+ "gate-blocked or partial push, 2 gate could not answer. Before running your " +
261
+ "argv the tool handshakes its binary candidate with `--help` and refuses " +
262
+ "everything (exit 2, cannot-answer) unless the responder provably is the " +
263
+ "border CLI — it never falls back to another binary. Honest privilege " +
190
264
  "note: this tool runs the border CLI, and the CLI IS the gate that decides " +
191
265
  "what leaves this machine — it carries the same power as the binary, so an " +
192
266
  "agent may only *ask* the gate, never bypass it. `push --yes` is " +
@@ -222,8 +296,19 @@ export default {
222
296
  }
223
297
  const argv: readonly string[] = [command, ...extra];
224
298
  context.metadata({ title: `border ${argv.join(" ")}` });
225
- const resolution = resolveBin();
226
- const result = await runCli(resolution.bin, resolution.prefix, argv);
299
+ const resolution = await resolveVerified();
300
+ if (!resolution.ok) {
301
+ return [
302
+ `$ border ${argv.join(" ")}`,
303
+ "exit: 2",
304
+ `note: cannot-answer — no border CLI passed the identity handshake, so nothing was run. Tried: ${resolution.reasons.join("; ")}. ` +
305
+ "Remedies: point BORDER_BIN at a real border CLI, run 'border opencode install' (route A), or npm i -g border-customs (PATH entry).",
306
+ "--- stdout ---\n(empty)",
307
+ "--- stderr ---\n(empty)",
308
+ ].join("\n");
309
+ }
310
+ const result = await runCli(resolution.resolution, argv, TIMEOUT_MS);
311
+ if (result.spawnFailed) verified = null;
227
312
  const sections = [
228
313
  `$ border ${argv.join(" ")}`,
229
314
  `exit: ${String(result.status)}`,