@bivy/bivy 0.16.25-staging.39 → 0.16.25-staging.40

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.
@@ -10,6 +10,8 @@ import { PairingStore } from "./device-registry.js";
10
10
  import { NodeIdentity } from "./identity.js";
11
11
  import { seal } from "./e2e.js";
12
12
  import { readNodeConfig } from "./node-config.js";
13
+ import { encodeAutomationTemplate } from "./automation-template.js";
14
+ import { AutomationFilterError, runAutomationFilter, webhookFilterInput } from "./automation-filter.js";
13
15
  import { loadProjectPolicy, resolveProjectSafety } from "./project-policy.js";
14
16
  function value(args, flag) {
15
17
  const exact = args.indexOf(flag);
@@ -29,6 +31,7 @@ Commands:
29
31
  validate [path] Parse and validate without network access
30
32
  plan [path] [--json] Show triggers, routing, and effective safety
31
33
  test [path] --event <file> Simulate an event and explain the first match
34
+ test-filter [path] --id <key> --event <json> Execute a trusted filter locally
32
35
  apply [path] [--prune] Encrypt instructions and reconcile the control plane
33
36
 
34
37
  Run commands (normally invoked as 'bivy runs ...'):
@@ -53,7 +56,7 @@ ciphertext only.`);
53
56
  function configPath(args) {
54
57
  for (let i = 0; i < args.length; i += 1) {
55
58
  const arg = args[i];
56
- if (arg === "--event") {
59
+ if (arg === "--event" || arg === "--id") {
57
60
  i += 1;
58
61
  continue;
59
62
  }
@@ -194,7 +197,7 @@ function appliedInput(entry, configOrder, nodeId, nodeName, roomKey) {
194
197
  name: entry.name,
195
198
  enabled: entry.enabled,
196
199
  trigger: entry.trigger,
197
- templateCiphertext: `bivy-room-v1:${nodeId}:${seal(roomKey, entry.instructions)}`,
200
+ templateCiphertext: `bivy-room-v1:${nodeId}:${seal(roomKey, encodeAutomationTemplate(entry.instructions, {}, entry.filter))}`,
198
201
  schedule: entry.schedule,
199
202
  repo: entry.repo,
200
203
  repos: entry.repos,
@@ -405,6 +408,7 @@ async function main() {
405
408
  id: a.id, action: "reconcile", enabled: a.enabled, trigger: a.trigger,
406
409
  route: { node: a.routing.node ?? "applying node", agent: a.routing.agent ?? "node default", model: a.routing.model ?? "agent default", ephemeral: a.routing.ephemeral ?? false },
407
410
  safety: { approval: safety.approval, sandbox: safety.sandbox, requestedApproval: a.safety.approval, requestedSandbox: a.safety.sandbox, maxAttempts: a.safety.maxAttempts, checks: a.trigger === "schedule" || a.trigger === "webhook" || a.trigger === "github" || a.trigger === "linear" ? "project test/lint/typecheck when declared" : "project defaults" },
411
+ filter: a.filter,
408
412
  workspace: a.repo ?? (a.repos?.length ? a.repos.join(", ") : "from event"),
409
413
  };
410
414
  });
@@ -416,10 +420,27 @@ async function main() {
416
420
  console.log(` route ${item.route.node} · ${item.route.agent} · ${item.route.model}${item.route.ephemeral ? " · ephemeral" : ""}`);
417
421
  console.log(` safety ${item.safety.sandbox} · approvals ${item.safety.approval} · at most ${item.safety.maxAttempts} attempt(s)`);
418
422
  console.log(` checks ${item.safety.checks}`);
423
+ if (item.filter)
424
+ console.log(` filter ${JSON.stringify(item.filter.command)} in ${item.filter.cwd} (${item.filter.timeoutSeconds}s)`);
419
425
  }
420
426
  }
421
427
  return;
422
428
  }
429
+ if (command === "test-filter") {
430
+ const id = value(args, "--id");
431
+ const eventFile = value(args, "--event");
432
+ if (!id || !eventFile)
433
+ throw new Error("--id <automation-key> and --event <payload.json> are required");
434
+ const entry = config.automations.find(a => a.id === id);
435
+ if (!entry?.filter)
436
+ throw new Error(`No filter configured for ${id}`);
437
+ const payload = JSON.parse(fs.readFileSync(path.resolve(eventFile), "utf8"));
438
+ const result = await runAutomationFilter(entry.filter, webhookFilterInput(payload, "local-test"));
439
+ if (result.diagnostics)
440
+ process.stderr.write(result.diagnostics);
441
+ console.log(JSON.stringify({ decision: result.decision, ...(result.reason === undefined ? {} : { reason: result.reason }) }));
442
+ return;
443
+ }
423
444
  if (command === "test") {
424
445
  const eventFile = value(args, "--event");
425
446
  if (!eventFile)
@@ -437,7 +458,7 @@ async function main() {
437
458
  return;
438
459
  }
439
460
  const a = result.matched;
440
- console.log(`\nWould run ${a.name}`);
461
+ console.log(`\nWould run ${a.name}${a.filter ? " (subject to filter; use test-filter to execute it)" : ""}`);
441
462
  console.log(` node: ${a.routing.node ?? "applying node"}`);
442
463
  console.log(` agent: ${a.routing.agent ?? "node default"}`);
443
464
  const safety = effectiveSafety(a, file);
@@ -497,6 +518,8 @@ async function main() {
497
518
  throw new Error(`Unknown automation command: ${command}`);
498
519
  }
499
520
  main().catch((error) => {
521
+ if (error instanceof AutomationFilterError && error.diagnostics)
522
+ process.stderr.write(error.diagnostics);
500
523
  console.error(`Automation error: ${error instanceof Error ? error.message : String(error)}`);
501
524
  process.exit(1);
502
525
  });
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { parse as parseYaml } from "yaml";
10
10
  import { matchFirst } from "./automation/index.js";
11
+ import { parseAutomationFilter } from "./automation-template.js";
11
12
  export const AUTOMATION_CONFIG_VERSION = 1;
12
13
  export const DEFAULT_AUTOMATION_CONFIG_PATH = ".bivy/automations.yaml";
13
14
  const GITHUB_EVENTS = new Set(["issues", "issue_comment", "pull_request", "pull_request_review_comment", "workflow_run"]);
@@ -110,7 +111,7 @@ function parseEntry(value, index, errors, warnings) {
110
111
  errors.push(`${at} must be an object`);
111
112
  return undefined;
112
113
  }
113
- rejectUnknown(o, ["id", "name", "enabled", "instructions", "trigger", "schedule", "repo", "repos", "labels", "on", "routing", "safety"], at, errors);
114
+ rejectUnknown(o, ["id", "name", "enabled", "instructions", "trigger", "schedule", "repo", "repos", "labels", "on", "routing", "safety", "filter"], at, errors);
114
115
  const id = typeof o.id === "string" ? o.id.trim() : "";
115
116
  const name = typeof o.name === "string" ? o.name.trim() : "";
116
117
  const instructions = typeof o.instructions === "string" ? o.instructions.trim() : "";
@@ -144,6 +145,17 @@ function parseEntry(value, index, errors, warnings) {
144
145
  warnings.push(`${id || at}: GitHub trigger has no on rules; it will use the default bivy label contract`);
145
146
  if (trigger !== "github" && on)
146
147
  errors.push(`${at}.on is only valid for github triggers`);
148
+ let filter;
149
+ if (o.filter !== undefined) {
150
+ try {
151
+ filter = parseAutomationFilter(o.filter);
152
+ }
153
+ catch (error) {
154
+ errors.push(`${at}.${error.message}`);
155
+ }
156
+ if (trigger !== "webhook")
157
+ errors.push(`${at}.filter is only valid for webhook triggers`);
158
+ }
147
159
  const routingRaw = o.routing === undefined ? {} : obj(o.routing);
148
160
  if (!routingRaw)
149
161
  errors.push(`${at}.routing must be an object`);
@@ -184,7 +196,7 @@ function parseEntry(value, index, errors, warnings) {
184
196
  if (!id || !name || !instructions || !trigger)
185
197
  return undefined;
186
198
  return {
187
- id, name, enabled: o.enabled !== false, instructions, trigger, schedule, repo, repos, labels, on,
199
+ id, name, enabled: o.enabled !== false, instructions, trigger, schedule, repo, repos, labels, on, filter,
188
200
  routing: {
189
201
  node,
190
202
  agent: typeof routing.agent === "string" ? routing.agent.trim() || undefined : undefined,
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { AutomationFilterError, runAutomationFilter, webhookFilterInput } from "./automation-filter.js";
3
+ /** Returns false for an intentional skip; failures park the run, never start an agent. */
4
+ export async function passWebhookFilter(filter, item, report, signal) {
5
+ let result;
6
+ try {
7
+ let payload;
8
+ try {
9
+ payload = JSON.parse(item.eventContext ?? "");
10
+ }
11
+ catch {
12
+ throw new AutomationFilterError("Webhook filter requires structured event context; manual runs without a payload are not supported");
13
+ }
14
+ result = await runAutomationFilter(filter, webhookFilterInput(payload, item.id), signal);
15
+ }
16
+ catch (error) {
17
+ const failure = error instanceof AutomationFilterError ? error : new AutomationFilterError("Invalid webhook filter configuration");
18
+ // Raw stderr stays on the node, not in control-plane evidence.
19
+ if (failure.diagnostics)
20
+ console.warn(`[automation-filter:${item.id}] ${failure.diagnostics}`);
21
+ throw failure;
22
+ }
23
+ if (result.diagnostics)
24
+ console.warn(`[automation-filter:${item.id}] ${result.diagnostics}`);
25
+ await report({
26
+ checks: [{ name: "webhook-filter", status: result.decision === "accept" ? "passed" : "skipped" }],
27
+ events: [{ at: new Date().toISOString(), kind: "checkpoint",
28
+ summary: `Webhook filter ${result.decision === "accept" ? "accepted" : "skipped"} delivery${result.reason ? `: ${result.reason}` : "."}`,
29
+ }],
30
+ });
31
+ return result.decision === "accept";
32
+ }
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { spawn } from "node:child_process";
3
+ import { parseAutomationFilter } from "./automation-template.js";
4
+ export class AutomationFilterError extends Error {
5
+ diagnostics;
6
+ constructor(message, diagnostics = "") {
7
+ super(message);
8
+ this.diagnostics = diagnostics;
9
+ this.name = "AutomationFilterError";
10
+ }
11
+ }
12
+ /** The same input contract is used by local tests and live deliveries. No headers. */
13
+ export function webhookFilterInput(payload, deliveryId) {
14
+ if (!payload || typeof payload !== "object")
15
+ throw new AutomationFilterError("Filter payload must be a JSON object or array");
16
+ return { version: 1, event: { type: "webhook", payload: payload }, delivery: { id: deliveryId } };
17
+ }
18
+ const INPUT_LIMIT = 256 * 1024;
19
+ const OUTPUT_LIMIT = 16 * 1024;
20
+ /** Trusted operator code, NOT a sandbox. No shell or inherited credentials. */
21
+ export async function runAutomationFilter(config, input, signal) {
22
+ const filter = parseAutomationFilter(config);
23
+ if (signal?.aborted)
24
+ throw new AutomationFilterError("Filter cancelled");
25
+ const serialized = JSON.stringify(input);
26
+ if (Buffer.byteLength(serialized) > INPUT_LIMIT)
27
+ throw new AutomationFilterError("Filter input exceeds 256 KiB");
28
+ return new Promise((resolve, reject) => {
29
+ const child = spawn(filter.command[0], filter.command.slice(1), {
30
+ cwd: filter.cwd,
31
+ shell: false,
32
+ detached: process.platform !== "win32",
33
+ env: {
34
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
35
+ ...(process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
36
+ LANG: "C.UTF-8",
37
+ },
38
+ stdio: ["pipe", "pipe", "pipe"],
39
+ });
40
+ let stdout = Buffer.alloc(0);
41
+ let stderr = Buffer.alloc(0);
42
+ let settled = false;
43
+ const kill = () => {
44
+ try {
45
+ if (process.platform !== "win32" && child.pid)
46
+ process.kill(-child.pid, "SIGKILL");
47
+ else
48
+ child.kill("SIGKILL");
49
+ }
50
+ catch { /* already exited */ }
51
+ };
52
+ const cleanup = () => {
53
+ clearTimeout(timer);
54
+ signal?.removeEventListener("abort", abort);
55
+ // Also reap descendants that outlive a successful parent.
56
+ kill();
57
+ child.stdin.destroy();
58
+ child.stdout.destroy();
59
+ child.stderr.destroy();
60
+ };
61
+ const fail = (message) => {
62
+ if (settled)
63
+ return;
64
+ settled = true;
65
+ cleanup();
66
+ reject(new AutomationFilterError(message, stderr.toString("utf8")));
67
+ };
68
+ const abort = () => fail("Filter cancelled");
69
+ const timer = setTimeout(() => fail(`Filter timed out after ${filter.timeoutSeconds}s`), filter.timeoutSeconds * 1000);
70
+ signal?.addEventListener("abort", abort, { once: true });
71
+ if (signal?.aborted)
72
+ abort();
73
+ child.on("error", () => fail("Could not start filter executable or open its working directory"));
74
+ // Early stdin closure is legal for filters that don't need the payload.
75
+ child.stdin.on("error", () => { });
76
+ child.stdout.on("data", (chunk) => {
77
+ if (stdout.length + chunk.length > OUTPUT_LIMIT) {
78
+ fail("Filter stdout exceeds 16 KiB");
79
+ return;
80
+ }
81
+ stdout = Buffer.concat([stdout, chunk]);
82
+ });
83
+ child.stderr.on("data", (chunk) => {
84
+ if (stderr.length + chunk.length > OUTPUT_LIMIT) {
85
+ fail("Filter stderr exceeds 16 KiB");
86
+ return;
87
+ }
88
+ stderr = Buffer.concat([stderr, chunk]);
89
+ });
90
+ child.on("close", (code) => {
91
+ if (settled)
92
+ return;
93
+ if (code !== 0) {
94
+ fail(`Filter exited unsuccessfully (${code ?? "signal"})`);
95
+ return;
96
+ }
97
+ let data;
98
+ try {
99
+ data = JSON.parse(stdout.toString("utf8"));
100
+ }
101
+ catch {
102
+ fail("Filter stdout must be one JSON decision object");
103
+ return;
104
+ }
105
+ if (!data || typeof data !== "object" || Array.isArray(data) ||
106
+ Object.keys(data).some(key => !["decision", "reason"].includes(key)) ||
107
+ (data.decision !== "accept" && data.decision !== "skip") ||
108
+ (data.reason !== undefined && (typeof data.reason !== "string" || data.reason.length > 500))) {
109
+ fail("Filter output requires decision accept/skip and optional reason (at most 500 characters)");
110
+ return;
111
+ }
112
+ settled = true;
113
+ cleanup();
114
+ resolve({ decision: data.decision, reason: data.reason, diagnostics: stderr.toString("utf8") });
115
+ });
116
+ child.stdin.end(serialized);
117
+ });
118
+ }
@@ -1,10 +1,23 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Wire format mirrored in src/automation-template.ts (node has no core dependency).
3
3
  const PREFIX = "bivy-automation-v1\n";
4
+ export function parseAutomationFilter(value) {
5
+ const o = value;
6
+ if (!o || typeof o !== "object" || Array.isArray(o) ||
7
+ Object.keys(o).some(key => !["command", "cwd", "timeoutSeconds"].includes(key)) ||
8
+ !Array.isArray(o.command) || !o.command.length || o.command.length > 64 ||
9
+ o.command.some(arg => typeof arg !== "string" || arg.length > 4096 || arg.includes("\u0000")) ||
10
+ !o.command[0]?.trim() || typeof o.cwd !== "string" || o.cwd.length > 4096 ||
11
+ o.cwd.includes("\u0000") || !/^(\/|[A-Za-z]:[\\/])/.test(o.cwd) ||
12
+ (o.timeoutSeconds !== undefined && (!Number.isInteger(o.timeoutSeconds) || o.timeoutSeconds < 1 || o.timeoutSeconds > 60))) {
13
+ throw new Error("filter requires command (non-empty argv list), cwd (absolute trusted directory), and optional timeoutSeconds (1-60)");
14
+ }
15
+ return { command: [...o.command], cwd: o.cwd, timeoutSeconds: o.timeoutSeconds ?? 5 };
16
+ }
4
17
  /** Account labels, never credentials, travel inside the encrypted template. */
5
- export function encodeAutomationTemplate(instructions, credentialLabels) {
6
- return Object.keys(credentialLabels).length || instructions.startsWith(PREFIX)
7
- ? PREFIX + JSON.stringify({ instructions, credentialLabels })
18
+ export function encodeAutomationTemplate(instructions, credentialLabels, filter) {
19
+ return filter || Object.keys(credentialLabels).length || instructions.startsWith(PREFIX)
20
+ ? PREFIX + JSON.stringify({ instructions, credentialLabels, ...(filter ? { filter: parseAutomationFilter(filter) } : {}) })
8
21
  : instructions;
9
22
  }
10
23
  /** Legacy plaintext templates remain valid. Malformed structured templates fail closed. */
@@ -17,5 +30,6 @@ export function decodeAutomationTemplate(value) {
17
30
  Object.entries(data.credentialLabels).some(([provider, label]) => !provider.trim() || provider !== provider.trim().toLowerCase() || typeof label !== "string" || !label.trim())) {
18
31
  throw new Error("Invalid automation account selections");
19
32
  }
20
- return { instructions: data.instructions, credentialLabels: data.credentialLabels };
33
+ return { instructions: data.instructions, credentialLabels: data.credentialLabels,
34
+ ...(data.filter === undefined ? {} : { filter: parseAutomationFilter(data.filter) }) };
21
35
  }
@@ -19,6 +19,7 @@
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { WorkResultOutbox } from "./work-result-outbox.js";
21
21
  import { RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
22
+ import { AutomationFilterError } from "./automation-filter.js";
22
23
  /** True when every required tag is among this node's declared capabilities.
23
24
  * A node that fails this must never attempt to claim the item — the hard
24
25
  * block happens locally, before any claim race, so an ineligible node never
@@ -487,6 +488,19 @@ export class ControlPlaneTaskPoller {
487
488
  }
488
489
  if (run.state !== "active")
489
490
  return;
491
+ // Filter errors are operator/configuration failures, never provider retries.
492
+ if (error instanceof AutomationFilterError) {
493
+ const now = new Date().toISOString();
494
+ await report({
495
+ checks: [{ name: "webhook-filter", status: "failed" }],
496
+ events: [{ at: now, kind: "needs_attention", summary: `Webhook filter failed: ${error.message}`, attempt }],
497
+ attention: { severity: "warning", reason: `Webhook filter failed: ${error.message}`, since: now },
498
+ });
499
+ if (run.state !== "active")
500
+ return;
501
+ await this.finishWork(item.id, 'needs-attention', run);
502
+ return;
503
+ }
490
504
  // Deployment admission is not an agent failure. Never reroute/retry it
491
505
  // through a provider policy or mark the work successfully completed.
492
506
  if (error instanceof RemoteSessionAdmissionError) {
package/dist/server.js CHANGED
@@ -41,6 +41,7 @@ import { ControlPlaneSessionLocationRegistry, LayeredSessionLocationRegistry } f
41
41
  import { attachAdoptedSessions, classifyAttachFailure } from "./runtime/adoption.js";
42
42
  import { createCredentialStore, testProviderCredential } from "./runtime/credentials.js";
43
43
  import { decodeAutomationTemplate } from "./automation-template.js";
44
+ import { passWebhookFilter } from "./automation-filter-gate.js";
44
45
  import { isModelAuthError, authProviderForSession, classifyModelAuthError } from "./runtime/auth-errors.js";
45
46
  import { createCredentialVault, migrateVaultDir } from "./runtime/credential-store.js";
46
47
  import { probeAnthropicAccess } from "./runtime/anthropic-preflight.js";
@@ -5013,6 +5014,7 @@ async function executeWorkItem(item, report, signal) {
5013
5014
  // on issue/Slack/Linear bodies, so decrypt whenever it's present regardless of
5014
5015
  // source.
5015
5016
  let credentialLabels;
5017
+ let filter;
5016
5018
  if (item.body?.startsWith("bivy-room-v1:")) {
5017
5019
  const [, nodeId, ...payload] = item.body.split(":");
5018
5020
  if (nodeId !== identity.nodeId || payload.length === 0) {
@@ -5021,12 +5023,17 @@ async function executeWorkItem(item, report, signal) {
5021
5023
  try {
5022
5024
  const template = decodeAutomationTemplate(open(pairingStore.roomKey(), payload.join(":")));
5023
5025
  credentialLabels = template.credentialLabels;
5026
+ filter = template.filter;
5024
5027
  item = { ...item, body: template.instructions };
5025
5028
  }
5026
5029
  catch {
5027
5030
  throw new Error("could not decrypt automation instructions on this node");
5028
5031
  }
5029
5032
  }
5033
+ // Gate before any repository fetch, session continuation, or agent creation.
5034
+ // A skip finishes this selected automation; it does not fall through to another.
5035
+ if (filter && !await passWebhookFilter(filter, item, report, signal))
5036
+ return;
5030
5037
  // A webhook trigger's event payload is untrusted. Append it AFTER the operator's
5031
5038
  // own (decrypted) instructions, clearly framed as data, so the agent treats it
5032
5039
  // as context rather than as commands to follow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.16.25-staging.39",
3
+ "version": "0.16.25-staging.40",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",