border-customs 0.6.0 → 0.8.0

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/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // provenance: .omo/plans/border-opencode-v2-plugin.md T1 — V2 project-directory plugin
2
+ // discovery resolves <dir>/index directly (bypassing the package exports map, live-proven
3
+ // on cli beta-19271); this re-export lets the unpacked package directory itself serve as a
4
+ // V2 local plugin. Name-based imports continue to enter through exports["./server"].
5
+ import plugin from "./plugin/border.ts";
6
+
7
+ export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
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",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "dist",
17
- "plugin"
17
+ "plugin",
18
+ "index.ts"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=22.0.0"
@@ -5,7 +5,7 @@ User request: $ARGUMENTS
5
5
 
6
6
  Interpret the request as ONE `border` CLI invocation: the first word is the
7
7
  command (`check`, `push`, `status`, `llm-request`, `llm-ingest`, `scan`,
8
- `roundtrip`, or `--help`) and the rest are argv tokens. Call the `border` tool
8
+ `roundtrip`, `exfil`, or `--help`) and the rest are argv tokens. Call the `border` tool
9
9
  with `command` set to the first word and `extra` set to the remaining tokens,
10
10
  then report the CLI exit code (0 pass / 1 gate-blocked or partial push / 2 gate
11
11
  could not answer) and the relevant lines of its output. If no request was given,
package/plugin/border.ts CHANGED
@@ -1,4 +1,4 @@
1
- // border-opencode-plugin v0.6.0
1
+ // border-opencode-plugin v0.8.0
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
@@ -6,9 +6,17 @@
6
6
  // the human gate: nothing here can push for real (a bare `border push` is a
7
7
  // DRY-RUN by the CLI's own contract, and `--yes` is refused outright).
8
8
  //
9
- // V1 plugin format (opencode >= 1.14): default export { id, server }, where
9
+ // Dual-shape default export — one module, two host generations:
10
+ // V1 plugin format (opencode >= 1.18.29, object entry): { id, server }, where
10
11
  // server(input, options) resolves to Hooks; Hooks.tool is a
11
- // { [name]: ToolDefinition } record (see @opencode-ai/plugin).
12
+ // { [name]: ToolDefinition } record (see @opencode-ai/plugin). The V1 host
13
+ // ignores the extra `setup` key (this is the official dual-shape form).
14
+ // V2 plugin format (@opencode-ai/cli beta line): the loader accepts only
15
+ // { id, setup } (or { id, effect }) and silently drops anything else, so the
16
+ // SAME tool registers through setup(ctx) -> ctx.tool.transform ->
17
+ // editor.add(Info). Both edges share the pre-spawn gate (gateArgv) and the
18
+ // execute substance (buildToolResult) below, so behavior stays identical;
19
+ // test/opencode.v2shape.test.ts pins V1↔V2 rendering and refusal parity.
12
20
  // This file ships as-is (package.json "files") and reaches a session by two
13
21
  // routes, neither compiling it through border's tsc:
14
22
  // (A) copied into <CONFIG>/plugins/ by `border opencode install`, which also
@@ -57,6 +65,7 @@ const ALLOWED_COMMANDS: readonly string[] = [
57
65
  "llm-ingest",
58
66
  "scan",
59
67
  "roundtrip",
68
+ "exfil",
60
69
  "--help",
61
70
  ];
62
71
 
@@ -222,7 +231,7 @@ User request: $ARGUMENTS
222
231
 
223
232
  Interpret the request as ONE \`border\` CLI invocation: the first word is the
224
233
  command (\`check\`, \`push\`, \`status\`, \`llm-request\`, \`llm-ingest\`, \`scan\`,
225
- \`roundtrip\`, or \`--help\`) and the rest are argv tokens. Call the \`border\` tool
234
+ \`roundtrip\`, \`exfil\`, or \`--help\`) and the rest are argv tokens. Call the \`border\` tool
226
235
  with \`command\` set to the first word and \`extra\` set to the remaining tokens,
227
236
  then report the CLI exit code (0 pass / 1 gate-blocked or partial push / 2 gate
228
237
  could not answer) and the relevant lines of its output. If no request was given,
@@ -234,8 +243,160 @@ contract: it reports the verdict the gate would produce without touching any
234
243
  remote.
235
244
  `;
236
245
 
