diff --git a/FORK-AUDIT.md b/FORK-AUDIT.md index 77cf5c437..37994464b 100644 --- a/FORK-AUDIT.md +++ b/FORK-AUDIT.md @@ -202,6 +202,56 @@ or network egress to Anomaly endpoints is observed from Neuron processes. --- +## Part 6: The shape argument — intent, capability, and the Neuron Guard + +**Thesis.** Judge agent software by capability envelope and governance, not +by component justifications. Every dangerous element found here had a +plausible local alibi (updates, catalog, compliance). But the aggregate — a +program running with full user permissions that executes arbitrary commands, +replaces its own binary from a remote endpoint, carries long-lived identity +credentials on every call, defeats third-party policy gates, stores secrets +in plaintext, and configures its own judgment invisibly — is the exact +capability envelope of hostile software. + +**On intent.** "Malice" requires purpose, which cannot be proven from code; +what IS provable is intentional construction of unilateral control over +machines the builder does not own, kept default-on, wrapped in opacity. +Security doctrine treats concealed dangerous capability as hostile regardless +of stated motive — the hidden camera is the violation, not the footage. +Recklessness at this capability level is indistinguishable from malice in its +outcomes. The invited-guest rule: hospitality ends when access outlives the +invitation. + +**Why openness still matters:** visibility did not prevent upstream's sins, +but it made them provable and removable. Closed agents run the same envelope +plus opacity, minus recourse. The fork converted vendor-shaped control into +owner-shaped control. + +## Part 7: The Neuron Guard (enforcement, not observation) + +Since knowing is insufficient, egress is now caught, logged, and prevented: + +- **Module**: `packages/opencode/src/guard/index.ts`, installed at the very + top of the CLI entrypoint before any provider/plugin/update path loads. +- **Mechanism**: wraps `globalThis.fetch`; records every outbound request as + JSONL to `~/.local/state/neuron-guard.jsonl` (timestamp, host, URL, + blocked-flag). +- **Modes** (`NEURON_GUARD` env): + - unset / `log`: record all egress, block nothing (default) + - `strict`: additionally BLOCK denylisted hosts — currently + `opencode.ai`, `anomalyco.com`, `github.com/anomalyco` +- **Exemptions**: `NEURON_GUARD_ALLOW="host1,host2"`. +- **Verified**: strict-mode test confirmed allow-passthrough for normal hosts + and hard block + log entry for opencode.ai. +- **Known limits**: covers only fetch-based egress inside this process; native + subprocess sockets and child processes are outside its view. OS-level + firewall rules remain the stronger boundary for adversarial cases. + +Escalation criteria from Part 4b remain in force; the guard log is now the +primary evidence source for them. + +--- + ## Part 5: Open discussion points 1. The workspace/sync runtime (`control-plane/workspace.ts`, ~966 lines) still diff --git a/packages/opencode/src/guard/index.ts b/packages/opencode/src/guard/index.ts new file mode 100644 index 000000000..f5f969d50 --- /dev/null +++ b/packages/opencode/src/guard/index.ts @@ -0,0 +1,89 @@ +import { appendFileSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" + +/** + * Neuron Egress Guard + * + * Catches and records every outbound HTTP(S) request made through + * globalThis.fetch, and optionally blocks vendor endpoints. + * + * Modes (NEURON_GUARD): + * - unset or "log" : record all egress, block nothing + * - "strict" : additionally BLOCK denylisted vendor hosts + * + * Log: ~/.local/state/neuron-guard.jsonl (one JSON object per request) + * Overrides: NEURON_GUARD_ALLOW="host1,host2" exempts hosts from blocking. + * + * Installed at the very top of the CLI entrypoint so no provider, plugin, + * or update path can make an unobserved request. + */ + +type Entry = { t: string; host: string; url: string; blocked?: boolean } + +const DENYLIST = ["opencode.ai", "anomalyco.com", "github.com/anomalyco"] + +const LOG_PATH = path.join( + process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state"), + "neuron-guard.jsonl", +) + +import { appendFileSync, mkdirSync } from "node:fs" + +function log(entry: Entry) { + try { + mkdirSync(path.dirname(LOG_PATH), { recursive: true }) + appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n") + } catch { + // never break the app over guard logging + } +} + +function hostOf(url: string): string { + try { + return new URL(url).host + } catch { + return "" + } +} + +export function installGuard() { + const mode = process.env.NEURON_GUARD ?? "log" + const strict = mode === "strict" + const allow = (process.env.NEURON_GUARD_ALLOW ?? "") + .split(",") + .map((h) => h.trim()) + .filter(Boolean) + + const original = globalThis.fetch + const guarded = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input && "url" in input && typeof input.url === "string" ? input.url : String(input)) + const host = hostOf(url) + const entry: Entry = { t: new Date().toISOString(), host, url } + if (strict && !allow.includes(host)) { + for (const denied of DENYLIST) { + if (host === denied || host.endsWith("." + denied) || url.includes(denied)) { + entry.blocked = true + log(entry) + throw new Error(`Neuron guard: egress to ${denied} is blocked (NEURON_GUARD=strict)`) + } + } + } + log(entry) + return original(input, init) + } + // preserve fetch-adjacent properties attached by the runtime + for (const key of Object.keys(original)) { + try { + ;(guarded as unknown as Record)[key] = (original as unknown as Record)[key] + } catch {} + } + globalThis.fetch = guarded as typeof globalThis.fetch +} + +export * as Guard from "." diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 70ee0f64d..46208a133 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,3 +1,6 @@ +import { installGuard } from "./guard" + +installGuard() import yargs from "yargs" import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run"