@bivy/bivy 0.6.0 → 0.7.0-staging.100

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.
@@ -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 the vulnerable
6
- * brace-expansion 5.0.7 below its own node_modules. npm overrides update the
7
- * outer lockfile/audit result but do not replace the shrinkwrapped files during
8
- * installation. Replace that one nested package with our direct, exact 5.0.9
9
- * dependency until pi publishes a corrected shrinkwrap.
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
- const target = piPackage && path.join(piPackage, "node_modules", "brace-expansion");
31
+ if (!piPackage) process.exit(0);
33
32
 
34
- if (!target || !fs.existsSync(target)) process.exit(0);
35
- if (!source) throw new Error("Security patch source brace-expansion@5.0.9 is missing");
33
+ const patches = [
34
+ { name: "brace-expansion", version: "5.0.9" },
35
+ { name: "undici", version: "8.9.0" },
36
+ ];
36
37
 
37
- const sourcePackage = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
38
- if (sourcePackage.version !== "5.0.9") {
39
- throw new Error(`Refusing dependency patch from unexpected brace-expansion ${sourcePackage.version}`);
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
+ }
@@ -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
- await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/${action}`).catch(() => { });
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, so failures here are swallowed like the other transition calls. */
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
- await fetch(`${cfg.controlPlaneUrl}/node/work/${encodeURIComponent(id)}/evidence`, {
86
- method: "POST",
87
- headers: { authorization: `Bearer ${cfg.enrollmentToken}`, "content-type": "application/json" },
88
- body: JSON.stringify(patch),
89
- }).catch(() => { });
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
+ }
@@ -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
- await addLabel(cfg, issueNumber, cfg.claimLabel).catch(() => { });
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 (!res.ok)
327
- return undefined;
328
- const data = (await res.json().catch(() => ({})));
329
- return data.html_url ? { url: data.html_url, number: Number(data.number) } : undefined;
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 command = input.command;
8
- return typeof command === "string" ? command : "";
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 (/\brm\s+(-\S*[rf]\S*\s+)+(\/|~|\$home|\/\*)(\s|$|\/)/.test(c) || // rm -rf / | ~ | /*
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 (tool === "bash" && looksCatastrophic(bashCommand(input))) {
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 (tool === "bash" && looksBackstop(bashCommand(input)))
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: tool === "bash" || isWrite ? "ask" : "allow" };
146
+ return { decision: isShell || isWrite ? "ask" : "allow" };
104
147
  }
105
148
  // "risky"
106
- if (tool === "bash")
149
+ if (isShell)
107
150
  return { decision: looksRiskyBash(bashCommand(input)) ? "ask" : "allow" };
108
151
  return { decision: isWrite ? "ask" : "allow" };
109
152
  }
@@ -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 — but the shape is near-universal. Claude Code, Codex, Cursor,
7
- // Windsurf, and most MCP hosts use the same `{ mcpServers: { name: { command,
8
- // args, env } } }` object (stdio servers) plus optional remote (url) servers.
9
- // This module rewrites that object so every stdio server launches through the
10
- // Bivy MCP proxy instead of directly — turning each agent's own MCP config into
11
- // the injection point, with no agent-specific code beyond the file location.
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);