@bivy/bivy 0.6.0 → 0.7.0-staging.101
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 +15 -6
- package/bin/acp-shim.mjs +128 -14
- package/bin/agent-manifest.json +39 -0
- package/bin/bivy.mjs +99 -13
- package/bin/patch-pi-dependencies.mjs +22 -16
- package/dist/automation-checks.js +68 -0
- package/dist/bivy-login.js +13 -0
- package/dist/control-plane-tasks.js +39 -8
- package/dist/diagnostics.js +75 -0
- package/dist/github-tasks.js +27 -7
- package/dist/guard.js +51 -8
- package/dist/harness/egress.js +64 -1
- package/dist/harness/mcp-config.js +89 -6
- package/dist/harness/mcp-inject.js +31 -8
- package/dist/harness/net-proxy.js +28 -0
- package/dist/metadata.js +18 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +52 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/anthropic-preflight.js +41 -0
- package/dist/runtime/codex-sessions.js +10 -1
- package/dist/runtime/credential-store.js +35 -6
- package/dist/runtime/index.js +177 -20
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +64 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +789 -108
- package/dist/session/attachment-store.js +99 -11
- package/dist/session/event-log.js +75 -11
- package/dist/session/fork-dirty.js +41 -3
- package/dist/session/revert-file.js +44 -0
- package/dist/session/turn-watchdog.js +19 -0
- package/package.json +6 -3
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
3
3
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
4
4
|
/**
|
|
5
|
-
* pi-coding-agent 0.82.1 publishes an npm-shrinkwrap that pins
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* installation. Replace
|
|
9
|
-
*
|
|
5
|
+
* pi-coding-agent 0.82.1 publishes an npm-shrinkwrap that pins vulnerable
|
|
6
|
+
* transitive packages below its own node_modules. npm overrides update the outer
|
|
7
|
+
* lockfile/audit result but do not replace those shrinkwrapped files during
|
|
8
|
+
* installation. Replace the affected nested packages with direct, exact patched
|
|
9
|
+
* dependencies until pi publishes a corrected shrinkwrap.
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
@@ -27,18 +27,24 @@ function findDependency(relativePath) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
const source = findDependency("brace-expansion");
|
|
31
30
|
const piPackage = findDependency(path.join("@earendil-works", "pi-coding-agent"));
|
|
32
|
-
|
|
31
|
+
if (!piPackage) process.exit(0);
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const patches = [
|
|
34
|
+
{ name: "brace-expansion", version: "5.0.9" },
|
|
35
|
+
{ name: "undici", version: "8.9.0" },
|
|
36
|
+
];
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
for (const patch of patches) {
|
|
39
|
+
const source = findDependency(patch.name);
|
|
40
|
+
const target = path.join(piPackage, "node_modules", patch.name);
|
|
41
|
+
if (!fs.existsSync(target)) continue;
|
|
42
|
+
if (!source) throw new Error(`Security patch source ${patch.name}@${patch.version} is missing`);
|
|
43
|
+
const sourcePackage = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
|
|
44
|
+
if (sourcePackage.version !== patch.version) {
|
|
45
|
+
throw new Error(`Refusing dependency patch from unexpected ${patch.name} ${sourcePackage.version}`);
|
|
46
|
+
}
|
|
47
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
48
|
+
fs.cpSync(source, target, { recursive: true });
|
|
49
|
+
console.log(`Patched pi-coding-agent's nested ${patch.name} to ${patch.version}`);
|
|
40
50
|
}
|
|
41
|
-
|
|
42
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
43
|
-
fs.cpSync(source, target, { recursive: true });
|
|
44
|
-
console.log("Patched pi-coding-agent's nested brace-expansion to 5.0.9");
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
const DEFAULT_SCRIPT_NAMES = ["test", "lint", "typecheck"];
|
|
8
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
9
|
+
const MAX_CHECK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
10
|
+
function configuredScriptNames(env) {
|
|
11
|
+
const raw = env.BIVY_AUTOMATION_CHECKS?.trim();
|
|
12
|
+
if (!raw)
|
|
13
|
+
return DEFAULT_SCRIPT_NAMES;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
if (Array.isArray(parsed))
|
|
17
|
+
return [...new Set(parsed.map(String).map((s) => s.trim()).filter((s) => /^[\w:.-]+$/.test(s)))].slice(0, 10);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Fall through to a comma list; malformed entries are discarded rather than
|
|
21
|
+
// interpreted as shell. Only package-script names are accepted.
|
|
22
|
+
}
|
|
23
|
+
return [...new Set(raw.split(",").map((s) => s.trim()).filter((s) => /^[\w:.-]+$/.test(s)))].slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
function checkTimeoutMs(env) {
|
|
26
|
+
const parsed = Number(env.BIVY_AUTOMATION_CHECK_TIMEOUT_MS);
|
|
27
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
28
|
+
return DEFAULT_CHECK_TIMEOUT_MS;
|
|
29
|
+
return Math.min(MAX_CHECK_TIMEOUT_MS, Math.max(1_000, Math.floor(parsed)));
|
|
30
|
+
}
|
|
31
|
+
function packageManager(cwd) {
|
|
32
|
+
if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml")))
|
|
33
|
+
return { command: "pnpm", args: (script) => ["run", script] };
|
|
34
|
+
if (fs.existsSync(path.join(cwd, "yarn.lock")))
|
|
35
|
+
return { command: "yarn", args: (script) => ["run", script] };
|
|
36
|
+
return { command: "npm", args: (script) => ["run", script] };
|
|
37
|
+
}
|
|
38
|
+
/** Discover and run only declared package scripts. Command text and output stay
|
|
39
|
+
* on the node; hosted evidence receives a name, hash, pass/fail, and exit code. */
|
|
40
|
+
export function runRequiredAutomationChecks(cwd, env = process.env, run = spawnSync) {
|
|
41
|
+
let pkg;
|
|
42
|
+
try {
|
|
43
|
+
pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const manager = packageManager(cwd);
|
|
49
|
+
const timeout = checkTimeoutMs(env);
|
|
50
|
+
const results = [];
|
|
51
|
+
for (const name of configuredScriptNames(env)) {
|
|
52
|
+
if (typeof pkg.scripts?.[name] !== "string")
|
|
53
|
+
continue;
|
|
54
|
+
const args = manager.args(name);
|
|
55
|
+
const commandHash = `sha256:${createHash("sha256").update(JSON.stringify([manager.command, ...args])).digest("hex")}`;
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
const result = run(manager.command, args, {
|
|
58
|
+
cwd,
|
|
59
|
+
env,
|
|
60
|
+
stdio: "ignore",
|
|
61
|
+
timeout,
|
|
62
|
+
killSignal: "SIGTERM",
|
|
63
|
+
});
|
|
64
|
+
const exitCode = typeof result.status === "number" ? result.status : 1;
|
|
65
|
+
results.push({ name, commandHash, status: exitCode === 0 ? "passed" : "failed", exitCode, durationMs: Math.max(0, Date.now() - startedAt) });
|
|
66
|
+
}
|
|
67
|
+
return results;
|
|
68
|
+
}
|
package/dist/bivy-login.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
5
5
|
import { stdin as input, stdout as output } from "node:process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { createCredentialVault } from "./runtime/credential-store.js";
|
|
8
|
+
import { probeAnthropicAccess } from "./runtime/anthropic-preflight.js";
|
|
8
9
|
import { listPiProviders } from "./runtime/pi-oauth.js";
|
|
9
10
|
import { loginModelOAuth } from "./runtime/oauth/model-oauth.js";
|
|
10
11
|
import { openBrowser } from "./browser-open.js";
|
|
@@ -95,6 +96,18 @@ async function loginApiKey(provider) {
|
|
|
95
96
|
throw new Error("API key cannot be empty.");
|
|
96
97
|
await createCredentialVault(credsDir).setApiKey(provider.id, apiKey);
|
|
97
98
|
console.log(`Saved API key for ${provider.name} to Bivy's credential vault.`);
|
|
99
|
+
// B1: validate real access, not just that a key was typed, where a safe probe
|
|
100
|
+
// exists. A rejected key is reported now instead of surfacing later as an
|
|
101
|
+
// opaque 401 on the user's first task.
|
|
102
|
+
if (provider.id === "anthropic") {
|
|
103
|
+
const probe = await probeAnthropicAccess(apiKey);
|
|
104
|
+
if (probe.probed && !probe.ok) {
|
|
105
|
+
console.log(`⚠ ${probe.reason || "The key was saved but Anthropic rejected it."} Double-check the key; re-run 'bivy login' to replace it.`);
|
|
106
|
+
}
|
|
107
|
+
else if (probe.probed) {
|
|
108
|
+
console.log("✓ Verified: the key can reach the Anthropic API.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
98
111
|
}
|
|
99
112
|
/** Bridge Pi's AuthInteraction to the terminal (prompt/notify). */
|
|
100
113
|
function terminalInteraction(provider, signal) {
|
|
@@ -53,7 +53,19 @@ async function cp(cfg, method, path) {
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
async function transitionWork(cfg, id, action) {
|
|
56
|
-
|
|
56
|
+
// Best-effort — a dropped transition never loses the run itself — but NOT
|
|
57
|
+
// silent: a swallowed `complete`/`fail`/`needs-attention` leaves the control
|
|
58
|
+
// plane's view of the item stale (stuck "running", or re-dispatched), so the
|
|
59
|
+
// failure must be visible in node logs/diagnostics rather than discarded (A4).
|
|
60
|
+
try {
|
|
61
|
+
const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/${action}`);
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
console.warn(`[control-plane-tasks] work ${id} "${action}" rejected by control plane (${res.status}); its status may be stale`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.warn(`[control-plane-tasks] work ${id} "${action}" could not reach control plane:`, error instanceof Error ? error.message : error);
|
|
68
|
+
}
|
|
57
69
|
}
|
|
58
70
|
export async function fetchPendingWork(cfg) {
|
|
59
71
|
const res = await cp(cfg, "GET", `/node/work?labels=${encodeURIComponent(cfg.labels.join(","))}`);
|
|
@@ -67,6 +79,10 @@ export async function claimWork(cfg, id) {
|
|
|
67
79
|
const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`);
|
|
68
80
|
return res.ok;
|
|
69
81
|
}
|
|
82
|
+
export async function renewWorkLease(cfg, id) {
|
|
83
|
+
const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/heartbeat`);
|
|
84
|
+
return res.ok;
|
|
85
|
+
}
|
|
70
86
|
export async function completeWork(cfg, id) {
|
|
71
87
|
await transitionWork(cfg, id, "complete");
|
|
72
88
|
}
|
|
@@ -80,13 +96,22 @@ export async function needsAttentionWork(cfg, id) {
|
|
|
80
96
|
/** Report privacy-safe run evidence (issue #153) — routing reason, output refs
|
|
81
97
|
* (branch/PR/checkpoint/commit/...), check results, and new timeline events.
|
|
82
98
|
* Best-effort: a dropped report loses one evidence update, never the run
|
|
83
|
-
* itself
|
|
99
|
+
* itself. It is not throwing, but the failure is logged (A4) so a persistently
|
|
100
|
+
* failing evidence channel is visible in diagnostics instead of silent. */
|
|
84
101
|
export async function reportEvidence(cfg, id, patch) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(`${cfg.controlPlaneUrl}/node/work/${encodeURIComponent(id)}/evidence`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json" },
|
|
106
|
+
body: JSON.stringify(patch),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
console.warn(`[control-plane-tasks] work ${id} evidence report rejected (${res.status})`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
console.warn(`[control-plane-tasks] work ${id} evidence report could not reach control plane:`, error instanceof Error ? error.message : error);
|
|
114
|
+
}
|
|
90
115
|
}
|
|
91
116
|
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
92
117
|
export class ControlPlaneTaskPoller {
|
|
@@ -174,11 +199,15 @@ export class ControlPlaneTaskPoller {
|
|
|
174
199
|
await Promise.all(running);
|
|
175
200
|
}
|
|
176
201
|
async runOne(item) {
|
|
202
|
+
let leaseHeartbeat;
|
|
177
203
|
try {
|
|
178
204
|
// Claim first so only one node runs it; skip if another node won (no
|
|
179
|
-
// claim → not ours → don't run or complete it).
|
|
205
|
+
// claim → not ours → don't run or complete it). A heartbeat keeps the
|
|
206
|
+
// finite lease alive; process death stops it and makes the item reclaimable.
|
|
180
207
|
if (!(await claimWork(this.cfg, item.id)))
|
|
181
208
|
return;
|
|
209
|
+
leaseHeartbeat = setInterval(() => void renewWorkLease(this.cfg, item.id), 30_000);
|
|
210
|
+
leaseHeartbeat.unref?.();
|
|
182
211
|
const report = (patch) => reportEvidence(this.cfg, item.id, patch);
|
|
183
212
|
await transitionWork(this.cfg, item.id, "running");
|
|
184
213
|
console.log(`[control-plane-tasks] running ${item.source} item ${item.id}: ${item.title}`);
|
|
@@ -190,6 +219,8 @@ export class ControlPlaneTaskPoller {
|
|
|
190
219
|
await this.runWithPolicy(item, report);
|
|
191
220
|
}
|
|
192
221
|
finally {
|
|
222
|
+
if (leaseHeartbeat)
|
|
223
|
+
clearInterval(leaseHeartbeat);
|
|
193
224
|
this.inFlight.delete(item.id);
|
|
194
225
|
}
|
|
195
226
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Redacted diagnostics export + privacy-safe activation instrumentation (B4d).
|
|
5
|
+
//
|
|
6
|
+
// A support bundle a user can share without leaking secrets, prompts, transcripts,
|
|
7
|
+
// diffs, or repo contents: versions, health counters, a whitelisted set of config
|
|
8
|
+
// flags (values still passed through redactSecrets defensively), and the activation
|
|
9
|
+
// stage record. Everything user-authored stays on the node.
|
|
10
|
+
import { redactSecrets } from "./redact.js";
|
|
11
|
+
/** Ordered activation stages — the golden path from install to first useful task. */
|
|
12
|
+
export const ACTIVATION_STAGES = ["install", "node_online", "runtime", "credential", "repo", "first_task"];
|
|
13
|
+
/**
|
|
14
|
+
* Map the setup/doctor readiness booleans to a privacy-safe activation record —
|
|
15
|
+
* where the golden path is blocked, and where it flowed. No content, only the
|
|
16
|
+
* stage and its status. `null` for an unknown stage means "pending" (not reached).
|
|
17
|
+
*/
|
|
18
|
+
export function activationRecord(readiness) {
|
|
19
|
+
const status = (ok, skipWhenNull = false) => {
|
|
20
|
+
if (ok === null || ok === undefined)
|
|
21
|
+
return skipWhenNull ? "skipped" : "pending";
|
|
22
|
+
return ok ? "ok" : "blocked";
|
|
23
|
+
};
|
|
24
|
+
return [
|
|
25
|
+
{ stage: "install", status: "ok" }, // reaching this code means the CLI installed
|
|
26
|
+
{ stage: "node_online", status: status(readiness.nodeOnline) },
|
|
27
|
+
{ stage: "runtime", status: status(readiness.runtimeReady) },
|
|
28
|
+
{ stage: "credential", status: status(readiness.credentialReady, true) },
|
|
29
|
+
{ stage: "repo", status: status(readiness.repoChosen) },
|
|
30
|
+
{ stage: "first_task", status: status(readiness.firstTaskReady) },
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
/** Env vars safe to include in a diagnostics bundle: non-secret config knobs only.
|
|
34
|
+
* Anything not matching is dropped entirely (not just redacted). */
|
|
35
|
+
const SAFE_ENV_KEYS = new Set([
|
|
36
|
+
"BIVY_APPROVAL_MODE", "BIVY_SANDBOX", "BIVY_RUNTIME", "BIVY_MULTI_USER_HOST",
|
|
37
|
+
"BIVY_REQUIRE_LOCAL_AUTH", "BIVY_TURN_TIMEOUT_MS", "BIVY_AUTOMATION_CHECKS",
|
|
38
|
+
"PORT", "BIVY_PUBLIC_URL",
|
|
39
|
+
]);
|
|
40
|
+
/** Deep-redact any string leaf in a JSON-ish value. */
|
|
41
|
+
function redactDeep(value) {
|
|
42
|
+
if (typeof value === "string")
|
|
43
|
+
return redactSecrets(value);
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return value.map(redactDeep);
|
|
46
|
+
if (value && typeof value === "object") {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [k, v] of Object.entries(value))
|
|
49
|
+
out[k] = redactDeep(v);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Assemble a shareable diagnostics report. Only whitelisted config keys are
|
|
56
|
+
* included; every string leaf is passed through redactSecrets as a backstop so a
|
|
57
|
+
* value that slips in (a URL with an embedded token, say) is still masked.
|
|
58
|
+
*/
|
|
59
|
+
export function buildDiagnosticsReport(input) {
|
|
60
|
+
const config = {};
|
|
61
|
+
for (const [k, v] of Object.entries(input.env ?? {})) {
|
|
62
|
+
if (SAFE_ENV_KEYS.has(k) && typeof v === "string" && v.length)
|
|
63
|
+
config[k] = redactSecrets(v);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
version: input.version ?? "unknown",
|
|
67
|
+
platform: input.platform ?? "unknown",
|
|
68
|
+
nodeVersion: input.nodeVersion ?? "unknown",
|
|
69
|
+
relayConfigured: Boolean(input.relayConfigured),
|
|
70
|
+
health: redactDeep(input.health ?? {}),
|
|
71
|
+
config,
|
|
72
|
+
activation: input.activation ?? [],
|
|
73
|
+
generatedAt: input.generatedAt ?? "",
|
|
74
|
+
};
|
|
75
|
+
}
|
package/dist/github-tasks.js
CHANGED
|
@@ -308,11 +308,15 @@ export function pickupMessage(nodeName) {
|
|
|
308
308
|
* the control plane, not a GitHub label — touches the issue's labels at all.
|
|
309
309
|
*/
|
|
310
310
|
export async function announcePickup(cfg, issueNumber, nodeName) {
|
|
311
|
-
|
|
311
|
+
// Best-effort and idempotent, but not silent (A4): a failed claim label can let
|
|
312
|
+
// another node pick up the same issue, and a failed comment hides the pickup
|
|
313
|
+
// from the reporter — both are worth a warning in the node log/diagnostics.
|
|
314
|
+
const warn = (what, error) => console.warn(`[github-tasks] issue #${issueNumber}: could not ${what}:`, error instanceof Error ? error.message : error);
|
|
315
|
+
await addLabel(cfg, issueNumber, cfg.claimLabel).catch((error) => warn(`apply claim label "${cfg.claimLabel}"`, error));
|
|
312
316
|
if (cfg.label && cfg.label !== cfg.claimLabel) {
|
|
313
|
-
await removeLabel(cfg, issueNumber, cfg.label).catch(() => {
|
|
317
|
+
await removeLabel(cfg, issueNumber, cfg.label).catch((error) => warn(`remove routing label "${cfg.label}"`, error));
|
|
314
318
|
}
|
|
315
|
-
await commentIssue(cfg, issueNumber, pickupMessage(nodeName)).catch(() =>
|
|
319
|
+
await commentIssue(cfg, issueNumber, pickupMessage(nodeName)).catch((error) => warn("post pickup comment", error));
|
|
316
320
|
}
|
|
317
321
|
export async function defaultBranch(cfg) {
|
|
318
322
|
const res = await gh(cfg, "GET", "");
|
|
@@ -322,11 +326,27 @@ export async function defaultBranch(cfg) {
|
|
|
322
326
|
return data.default_branch || "main";
|
|
323
327
|
}
|
|
324
328
|
export async function openPullRequest(cfg, input) {
|
|
329
|
+
// Idempotent across retry/reclaim (C4a). Creating a PR is an external effect
|
|
330
|
+
// that must not duplicate when the same item is retried on this node or
|
|
331
|
+
// reclaimed by another. A PR for this head may already exist — from a prior
|
|
332
|
+
// attempt or the agent's own `gh pr create` — so reuse it instead of POSTing
|
|
333
|
+
// blindly (GitHub 422s "a pull request already exists", which the old code
|
|
334
|
+
// reported as failure, silently losing the PR reference).
|
|
335
|
+
const branch = input.head.includes(":") ? input.head.slice(input.head.indexOf(":") + 1) : input.head;
|
|
336
|
+
const existing = await findOpenPullRequestForBranch(cfg, branch);
|
|
337
|
+
if (existing)
|
|
338
|
+
return existing;
|
|
325
339
|
const res = await gh(cfg, "POST", "/pulls", input);
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
340
|
+
if (res.ok) {
|
|
341
|
+
const data = (await res.json().catch(() => ({})));
|
|
342
|
+
return data.html_url ? { url: data.html_url, number: Number(data.number) } : undefined;
|
|
343
|
+
}
|
|
344
|
+
// 422 = a PR for this head/base already exists (opened concurrently, e.g. by a
|
|
345
|
+
// node that reclaimed the item mid-flight). Recover its reference rather than
|
|
346
|
+
// reporting failure and re-attempting.
|
|
347
|
+
if (res.status === 422)
|
|
348
|
+
return findOpenPullRequestForBranch(cfg, branch);
|
|
349
|
+
return undefined;
|
|
330
350
|
}
|
|
331
351
|
/**
|
|
332
352
|
* Update an existing pull request's title and/or body. Used to refresh the
|
package/dist/guard.js
CHANGED
|
@@ -4,8 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
export function bashCommand(input) {
|
|
5
5
|
if (!input || typeof input !== "object")
|
|
6
6
|
return "";
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
const value = input;
|
|
8
|
+
// Structured agents use different names for their shell surface. Keep this
|
|
9
|
+
// intentionally small and factual; unknown tools are not magically governed.
|
|
10
|
+
for (const key of ["command", "cmd", "script"]) {
|
|
11
|
+
if (typeof value[key] === "string")
|
|
12
|
+
return value[key];
|
|
13
|
+
}
|
|
14
|
+
return "";
|
|
9
15
|
}
|
|
10
16
|
/** Heuristic for the legacy "risky" mode (prompt-heavy). */
|
|
11
17
|
export function looksRiskyBash(command) {
|
|
@@ -15,15 +21,51 @@ export function looksRiskyBash(command) {
|
|
|
15
21
|
return (/(^|[;&|()\s])(rm|rmdir|mv|cp|chmod|chown|dd|mkfs|sudo|su|kill|pkill|curl|wget|git\s+(commit|push|reset|clean|checkout|switch|merge|rebase)|npm\s+(install|update|publish)|pnpm\s+(install|update|publish)|yarn\s+(add|install|upgrade|publish))([;&|()\s]|$)/.test(normalized) ||
|
|
16
22
|
/(^|\s)(>|>>|2>|&>|tee\s+)/.test(normalized));
|
|
17
23
|
}
|
|
24
|
+
/** Parse dangerous recursive rm targets without a nested-quantifier regex. The
|
|
25
|
+
* input is user/agent controlled, so this intentionally stays linear-time. */
|
|
26
|
+
function catastrophicRm(command) {
|
|
27
|
+
const systemRoots = ["/etc", "/usr", "/home", "/var", "/opt", "/boot", "/root", "/bin", "/sbin", "/lib", "/lib64"];
|
|
28
|
+
for (const segment of command.split(/[;\n|&]+/)) {
|
|
29
|
+
const tokens = segment.trim().split(/\s+/).map((token) => token.replace(/^["']|["']$/g, ""));
|
|
30
|
+
const rmIndex = tokens.lastIndexOf("rm");
|
|
31
|
+
if (rmIndex < 0)
|
|
32
|
+
continue;
|
|
33
|
+
let recursive = false;
|
|
34
|
+
let force = false;
|
|
35
|
+
const targets = [];
|
|
36
|
+
for (const token of tokens.slice(rmIndex + 1)) {
|
|
37
|
+
if (token === "--recursive")
|
|
38
|
+
recursive = true;
|
|
39
|
+
else if (token === "--force")
|
|
40
|
+
force = true;
|
|
41
|
+
else if (token.startsWith("-") && !token.startsWith("--")) {
|
|
42
|
+
recursive ||= token.includes("r") || token.includes("R");
|
|
43
|
+
force ||= token.includes("f");
|
|
44
|
+
}
|
|
45
|
+
else if (!token.startsWith("--"))
|
|
46
|
+
targets.push(token);
|
|
47
|
+
}
|
|
48
|
+
if (!recursive || !force)
|
|
49
|
+
continue;
|
|
50
|
+
for (const rawTarget of targets) {
|
|
51
|
+
const target = rawTarget.length > 1 ? rawTarget.replace(/\/+$/, "") : rawTarget;
|
|
52
|
+
if (target === "/" || target === "~" || target === "$home" || target === "/*")
|
|
53
|
+
return true;
|
|
54
|
+
if (systemRoots.some((root) => target === root || target.startsWith(`${root}/`)))
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
18
60
|
/**
|
|
19
61
|
* Catastrophic, irreversible, system-wide actions. Blocked OUTRIGHT in every mode
|
|
20
62
|
* — the boundary that makes unattended autonomy safe rather than reckless.
|
|
21
63
|
*/
|
|
22
64
|
export function looksCatastrophic(command) {
|
|
23
|
-
const c = command.trim().toLowerCase();
|
|
65
|
+
const c = command.trim().toLowerCase().slice(0, 100_000);
|
|
24
66
|
if (!c)
|
|
25
67
|
return false;
|
|
26
|
-
return (
|
|
68
|
+
return (catastrophicRm(c) || // rm -rf / | ~ | system roots
|
|
27
69
|
/\bmkfs(\.\w+)?\b/.test(c) ||
|
|
28
70
|
/\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk)/.test(c) ||
|
|
29
71
|
/>\s*\/dev\/(sd|nvme|hd|disk)/.test(c) ||
|
|
@@ -76,11 +118,12 @@ export function guardToolCall(workspace, toolName, input, mode, isRiskyIntegrati
|
|
|
76
118
|
// case-sensitive compare silently disabled it for claude-code. Integration
|
|
77
119
|
// tool names (MCP, etc.) keep their original casing via `isRiskyIntegration`.
|
|
78
120
|
const tool = toolName.toLowerCase();
|
|
121
|
+
const isShell = tool === "bash" || tool === "shell" || tool === "execute" || tool === "run_command";
|
|
79
122
|
// Tools that write to the filesystem and so must respect the workspace
|
|
80
123
|
// boundary. MultiEdit/NotebookEdit also take a `file_path` (see toolPath).
|
|
81
124
|
const isWrite = tool === "write" || tool === "edit" || tool === "multiedit" || tool === "notebookedit";
|
|
82
125
|
// --- Hard floor: blocked in every mode ---
|
|
83
|
-
if (
|
|
126
|
+
if (isShell && looksCatastrophic(bashCommand(input))) {
|
|
84
127
|
return { decision: "deny", reason: "Blocked: catastrophic command (outside the safety boundary)" };
|
|
85
128
|
}
|
|
86
129
|
if (isWrite) {
|
|
@@ -95,15 +138,15 @@ export function guardToolCall(workspace, toolName, input, mode, isRiskyIntegrati
|
|
|
95
138
|
if (isRiskyIntegration(toolName))
|
|
96
139
|
return { decision: "ask" };
|
|
97
140
|
if (mode === "autonomous") {
|
|
98
|
-
if (
|
|
141
|
+
if (isShell && looksBackstop(bashCommand(input)))
|
|
99
142
|
return { decision: "ask" };
|
|
100
143
|
return { decision: "allow" };
|
|
101
144
|
}
|
|
102
145
|
if (mode === "always") {
|
|
103
|
-
return { decision:
|
|
146
|
+
return { decision: isShell || isWrite ? "ask" : "allow" };
|
|
104
147
|
}
|
|
105
148
|
// "risky"
|
|
106
|
-
if (
|
|
149
|
+
if (isShell)
|
|
107
150
|
return { decision: looksRiskyBash(bashCommand(input)) ? "ask" : "allow" };
|
|
108
151
|
return { decision: isWrite ? "ask" : "allow" };
|
|
109
152
|
}
|
package/dist/harness/egress.js
CHANGED
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
// subprocess — without process.ts importing the server. Opt-in via the
|
|
8
8
|
// BIVY_EGRESS_PROXY env var, so routing all agent traffic through the broker is
|
|
9
9
|
// an explicit choice (it adds a hop and logs destinations).
|
|
10
|
-
import { EgressProxy } from "./net-proxy.js";
|
|
10
|
+
import { EgressProxy, denyAllDecider } from "./net-proxy.js";
|
|
11
11
|
let proxy;
|
|
12
|
+
// Per-session egress proxies, keyed by session id. This is the plan's
|
|
13
|
+
// "per-workflow proxy/decider, never the singleton": a session that needs its own
|
|
14
|
+
// network policy (e.g. a read-only sandbox that must actually block egress, or a
|
|
15
|
+
// workflow with an allowlist) gets its OWN EgressProxy with its OWN decider,
|
|
16
|
+
// injected into just that session's subprocess — the node-global `proxy` above and
|
|
17
|
+
// every other session are untouched. Empty by default, so nothing here changes the
|
|
18
|
+
// default path.
|
|
19
|
+
const sessionProxies = new Map();
|
|
12
20
|
/** Start the egress proxy if BIVY_EGRESS_PROXY is set. Idempotent. */
|
|
13
21
|
export async function startEgressProxyIfEnabled(onEvent) {
|
|
14
22
|
if (proxy)
|
|
@@ -28,3 +36,58 @@ export async function stopEgressProxy() {
|
|
|
28
36
|
await proxy.stop();
|
|
29
37
|
proxy = undefined;
|
|
30
38
|
}
|
|
39
|
+
// --- Per-session egress (the per-workflow proxy/decider) --------------------
|
|
40
|
+
/**
|
|
41
|
+
* Start a per-session egress proxy governed by `decide`, keyed to `sessionId`.
|
|
42
|
+
* Its `env()` is what `sessionEgressEnv(sessionId)` returns, so the runtime
|
|
43
|
+
* injects it into that session's subprocess *instead of* the node-global proxy.
|
|
44
|
+
* Idempotent per session. Best-effort — a listen failure leaves the session on the
|
|
45
|
+
* default path rather than blocking it.
|
|
46
|
+
*/
|
|
47
|
+
export async function startSessionEgress(sessionId, decide, onEvent) {
|
|
48
|
+
if (sessionProxies.has(sessionId))
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
const p = await EgressProxy.start({ decide, onEvent });
|
|
52
|
+
// A concurrent start for the same id won the race — keep the first, stop this.
|
|
53
|
+
if (sessionProxies.has(sessionId)) {
|
|
54
|
+
await p.stop();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
sessionProxies.set(sessionId, p);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Leave the session on the default egress path (global proxy or none).
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The per-session proxy env to inject for `sessionId`, or undefined when it has
|
|
64
|
+
* none (the caller then falls back to the node-global `egressEnv()`). */
|
|
65
|
+
export function sessionEgressEnv(sessionId) {
|
|
66
|
+
return sessionProxies.get(sessionId)?.env();
|
|
67
|
+
}
|
|
68
|
+
/** Tear down a session's own egress proxy (call on session close). Idempotent. */
|
|
69
|
+
export async function stopSessionEgress(sessionId) {
|
|
70
|
+
const p = sessionProxies.get(sessionId);
|
|
71
|
+
if (!p)
|
|
72
|
+
return;
|
|
73
|
+
sessionProxies.delete(sessionId);
|
|
74
|
+
await p.stop().catch(() => { });
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Apply the sandbox tier's network policy to a session as a per-session proxy.
|
|
78
|
+
* `read-only` means "no writes, no network" (see sandbox.ts), but only agents with
|
|
79
|
+
* a native sandbox enforce the network half — a CLI agent without one (opencode,
|
|
80
|
+
* aider, goose) would still reach the internet. When enforcement is opted in
|
|
81
|
+
* (`BIVY_SANDBOX_NET`), a read-only session gets a deny-all egress proxy so the
|
|
82
|
+
* contract holds for every agent. Other tiers (workspace-write, danger-full-access)
|
|
83
|
+
* allow network and get no per-session proxy. No-op unless opted in, so the default
|
|
84
|
+
* path is unchanged. Node-local traffic (the daemon's MCP/API) is exempt via the
|
|
85
|
+
* proxy env's NO_PROXY, so read-only sessions keep working against localhost.
|
|
86
|
+
*/
|
|
87
|
+
export async function applySessionSandboxEgress(sessionId, tier, onEvent) {
|
|
88
|
+
if (!process.env.BIVY_SANDBOX_NET)
|
|
89
|
+
return;
|
|
90
|
+
if (tier !== "read-only")
|
|
91
|
+
return;
|
|
92
|
+
await startSessionEgress(sessionId, denyAllDecider(), onEvent);
|
|
93
|
+
}
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// Universal Agent Harness — MCP config rewriting.
|
|
4
4
|
//
|
|
5
5
|
// The one piece of MCP governance that is unavoidably per-agent is *where* the
|
|
6
|
-
// config lives —
|
|
7
|
-
// Windsurf, and most MCP hosts use
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// config lives — and, for a couple of hosts, the shape. Claude Code, Cursor,
|
|
7
|
+
// Windsurf, and most MCP hosts use `{ mcpServers: { name: { command, args, env
|
|
8
|
+
// } } }` (stdio) plus optional remote (url) servers. OpenCode is the JSON
|
|
9
|
+
// outlier: `{ mcp: { name: { type: "local", command: [bin, ...args],
|
|
10
|
+
// environment } } }` (see opencode.ai/config.json). This module rewrites both
|
|
11
|
+
// shapes so every stdio server launches through the Bivy MCP proxy instead of
|
|
12
|
+
// directly — turning each agent's own MCP config into the injection point.
|
|
12
13
|
//
|
|
13
14
|
// Pure functions, no I/O — unit-tested in test/harness-mcp-config.test.ts. The
|
|
14
15
|
// file-location table for each agent is data (see agentMcpConfigTargets) that
|
|
@@ -109,6 +110,88 @@ export function withBivyToolsServer(config, spec, name = "bivy") {
|
|
|
109
110
|
return { config, added: false };
|
|
110
111
|
return { config: { ...config, mcpServers: { ...servers, [name]: spec } }, added: true };
|
|
111
112
|
}
|
|
113
|
+
/** Convert a universal stdio server spec into OpenCode's local-server shape. */
|
|
114
|
+
export function toOpenCodeLocalServer(spec) {
|
|
115
|
+
const command = [spec.command ?? "", ...(spec.args ?? [])].filter((s, i) => i === 0 || s !== undefined);
|
|
116
|
+
// Drop a leading empty command if somehow absent — callers always pass one.
|
|
117
|
+
const argv = command[0] ? command : command.slice(1);
|
|
118
|
+
const out = { type: "local", command: argv };
|
|
119
|
+
if (spec.env && Object.keys(spec.env).length)
|
|
120
|
+
out.environment = { ...spec.env };
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
/** True when an OpenCode local server already launches through the Bivy proxy. */
|
|
124
|
+
export function isOpenCodeProxied(spec, launcher) {
|
|
125
|
+
if (!Array.isArray(spec.command) || spec.command.length === 0)
|
|
126
|
+
return false;
|
|
127
|
+
if (spec.command[0] !== launcher.command)
|
|
128
|
+
return false;
|
|
129
|
+
return spec.command.includes(PROXY_MARKER);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Rewrite every local stdio server in `config.mcp` to launch through the proxy:
|
|
133
|
+
*
|
|
134
|
+
* original: { type: "local", command: ["mcp-fs", "--root", "/w"], environment: {...} }
|
|
135
|
+
* rewritten: { type: "local",
|
|
136
|
+
* command: ["bivy", "mcp-proxy", "--bivy-mcp", "--server", "<name>", "--",
|
|
137
|
+
* "mcp-fs", "--root", "/w"],
|
|
138
|
+
* environment: {...} }
|
|
139
|
+
*
|
|
140
|
+
* Remote servers and already-proxied locals are left untouched (reported in
|
|
141
|
+
* `skipped`). Idempotent. Does not mutate the input.
|
|
142
|
+
*/
|
|
143
|
+
export function routeOpenCodeThroughProxy(config, launcher) {
|
|
144
|
+
const rewritten = [];
|
|
145
|
+
const skipped = [];
|
|
146
|
+
const servers = config.mcp ?? {};
|
|
147
|
+
const nextServers = {};
|
|
148
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
149
|
+
if (!spec || typeof spec !== "object") {
|
|
150
|
+
nextServers[name] = spec;
|
|
151
|
+
skipped.push(name);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(spec.command) || spec.command.length === 0 || typeof spec.command[0] !== "string" || !spec.command[0]) {
|
|
155
|
+
// Remote/url server, enabled-only stub, or malformed — can't wrap via stdio proxy.
|
|
156
|
+
nextServers[name] = spec;
|
|
157
|
+
skipped.push(name);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (isOpenCodeProxied(spec, launcher)) {
|
|
161
|
+
nextServers[name] = spec;
|
|
162
|
+
skipped.push(name);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const prefix = launcher.argsPrefix ?? [];
|
|
166
|
+
const orig = spec.command;
|
|
167
|
+
nextServers[name] = {
|
|
168
|
+
...spec,
|
|
169
|
+
type: "local",
|
|
170
|
+
command: [launcher.command, ...prefix, PROXY_MARKER, "--server", name, "--", ...orig],
|
|
171
|
+
};
|
|
172
|
+
rewritten.push(name);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
config: { ...config, mcp: nextServers },
|
|
176
|
+
rewritten,
|
|
177
|
+
skipped,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Insert the Bivy tools server under OpenCode's `mcp.<name>` (default "bivy").
|
|
182
|
+
* Idempotent: an existing entry of that name is left untouched.
|
|
183
|
+
*/
|
|
184
|
+
export function withOpenCodeBivyToolsServer(config, spec, name = "bivy") {
|
|
185
|
+
const servers = config.mcp ?? {};
|
|
186
|
+
if (servers[name])
|
|
187
|
+
return { config, added: false };
|
|
188
|
+
return { config: { ...config, mcp: { ...servers, [name]: spec } }, added: true };
|
|
189
|
+
}
|
|
190
|
+
/** Basename check for OpenCode project config files we inject into. */
|
|
191
|
+
export function isOpenCodeConfigFile(filePath) {
|
|
192
|
+
const base = nodePath.basename(filePath).toLowerCase();
|
|
193
|
+
return base === "opencode.json" || base === ".opencode.json" || base === "opencode.jsonc" || base === ".opencode.jsonc";
|
|
194
|
+
}
|
|
112
195
|
/** JSON MCP-config file candidates for an agent, most-specific (safest) first. */
|
|
113
196
|
export function agentMcpConfigTargets(agentId, ctx) {
|
|
114
197
|
const ws = (...parts) => nodePath.join(ctx.workspace, ...parts);
|