border-customs 0.4.2 → 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.
@@ -0,0 +1,17 @@
1
+ <!-- border-opencode-command -->
2
+ Drive the border fail-closed push gate through the `border` tool.
3
+
4
+ User request: $ARGUMENTS
5
+
6
+ Interpret the request as ONE `border` CLI invocation: the first word is the
7
+ command (`check`, `push`, `status`, `llm-request`, `llm-ingest`, `scan`,
8
+ `roundtrip`, or `--help`) and the rest are argv tokens. Call the `border` tool
9
+ with `command` set to the first word and `extra` set to the remaining tokens,
10
+ then report the CLI exit code (0 pass / 1 gate-blocked or partial push / 2 gate
11
+ could not answer) and the relevant lines of its output. If no request was given,
12
+ call the tool with `command: "--help"` and summarize the command list from it.
13
+ `push --yes` is refused by the tool — a real push is the human gate and belongs
14
+ to a terminal: if the user asks for one, tell them to run `border push --yes`
15
+ there. A bare `border push` through the tool is a DRY-RUN by the CLI's own
16
+ contract: it reports the verdict the gate would produce without touching any
17
+ remote.
@@ -0,0 +1,324 @@
1
+ // border-opencode-plugin v0.5.1
2
+ // Official opencode plugin adapter for the border fail-closed push gate.
3
+ // Registers ONE agent tool, `border`, that drives the border CLI — argv-only
4
+ // (node:child_process execFile, never a shell), top-level commands restricted
5
+ // to an allowlist, hard 300s timeout, capped output. The CLI itself remains
6
+ // the human gate: nothing here can push for real (a bare `border push` is a
7
+ // DRY-RUN by the CLI's own contract, and `--yes` is refused outright).
8
+ //
9
+ // V1 plugin format (opencode >= 1.14): default export { id, server }, where
10
+ // server(input, options) resolves to Hooks; Hooks.tool is a
11
+ // { [name]: ToolDefinition } record (see @opencode-ai/plugin).
12
+ // This file ships as-is (package.json "files") and reaches a session by two
13
+ // routes, neither compiling it through border's tsc:
14
+ // (A) copied into <CONFIG>/plugins/ by `border opencode install`, which also
15
+ // drops commands/border.md; "@opencode-ai/plugin" then resolves in
16
+ // opencode's config-directory node_modules.
17
+ // (B) served as the package's "./server" export when the npm package name is
18
+ // listed in opencode.jsonc "plugin" — opencode's arborist install places
19
+ // @opencode-ai/plugin (its runtime dependency) next to the package in the
20
+ // cache, so the same import resolves there too. With no commands/border.md
21
+ // on disk, the config hook below self-registers the slash command instead.
22
+ // On this route the CLI is spawned FROM THE PACKAGE ITSELF (../dist/index.js
23
+ // lives inside the same tarball), so no global install or PATH entry is
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.
36
+
37
+ import { execFile } from "node:child_process";
38
+ import { existsSync } from "node:fs";
39
+ import { fileURLToPath } from "node:url";
40
+ import { tool, type Config } from "@opencode-ai/plugin";
41
+
42
+ /** Spawn cap: a CLI call that outlives this is killed and reported, never awaited forever. */
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;
46
+ /** Per-stream output cap fed back into the session; keeps one tool call from flooding context. */
47
+ const MAX_OUTPUT_BYTES = 64 * 1024;
48
+
49
+ /** The eight tool-reachable top-level commands plus --help. Anything else is refused locally.
50
+ * A real push is the human gate and is deliberately not reachable here: any argv
51
+ * containing `--yes` is refused, and bare `border push` is DRY-RUN by CLI contract. */
52
+ const ALLOWED_COMMANDS: readonly string[] = [
53
+ "check",
54
+ "push",
55
+ "status",
56
+ "llm-request",
57
+ "llm-ingest",
58
+ "scan",
59
+ "roundtrip",
60
+ "--help",
61
+ ];
62
+
63
+ /** How to reach the CLI: a binary name/absolute path, optionally a script prefix. */
64
+ interface BinResolution {
65
+ readonly bin: string;
66
+ /** argv to prepend before the CLI's own argv (the dist entry when hosted by PATH node). */
67
+ readonly prefix: readonly string[];
68
+ /** Human-readable candidate identity, named in the cannot-answer note. */
69
+ readonly label: string;
70
+ }
71
+
72
+ /**
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.
79
+ */
80
+ function candidateList(): readonly BinResolution[] {
81
+ const configured = process.env["BORDER_BIN"];
82
+ if (configured !== undefined && configured.length > 0) {
83
+ return [{ bin: configured, prefix: [], label: `BORDER_BIN=${configured}` }];
84
+ }
85
+ const distEntry = fileURLToPath(new URL("../dist/index.js", import.meta.url));
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;
93
+ }
94
+
95
+ interface CliResult {
96
+ readonly status: number;
97
+ readonly stdout: string;
98
+ readonly stderr: string;
99
+ readonly note: string;
100
+ /** The child could not be spawned at all (ENOENT/EACCES/ENOEXEC) — never a gate verdict. */
101
+ readonly spawnFailed: boolean;
102
+ }
103
+
104
+ /** The subset of the execFile error shape this adapter reads (node sets `code` on failures). */
105
+ interface SpawnError extends Error {
106
+ readonly code?: string | number | null;
107
+ readonly killed?: boolean;
108
+ readonly signal?: string | null;
109
+ }
110
+
111
+ /** Never rejects: every spawn failure becomes a structured result the agent can read. */
112
+ function runCli(resolution: BinResolution, argv: readonly string[], timeoutMs: number): Promise<CliResult> {
113
+ return new Promise((resolve) => {
114
+ execFile(
115
+ resolution.bin,
116
+ [...resolution.prefix, ...argv],
117
+ { timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES, shell: false },
118
+ (error, stdout, stderr) => {
119
+ const out = stdout.slice(0, MAX_OUTPUT_BYTES);
120
+ const errOut = stderr.slice(0, MAX_OUTPUT_BYTES);
121
+ if (error === null) {
122
+ resolve({ status: 0, stdout: out, stderr: errOut, note: "", spawnFailed: false });
123
+ return;
124
+ }
125
+ const failure = error as SpawnError;
126
+ if (failure.code === "ENOENT" || failure.code === "EACCES" || failure.code === "ENOEXEC") {
127
+ resolve({
128
+ status: 2,
129
+ stdout: out,
130
+ stderr: errOut,
131
+ spawnFailed: true,
132
+ note: `cannot-answer: the border CLI could not be spawned (${String(failure.code)}).`,
133
+ });
134
+ return;
135
+ }
136
+ if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
137
+ resolve({
138
+ status: -1,
139
+ stdout: out,
140
+ stderr: errOut,
141
+ note: `output exceeded ${MAX_OUTPUT_BYTES} bytes per stream and was truncated.`,
142
+ spawnFailed: false,
143
+ });
144
+ return;
145
+ }
146
+ if (failure.killed === true || (failure.signal !== undefined && failure.signal !== null)) {
147
+ resolve({
148
+ status: 124,
149
+ stdout: out,
150
+ stderr: errOut,
151
+ note:
152
+ `killed after ${timeoutMs / 1000}s timeout (signal ${failure.signal ?? "SIGTERM"}). ` +
153
+ "border check and roundtrip can be long — if you need a long roundtrip, " +
154
+ "prefer running it in a terminal.",
155
+ spawnFailed: false,
156
+ });
157
+ return;
158
+ }
159
+ resolve({
160
+ status: typeof failure.code === "number" ? failure.code : -1,
161
+ stdout: out,
162
+ stderr: errOut,
163
+ note:
164
+ typeof failure.code === "number" ? "" : `spawn failed: ${failure.message}`,
165
+ spawnFailed: false,
166
+ });
167
+ },
168
+ );
169
+ });
170
+ }
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
+
215
+ /**
216
+ * The /border slash-command template. ALSO published verbatim (after the marker
217
+ * line) as plugin/border-command.md — test/opencode.test.ts pins the byte-mirror.
218
+ */
219
+ export const COMMAND_TEMPLATE = `Drive the border fail-closed push gate through the \`border\` tool.
220
+
221
+ User request: $ARGUMENTS
222
+
223
+ Interpret the request as ONE \`border\` CLI invocation: the first word is the
224
+ command (\`check\`, \`push\`, \`status\`, \`llm-request\`, \`llm-ingest\`, \`scan\`,
225
+ \`roundtrip\`, or \`--help\`) and the rest are argv tokens. Call the \`border\` tool
226
+ with \`command\` set to the first word and \`extra\` set to the remaining tokens,
227
+ then report the CLI exit code (0 pass / 1 gate-blocked or partial push / 2 gate
228
+ could not answer) and the relevant lines of its output. If no request was given,
229
+ call the tool with \`command: "--help"\` and summarize the command list from it.
230
+ \`push --yes\` is refused by the tool — a real push is the human gate and belongs
231
+ to a terminal: if the user asks for one, tell them to run \`border push --yes\`
232
+ there. A bare \`border push\` through the tool is a DRY-RUN by the CLI's own
233
+ contract: it reports the verdict the gate would produce without touching any
234
+ remote.
235
+ `;
236
+
237
+ export default {
238
+ id: "border",
239
+ server: async () => ({
240
+ // Self-registering /border: opencode calls hook.config(cfg) once per
241
+ // instance after ALL config sources are merged — file-based commands are
242
+ // already in cfg.command by then. `??=` keeps a Route-A commands/border.md
243
+ // authoritative and only fills the gap for Route B; the command map is
244
+ // keyed by name, so a same-name file + injection never duplicates.
245
+ config: async (cfg: Config) => {
246
+ cfg.command ??= {};
247
+ cfg.command.border ??= {
248
+ description: "Drive the border push gate (usage: /border <command> [args...])",
249
+ template: COMMAND_TEMPLATE,
250
+ };
251
+ },
252
+ tool: {
253
+ border: tool({
254
+ description:
255
+ "Run the border fail-closed push-gate CLI on this machine. " +
256
+ "Pass the top-level command word in `command` (one of: check, push, status, " +
257
+ "llm-request, llm-ingest, scan, roundtrip, --help) and every remaining argv " +
258
+ "token in `extra`. The call is spawned argv-only (no shell) with a 300s " +
259
+ "timeout; the result always ends with the CLI exit code: 0 pass, 1 " +
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 " +
264
+ "note: this tool runs the border CLI, and the CLI IS the gate that decides " +
265
+ "what leaves this machine — it carries the same power as the binary, so an " +
266
+ "agent may only *ask* the gate, never bypass it. `push --yes` is " +
267
+ "deliberately NOT reachable here: real pushes are the human gate and belong " +
268
+ "to a terminal. A bare `border push` through this tool is a DRY-RUN by the " +
269
+ "CLI's own contract.",
270
+ args: {
271
+ command: tool.schema
272
+ .string()
273
+ .describe("border command word, e.g. 'check' or 'push' or '--help'"),
274
+ extra: tool.schema
275
+ .array(tool.schema.string())
276
+ .optional()
277
+ .describe("remaining argv tokens, e.g. ['--force']"),
278
+ },
279
+ execute: async (args, context) => {
280
+ const command = args.command.trim();
281
+ if (!ALLOWED_COMMANDS.includes(command)) {
282
+ return (
283
+ `border: refused '${command}' — not an allowed command. ` +
284
+ `Allowed: ${ALLOWED_COMMANDS.join(", ")}. Put the command word in 'command' ` +
285
+ `and every other token in 'extra'.`
286
+ );
287
+ }
288
+ const extra = args.extra ?? [];
289
+ if (extra.includes("--yes")) {
290
+ return (
291
+ `border: refused '${command}' with '--yes' — a real push is the human gate ` +
292
+ `and happens in a terminal. Run \`border push --yes\` there if a visible ` +
293
+ `human go-ahead exists. A bare \`border push\` through this tool is a ` +
294
+ `DRY-RUN by the CLI's own contract.`
295
+ );
296
+ }
297
+ const argv: readonly string[] = [command, ...extra];
298
+ context.metadata({ title: `border ${argv.join(" ")}` });
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;
312
+ const sections = [
313
+ `$ border ${argv.join(" ")}`,
314
+ `exit: ${String(result.status)}`,
315
+ ];
316
+ if (result.note.length > 0) sections.push(`note: ${result.note}`);
317
+ sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);
318
+ sections.push(`--- stderr ---\n${result.stderr.length === 0 ? "(empty)" : result.stderr}`);
319
+ return sections.join("\n");
320
+ },
321
+ }),
322
+ },
323
+ }),
324
+ };