246
+ // --- V2 loader surface: local structural types ---------------------------------
247
+ // shape proven from @opencode-ai/plugin beta-19271 .d.ts; live-gated by
248
+ // test/opencode.v2.probe (plan T3). Deliberately NO V2 SDK import here (wave
249
+ // plan D1/R3 pending): only the two members the setup() seam touches are
250
+ // typed — ctx.tool.transform and editor.add — everything else stays opaque.
251
+
252
+ /** Parsed tool input, mirroring the V1 args schema (command required, extra optional). */
253
+ interface V2ToolArgs {
254
+ readonly command: string;
255
+ readonly extra?: readonly string[];
256
+ }
257
+
258
+ /** The V2 promise-entry result envelope: the rendered text in `content`. */
259
+ interface V2ToolResult {
260
+ readonly content: string;
261
+ }
262
+
263
+ /** One tool registration handed to ToolEditor.add by the transform callback. */
264
+ export interface V2ToolInfo {
265
+ readonly name: string;
266
+ readonly description: string;
267
+ /** Pure JSON Schema — ValueSchema accepts it without a schema-library import. */
268
+ readonly input: Record<string, unknown>;
269
+ readonly execute: (args: V2ToolArgs, context?: unknown) => Promise<V2ToolResult>;
270
+ }
271
+
272
+ export interface V2ToolEditor {
273
+ add(info: V2ToolInfo): void;
274
+ }
275
+
276
+ interface V2ToolRegistry {
277
+ transform(callback: (editor: V2ToolEditor) => void): Promise<unknown>;
278
+ }
279
+
280
+ export interface V2PluginContext {
281
+ readonly tool: V2ToolRegistry;
282
+ }
283
+
284
+ /**
285
+ * The ONE tool description, byte-shared by both edges: the V1 tool() schema
286
+ * and the V2 Info both render exactly this text (single source of truth).
287
+ */
288
+ const TOOL_DESCRIPTION =
289
+ "Run the border fail-closed push-gate CLI on this machine. " +
290
+ "Pass the top-level command word in `command` (one of: check, push, status, " +
291
+ "llm-request, llm-ingest, scan, roundtrip, exfil, --help) and every remaining argv " +
292
+ "token in `extra`. The call is spawned argv-only (no shell) with a 300s " +
293
+ "timeout; the result always ends with the CLI exit code: 0 pass, 1 " +
294
+ "gate-blocked or partial push, 2 gate could not answer. Before running your " +
295
+ "argv the tool handshakes its binary candidate with `--help` and refuses " +
296
+ "everything (exit 2, cannot-answer) unless the responder provably is the " +
297
+ "border CLI — it never falls back to another binary. Honest privilege " +
298
+ "note: this tool runs the border CLI, and the CLI IS the gate that decides " +
299
+ "what leaves this machine — it carries the same power as the binary, so an " +
300
+ "agent may only *ask* the gate, never bypass it. `push --yes` is " +
301
+ "deliberately NOT reachable here: real pushes are the human gate and belong " +
302
+ "to a terminal. A bare `border push` through this tool is a DRY-RUN by the " +
303
+ "CLI's own contract.";
304
+
305
+ /**
306
+ * The pre-spawn gate — the deliberate seam split, load-bearing for security:
307
+ * gateArgv(argv) decides WHETHER the argv may run at all (allowlist
308
+ * membership, `--yes` refusal) and returns the refusal text or null;
309
+ * buildToolResult(argv) does the actual work (handshake, spawn, render)
310
+ * and NEVER re-gates — it trusts its caller.
311
+ * Every edge (today: V1 tool.execute and V2 Info.execute) MUST call gateArgv
312
+ * BEFORE buildToolResult. A future edge that skips the gate silently gets no
313
+ * gating; test/opencode.v2shape.test.ts pins refusal parity on both edges.
314
+ */
315
+ function gateArgv(argv: readonly string[]): string | null {
316
+ const command = argv[0] ?? "";
317
+ if (!ALLOWED_COMMANDS.includes(command)) {
318
+ return (
319
+ `border: refused '${command}' — not an allowed command. ` +
320
+ `Allowed: ${ALLOWED_COMMANDS.join(", ")}. Put the command word in 'command' ` +
321
+ `and every other token in 'extra'.`
322
+ );
323
+ }
324
+ if (argv.includes("--yes")) {
325
+ return (
326
+ `border: refused '${command}' with '--yes' — a real push is the human gate ` +
327
+ `and happens in a terminal. Run \`border push --yes\` there if a visible ` +
328
+ `human go-ahead exists. A bare \`border push\` through this tool is a ` +
329
+ `DRY-RUN by the CLI's own contract.`
330
+ );
331
+ }
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * The tool-execute substance shared by both edges (wave plan D2): resolve the
337
+ * CLI via resolveVerified (identity handshake included), spawn it argv-only
338
+ * via runCli, render the sectioned result. No gates and no V1-only
339
+ * context.metadata here — those stay at each edge, see gateArgv above.
340
+ */
341
+ async function buildToolResult(argv: readonly string[]): Promise<string> {
342
+ const resolution = await resolveVerified();
343
+ if (!resolution.ok) {
344
+ return [
345
+ `$ border ${argv.join(" ")}`,
346
+ "exit: 2",
347
+ `note: cannot-answer — no border CLI passed the identity handshake, so nothing was run. Tried: ${resolution.reasons.join("; ")}. ` +
348
+ "Remedies: point BORDER_BIN at a real border CLI, run 'border opencode install' (route A), or npm i -g border-customs (PATH entry).",
349
+ "--- stdout ---\n(empty)",
350
+ "--- stderr ---\n(empty)",
351
+ ].join("\n");
352
+ }
353
+ const result = await runCli(resolution.resolution, argv, TIMEOUT_MS);
354
+ if (result.spawnFailed) verified = null;
355
+ const sections = [
356
+ `$ border ${argv.join(" ")}`,
357
+ `exit: ${String(result.status)}`,
358
+ ];
359
+ if (result.note.length > 0) sections.push(`note: ${result.note}`);
360
+ sections.push(`--- stdout ---\n${result.stdout.length === 0 ? "(empty)" : result.stdout}`);
361
+ sections.push(`--- stderr ---\n${result.stderr.length === 0 ? "(empty)" : result.stderr}`);
362
+ return sections.join("\n");
363
+ }
364
+
365
+ /** The V2-side tool registration: same gate + same substance as the V1 tool. */
366
+ function makeV2ToolInfo(): V2ToolInfo {
367
+ return {
368
+ name: "border",
369
+ description: TOOL_DESCRIPTION,
370
+ input: {
371
+ type: "object",
372
+ properties: {
373
+ command: { type: "string" },
374
+ extra: { type: "array", items: { type: "string" } },
375
+ },
376
+ required: ["command"],
377
+ additionalProperties: false,
378
+ },
379
+ execute: async (args: V2ToolArgs): Promise<V2ToolResult> => {
380
+ const argv: readonly string[] = [args.command.trim(), ...(args.extra ?? [])];
381
+ const refusal = gateArgv(argv);
382
+ if (refusal !== null) return { content: refusal };
383
+ return { content: await buildToolResult(argv) };
384
+ },
385
+ };
386
+ }
387
+
237
388
  export default {
389
+ // Shared plugin id: the V1 host reads it for the {id, server} object entry
390
+ // and the V2 loader reads this same id for its {id, setup} schema check.
238
391
  id: "border",
392
+ setup: async (ctx: V2PluginContext): Promise<void> => {
393
+ // transform's return is awaited (Promise<Registration>), but the callback
394
+ // body itself must stay SYNCHRONOUS — only editor.add inside, any async
395
+ // work belongs in Info.execute (contract E3; pinned by v2shape test (d)).
396
+ await ctx.tool.transform((editor: V2ToolEditor): void => {
397
+ editor.add(makeV2ToolInfo());
398
+ });
399
+ },
239
400
  server: async () => ({
240
401
  // Self-registering /border: opencode calls hook.config(cfg) once per
241
402
  // instance after ALL config sources are merged — file-based commands are
@@ -251,22 +412,7 @@ export default {
251
412
  },
252
413
  tool: {
253
414
  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.",
415
+ description: TOOL_DESCRIPTION,
270
416
  args: {
271
417
  command: tool.schema
272
418
  .string()
@@ -276,47 +422,13 @@ export default {
276
422
  .optional()
277
423
  .describe("remaining argv tokens, e.g. ['--force']"),
278
424
  },
425
+ // V1 edge: gate → V1-only metadata → shared substance (see gateArgv).
279
426
  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];
427
+ const argv: readonly string[] = [args.command.trim(), ...(args.extra ?? [])];
428
+ const refusal = gateArgv(argv);
429
+ if (refusal !== null) return refusal;
298
430
  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");
431
+ return buildToolResult(argv);
320
432
  },
321
433
  }),
322
434
  },