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/README.md +73 -4
- package/dist/assets/package-lock.json +2 -2
- package/dist/index.js +594 -82
- package/index.ts +7 -0
- package/package.json +3 -2
- package/plugin/border-command.md +1 -1
- package/plugin/border.ts +171 -59
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.
|
|
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"
|
package/plugin/border-command.md
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
},
|