@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.
- package/dist/automation-cli.js +26 -3
- package/dist/automation-config.js +14 -2
- package/dist/automation-filter-gate.js +32 -0
- package/dist/automation-filter.js +118 -0
- package/dist/automation-template.js +18 -4
- package/dist/control-plane-tasks.js +14 -0
- package/dist/server.js +7 -0
- package/package.json +1 -1
package/dist/automation-cli.js
CHANGED
|
@@ -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