From 1cb355a8623142b8bc70a41b731dbab40e7112ca Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Wed, 28 Oct 2026 21:14:04 -0500 Subject: [PATCH 1/2] Connect the Goose harness plugin and document the protection contract Co-authored-by: Codex Signed-off-by: Alex Rosenzweig --- .gitattributes | 2 + docs/agent-protection.md | 180 ++++++++++++++++++ scripts/build-goose-acp.mjs | 17 +- src/bundled/goose/goose-testing.ts | 2 + src/bundled/goose/goose.test.ts | 14 ++ src/bundled/goose/goose.ts | 33 +++- src/bundled/goose/index.test.ts | 1 + src/bundled/goose/index.ts | 27 ++- src/bundled/goose/runtime.test.ts | 76 ++++++++ src/bundled/goose/runtime.ts | 78 +++++++- src/bundled/goose/sessions.test.ts | 25 +++ src/bundled/goose/sessions.ts | 56 +++++- src/bundled/goose/setup.test.ts | 86 ++++++++- src/bundled/goose/setup.ts | 40 ++++ .../agent-harness/acp/agent-sessions.ts | 19 ++ src/features/agent-harness/acp/process.ts | 14 +- 16 files changed, 639 insertions(+), 31 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/agent-protection.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..08953968cb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Unified patch context lines intentionally contain one space. +runtime/*.patch -whitespace diff --git a/docs/agent-protection.md b/docs/agent-protection.md new file mode 100644 index 0000000000..60b49663b2 --- /dev/null +++ b/docs/agent-protection.md @@ -0,0 +1,180 @@ +# Agent protection providers + +`agentProtection` version 3 separates the native protection boundary from an +installed plugin's classifiers and approval UI. This contract replaces the +removed legacy agent-security/ACP integration. It is experimental and requires +packaged macOS acceptance before production use. + +## Provider API + +An installed plugin injects `agentProtection`, then registers +`{ processProtection, check(request, signal), phases?(policy), runtimeProcesses? }`. Registration is owned by its +plugin ID; replacing or unloading it revokes the old registration and pending +checks. Only the exact boolean `true` permits an action. Exceptions, cancellation, +missing providers, invalid/oversized requests and the bounded deadline deny it. + +`runtimeProcesses` maps runtime owner IDs, such as `buzz.codex`, to processes in +the protecting plugin's manifest. A bound `AgentProtection.spawn(options)` selects +only the captured agent's runtime and launches through that provider's captured +host scope. It always supplies the captured containment. Missing engines refuse +launch; provider replacement/unload kills its processes, including a launch that +finishes after revocation. Adapters keep ownership of sessions and credentials. + +Manifest processes may declare `artifact: {path, sha256, siblings?}` instead of +`bundled`. Native launch verifies the absolute executable and named sibling hashes, +rejects symbolic links, and contains their directory automatically. These paths +come from the installed plugin manifest, never from model input or PATH. + +`processProtection` contains absolute existing `readOnlyPaths` and `hiddenPaths`. +The native process launcher adds agent controls, installed plugin storage and the +application bundle. macOS denies worker writes to these roots and replacement of +their ancestors, reads of private approval state, and Apple Events. Workspace +writes remain possible. Mutable development frontends and other operating +systems refuse protected processes. This is approval-authority containment, not +a general network/filesystem policy. + +`check` receives an immutable action (`kind`, `session`, `content`, optional +`command`) plus host-captured `agent`, `relay`, `runtime` and `policy`. `kind` is +`context`, `tool` or `tool-result`. Session metadata is not authorization or proof +of the currently displayed conversation. Each source or combined tool action is limited to 256 KiB. Optional `phases(policy)` returns a +boolean for every action kind; it is captured from the native policy at launch. +Disabled phases still validate the provider lifetime, but send no inspection +evidence and do not invoke a checker. Omitting it enables every phase. Plugins +must propagate cancellation and must not derive identity/policy from tool text. + +`snapshot()` returns native defaults/revision and current local agents with +binding/revision and runtime coverage. `saveDefaults(revision, policyOrNull)` and +`saveAgent(pubkey, revision, policyOrNull)` use native compare-and-swap. Another +provider's binding cannot be overwritten implicitly. Policies are JSON objects +limited to 32 KiB. Defaults are copied under the identity lock on every creation +and import path, including agent-requested creation. + +Existing-agent saves require an idle runner and exclusive native hosting custody. +Every community membership, including disconnected ones, must acknowledge native +runtime retirement before persistence. A pubkey transition also holds memberships +that join while a save is pending. Failed retirement +continues to block retries. Native admission compares the cached protection +revision; stale app copies refuse work and refresh. Old handles are revoked. Protected identities use a sticky version-3 envelope +containing every membership; legacy split files with divergent policy and +ambiguous protected version-2 migrations are refused. + +## Runtime contract + +Runtime types declare `protection: true` only when every supported model request, +tool execution and tool result passes `AgentHandle.protection`. Their `retire` +method must fully end existing native sessions before a policy changes. Missing +coverage refuses protected runs and session mutations, with the type's explicit +`protectionUnavailable` reason. Shared Buzz tool dispatch also gates exact input +snapshots and returned output/errors. + +Protected Pi uses one contained +worker per agent/provider generation and separate durable state per native policy +revision. Built-in providers, coding tools and Buzz tools are supported. User Pi +packages are not loaded into protected workers. External Attention commands are +refused for protected agents until that path provides complete enforcement. Tool output/diagnostics/errors +remain buffered until approved, including safe replay. Model dispatch checks +cover restored history, compaction and retries. Deferred provider jobs and payload +rewrite callbacks are refused. Image-generation and classifier request shapes without explicit conversation messages are refused. An approved shell command is one action; its +internal syscalls are not separately reviewed. + +The separate [Buzz Security plugin](https://github.com/squareup/buzz-plugin-security) +owns the protected Codex/Goose source pins, patches, builds, release assets and +native boundary fixtures. Buzz packages only its ordinary Goose runtime; ordinary +Codex retains its independent updater. Installing the security plugin supplies +the protected engines on demand, without compiling them during Buzz development. + +Protected Goose's native provider decorator gates model input, +including tool results, and its central tool dispatcher gates arguments together +with the native conversation. Providers with internal tool loops, toolshim, +unreviewed native command hooks, recipe shell callbacks and unsupported extension +launches are refused. Protected agents have dedicated contained workers. + +Protected Codex uses the plugin's separate pinned app server. Prepared HTTP/WebSocket model requests pass the +native gate, including recovery/compaction requests; native local tool dispatch +checks the arguments and conversation after hook rewrites. Hosted tools are +refused. The plugin ships its sibling `codex-code-mode-host` from the same patched +pin; both executable hashes must match before native launch. + +Claude uses the pinned native CLI 2.1.305. Unfiltered input, pre-tool, raw success/ +failure output and final tool-batch hooks await review. Native transcript plus +immutable native stream events provide preceding evidence while writes are +pending. A native watchdog kills stalled hook callbacks before the CLI's timeout +can fail open. Hook-registration errors and Stop terminate the worker. Workspace +instruction loading, attachments, native subagent delegation, background tasks, +experimental context clearing and fallback rewrites are disabled. Compaction and +session reset/fork are refused; protected resumes require the same native policy +state key and readable prior transcript. Start a new conversation when this +bounded path cannot continue. Do not describe this evidence as a lossless active +history snapshot. + +Input inspection follows Goose's separation of conversation text from provider +configuration. Dynamic instructions and every native message are checked as +individual immutable sources before model dispatch; tool catalogues, schemas and +model options are not prompt-classifier evidence. Codex excludes only exact bundled +multi-agent mode text identified by its native fragment kind before attribution is +removed. Goose excludes only its exact compiled-in system prefix; custom prompts +and appended instructions, memory and external context remain inspection evidence. +Claude excludes only the known tool-catalogue fields in native prompt snapshots +and request-shape logs, plus top-level hook transport metadata. System text, +conversation payloads, unknown fields and nested data remain inspection evidence; +tool-action review retains the full hook input and preceding conversation. +Neither projection changes model-visible input or trusts a role label. Pi uses its native system-text +renderer to retain dynamic instruction sections without embedded tool declarations. +Codex validates local dispatch for additional tool declarations before excluding +those declarations from input inspection. Restored messages and tool results are +retained; this contract does not adopt Goose's ten-user-message window. An individual +source failure prevents model dispatch. Tool-action inspection still receives the +exact arguments and combined preceding native conversation. + +Optional native plugin hooks are disabled for protected Pi, Claude and Codex workers until replacement payloads can be inspected in the correct order. Ordinary workers retain native hook dispatch. + +All supported protected workers require packaged macOS containment. Reviewer +choice remains separate from the requesting runtime's enforcement capability. + +## Configurable inspection models + +Runtime plugins own `registerModels({label, models, complete})`; credentials remain +with that runtime. `modelChoices()` lists configured choices. A protection provider +calls `complete(model, {system, input, outputSchema?}, signal, requester?)` for +bounded tool-free inference. The plugin owns instructions, schemas and response +parsing; the host returns text and the actual model. The `agent` model selector +resolves only the current requesting agent's configured runtime/model using its +host identity and relay. Runtime changes and cancellation reject the result. +Prompts, including optional serializable schemas, are bounded to 768 KiB. + +Runtime completion receives an optional fourth, prepared spawn function for the +calling protection plugin's engine. Codex and Goose require it for their isolated, +tool-free review workers; no global engine lookup or credential handoff is used. +Pi and Claude retain their existing runtime-owned tool-free transports. Each +runtime preserves system/input roles and disposes temporary inference work. + +Buzz Security supplies the strict `{blocked, reason}` decision schema and its +separate detection, explanation and review prompts. Human explanations grant no +execution authority; model review creates no persistent agent identity. + +Buzz Security independently configures incoming-content and dangerous-action +hooks, at default and per-agent scope. Each can be disabled or assigned a model +from any registered runtime, including custom model IDs. Recommended selections +are defaults rather than an allowlist. Model assessment is distinct from assigning +a Buzz agent to recommend or decide on a flagged action. The security plugin +offers model review by default, with an existing Buzz agent as an explicit option. + +## Evidence + +Focused tests cover native identity/default persistence, cross-copy custody, +stale admission, policy retirement, cancellation and shared Buzz tools. The macOS +protection CI job installs exact Pi lockfile dependencies and exercises real Pi +storage/tools with a synthetic model/checker: held/denied writes, withheld output, +cancellation, recovery, restored context and compaction. It also executes the +native macOS containment profile and verifies the pinned Claude download against +its release checksum. Claude additionally exercises withheld successful/failed +output and resumed transcripts. The plugin's native tests build exact protected +Goose/Codex pins and use synthetic loopback model responses with real filesystem +writes to prove held actions, denied writes and denied input. Relocating these +fixtures preserves their assertions and native coverage. These fixtures do not +establish packaged-app integration, real classifier health or real-model review +quality. Native build caches remain keyed by pins and patches. Human +acceptance and current UI/interaction checks remain required before marking the +experimental PR ready. + +Each input source or combined tool action is bounded to 256 KiB, with separate 768 KiB serialized prompt/record headroom. Nothing is truncated to fit. Fixed classifiers can have smaller input bounds; a failed inspection must pause or deny. These byte limits do not establish classifier tokenization or detection completeness. diff --git a/scripts/build-goose-acp.mjs b/scripts/build-goose-acp.mjs index 90ed53dcdd..9d86d460b4 100644 --- a/scripts/build-goose-acp.mjs +++ b/scripts/build-goose-acp.mjs @@ -44,8 +44,21 @@ console.log( const source = join(root, "target/goose-acp-src"); mkdirSync(source, { recursive: true }); run("git", ["init", "--quiet"]); -run("git", ["fetch", "--quiet", "--depth", "1", pin.repository, pin.revision]); -run("git", ["checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"]); +if ( + spawnSync("git", ["cat-file", "-e", `${pin.revision}^{commit}`], { + cwd: source, + stdio: "ignore", + }).status !== 0 +) + run("git", [ + "fetch", + "--quiet", + "--depth", + "1", + pin.repository, + pin.revision, + ]); +run("git", ["checkout", "--quiet", "--force", "--detach", pin.revision]); run( "cargo", [ diff --git a/src/bundled/goose/goose-testing.ts b/src/bundled/goose/goose-testing.ts index 4208708703..8599e82ecd 100644 --- a/src/bundled/goose/goose-testing.ts +++ b/src/bundled/goose/goose-testing.ts @@ -249,6 +249,8 @@ export function fakeSpawn( if (fake.silent.has(method)) return; const sessionId = String(params.sessionId ?? ""); switch (method) { + case "_buzz/security/capabilities": + return reply(id, { version: 2 }); case "initialize": return reply(id, { protocolVersion: 1, diff --git a/src/bundled/goose/goose.test.ts b/src/bundled/goose/goose.test.ts index 4eb5985d99..4b82e35680 100644 --- a/src/bundled/goose/goose.test.ts +++ b/src/bundled/goose/goose.test.ts @@ -1455,3 +1455,17 @@ it("lets new work steer while a stopped Attention refresh is still pending", asy await Promise.all([first, stopped, next, later]); } }); + +it("does not acknowledge strict retirement when the native session refuses to close", async () => { + const { goose, release, waiting } = await slow("session/close", true); + const session = await goose.open({ + cwd: "/work", + systemPrompt: "", + tools: ok, + }); + const closing = goose.close(session, true); + const failed = expect(closing).rejects.toThrow("No active run"); + await vi.waitFor(() => expect(waiting()).toBe(1)); + release(); + await failed; +}); diff --git a/src/bundled/goose/goose.ts b/src/bundled/goose/goose.ts index 0c8b3fdc9a..92815275c8 100644 --- a/src/bundled/goose/goose.ts +++ b/src/bundled/goose/goose.ts @@ -167,18 +167,43 @@ export const GOOSE: AcpHooks = { /** Starts `goose-acp` and checks it can reach the Buzz tools. */ export const GooseProcess = { - start: ( + start: async ( spawn: Spawn, launch: Readonly<{ cwd: string; env?: Readonly>; serve?: ServeMcp; + protected?: boolean; + signal?: AbortSignal; }>, - ) => - AcpProcess.start(spawn, GOOSE, { + ) => { + const goose = await AcpProcess.start(spawn, GOOSE, { ...launch, env: { ...databricks(), ...launch.env }, - }), + }); + const cancel = () => void goose.kill(); + launch.signal?.addEventListener("abort", cancel, { once: true }); + try { + launch.signal?.throwIfAborted(); + if (launch.protected) { + const capability = (await goose.request( + "_buzz/security/capabilities", + {}, + REQUEST_TIMEOUT_MS, + )) as { version?: number }; + if (capability.version !== 2) + throw new Error("This Goose build lacks mandatory security hooks"); + } + launch.signal?.throwIfAborted(); + return goose; + } catch (error) { + await goose.kill(); + await goose.exited; + throw error; + } finally { + launch.signal?.removeEventListener("abort", cancel); + } + }, }; async function defaultModel(goose: AcpProcess, provider: string) { diff --git a/src/bundled/goose/index.test.ts b/src/bundled/goose/index.test.ts index e7825ec88e..b8a3ef19d0 100644 --- a/src/bundled/goose/index.test.ts +++ b/src/bundled/goose/index.test.ts @@ -26,6 +26,7 @@ it("observes saved conversations with the status of open sessions", () => { apply({ pluginOwner: { id: "buzz.goose", revision: "r1" }, host: { request: vi.fn() }, + agentProtection: { registerModels: vi.fn() }, agents: { register: (registered: AgentType) => { type = registered; diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts index 2fc3ea870a..1c4fd4a65f 100644 --- a/src/bundled/goose/index.ts +++ b/src/bundled/goose/index.ts @@ -15,7 +15,7 @@ import { readUsage } from "./usage"; export const TYPE_ID = "goose"; -export const inject = ["agents", "host"]; +export const inject = ["agents", "host", "agentProtection"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `goose-acp`; no unusable type elsewhere. if (!isTauri()) return; @@ -37,6 +37,29 @@ export const apply: PluginModule["apply"] = (ctx) => { } : undefined; const setup = new GooseSetup(desktop, () => runtime.defaultsChanged()); + ctx.agentProtection.registerModels({ + label: "Goose", + models: async () => { + const { providers } = await setup.providers(); + return ( + await Promise.all( + providers + .filter((provider) => provider.configured) + .map(async (provider) => + ( + await setup.models(provider.id) + ).map((model) => ({ + provider: provider.id, + id: model.id, + label: `${provider.name} · ${model.label}`, + })), + ), + ) + ).flat(); + }, + complete: (model, prompt, signal, protectedSpawn) => + setup.review(model, prompt, signal, protectedSpawn), + }); const history = new GooseHistory(desktop); const { Create, @@ -66,6 +89,8 @@ export const apply: PluginModule["apply"] = (ctx) => { agents.register({ id: TYPE_ID, + retire: (pubkey) => runtime.retire(pubkey), + protection: true, title: "Goose", icon: GooseLogoIcon, description: diff --git a/src/bundled/goose/runtime.test.ts b/src/bundled/goose/runtime.test.ts index 50e079e48c..f3b8b5d2b9 100644 --- a/src/bundled/goose/runtime.test.ts +++ b/src/bundled/goose/runtime.test.ts @@ -4,6 +4,7 @@ import { callTool, memoryVersion } from "../../buzz-mcp/tools"; import { createAgentMemories } from "../../features/agents/memory"; import { RESUMED_PROMPT, TOP_LEVEL_PROMPT } from "../../buzz-mcp/prompt"; import type { Agent, Delivery } from "../../features/agents/service"; +import type { AgentProtection } from "../../features/agent-protection/service"; import type { Host } from "../../features/host/service"; import type { EventData, RelayEvent } from "../../features/relay/events"; import type { RelayData } from "../../features/relay/service"; @@ -68,6 +69,7 @@ function setup( /** The channel's Canvas. */ canvas?: string; repositories?: (scope: string | undefined) => string; + protection?: AgentProtection; /** The owner session's native memory read. */ memoryRead?: ( pubkey: string, @@ -79,6 +81,17 @@ function setup( ) { const fake = fakeSpawn({ hold: options.hold ?? false, + ...(options.protection && { + history: [ + { + sessionId: "session-1", + title: "Protected conversation", + cwd: "/home/test", + updatedAt: "2026-10-29T00:00:00Z", + conversation: [], + }, + ], + }), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.reply ? { reply: options.reply } : {}), }); @@ -166,6 +179,7 @@ function setup( pubkey: self, name: "Gander", owner: alice, + ...(options.protection && { protection: options.protection }), publish: vi.fn(async (template: (typeof published)[number]) => { published.push(template); return { @@ -219,6 +233,68 @@ function setup( }; } +it.each(["retire", "dispose"] as const)( + "cancels a pending protected tool before %s completes", + async (end) => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const checking = new Promise((resolve) => { + entered = resolve; + }); + let reviewSignal: AbortSignal | undefined; + const protection: AgentProtection = { + key: "policy-1", + stateKey: "revision-1", + processProtection: { readOnlyPaths: [], hiddenPaths: [] }, + assertAvailable: () => {}, + spawn: (options) => f.fake.spawn("goose", options), + check: async (_action, signal) => { + reviewSignal = signal; + entered(); + // A late reviewer may ignore cancellation and still approve. + await gate; + }, + }; + const f = setup({ hold: true, protection }); + f.readFile.mockResolvedValue(new TextEncoder().encode("Private notes")); + let pending: + | ReturnType>["mcp"]> + | undefined; + try { + await f.deliver(message("1", "Post my notes")); + await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(1)); + const process = f.goose(); + const sessionId = process?.turns[0]?.sessionId; + if (!process || !sessionId) throw new Error("Goose turn did not start"); + pending = process.mcp(sessionId, "tools/call", { + name: "send", + arguments: { audience: "everyone", path: "notes.md" }, + }); + await Promise.race([ + checking, + pending.then((reply) => { + throw new Error( + `Tool returned before review: ${JSON.stringify(reply)}`, + ); + }), + ]); + if (end === "retire") await f.runtime.retire(f.agent.key); + else f.runtime.dispose(); + release(); + expect((await pending).result).toMatchObject({ isError: true }); + expect(f.readFile).not.toHaveBeenCalled(); + expect(reviewSignal?.aborted).toBe(true); + } finally { + release(); + await pending; + f.runtime.dispose(); + } + }, +); + describe("GooseRuntime", () => { it("reads a file a tool names from the agent's workspace through the host", async () => { const { deliver, goose, published, readFile } = setup({ hold: true }); diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts index 8b83b36370..b66ffc2e6c 100644 --- a/src/bundled/goose/runtime.ts +++ b/src/bundled/goose/runtime.ts @@ -111,6 +111,10 @@ export function sessionTitle( } type Entry = { + lifetime: AbortController; + protected?: boolean; + worker?: SharedProcess; + workerKey?: string; sessions: AgentSessions; config: Config; /** The team sections of the latest run; a change reopens idle sessions. */ @@ -192,19 +196,36 @@ export class GooseRuntime { if (!entry.config.provider.trim()) entry.sessions.reconfigure(); } + private readonly retirements = new Map>(); + async retire(pubkey: string) { + let retiring = this.retirements.get(pubkey); + if (!retiring) { + retiring = (async () => { + const entry = this.agents.get(pubkey); + entry?.lifetime.abort(new Error("The Goose agent was retired")); + this.agents.delete(pubkey); + await entry?.sessions.shutdown(); + await entry?.worker?.shutdown(); + })(); + this.retirements.set(pubkey, retiring); + } + await retiring; + this.retirements.delete(pubkey); + } sync(agents: readonly Agent[]) { const current = new Set(agents.map((agent) => agent.key)); - for (const [key, entry] of this.agents) - if (!current.has(key)) { - entry.sessions.dispose(); - this.agents.delete(key); - } + for (const key of this.agents.keys()) + if (!current.has(key)) void this.retire(key).catch(() => undefined); if (!this.agents.size) this.goose.stop(); for (const agent of agents) { + if (this.retirements.has(agent.key)) continue; const next = config(agent.config); const entry = this.agents.get(agent.key); - if (!entry) this.entry(agent.key, next).name = agent.name; - else if ( + if (!entry) { + const created = this.entry(agent.key, next); + created.name = agent.name; + created.protected = !!agent.protection; + } else if ( JSON.stringify(entry.config) !== JSON.stringify(next) || entry.name !== agent.name ) { @@ -282,7 +303,11 @@ export class GooseRuntime { } dispose() { - for (const entry of this.agents.values()) entry.sessions.dispose(); + for (const entry of this.agents.values()) { + entry.lifetime.abort(new Error("The Goose runtime was disposed")); + entry.sessions.dispose(); + entry.worker?.stop(); + } this.agents.clear(); this.goose.stop(); } @@ -730,9 +755,12 @@ export class GooseRuntime { } private entry(agent: AgentKey, initial: Config): Entry { + if (this.retirements.has(agent)) + throw new Error("This agent’s previous native sessions have not retired"); const existing = this.agents.get(agent); if (existing) return existing; const entry: Entry = { + lifetime: new AbortController(), config: initial, team: "", sessions: undefined as unknown as AgentSessions, @@ -746,6 +774,19 @@ export class GooseRuntime { const client = handle && appClient(handle, { + signal: entry.lifetime.signal, + protectionContext: async () => { + const sessionId = + conversation && entry.sessions.sessionId(conversation); + if (!sessionId) + throw new Error("The native Goose session is unavailable"); + const process = await (entry.worker ?? this.goose).get(); + const { data } = (await process.request( + "_goose/unstable/session/export", + { sessionId, format: "json" }, + )) as { data: string }; + return JSON.parse(data); + }, relayAuthor: this.relay(agent)?.snapshot().session.relayAuthor, signingAuthority: this.relay(agent)?.snapshot().session.workflowAuthority, @@ -760,7 +801,26 @@ export class GooseRuntime { entry.sessions = new AgentSessions({ resolveDirectory: (path) => this.desktop.resolveDirectory(path), tools, - process: this.goose, + process: { + get: () => { + const protection = entry.handle?.protection; + if (!protection) { + if (entry.protected) + throw new Error("The protected agent has no live policy binding"); + return this.goose.get(); + } + protection.assertAvailable(); + if (entry.worker && entry.workerKey !== protection.key) + throw new Error("Retire the previous protected Goose worker first"); + entry.workerKey = protection.key; + entry.worker ??= new SharedProcess( + this.desktop, + (handler) => this.serve(handler), + protection, + ); + return entry.worker.get(); + }, + }, store: localSessions(this.storage, agent), onChange: () => this.notify(), // Read as each turn starts, so a change needs no restart. diff --git a/src/bundled/goose/sessions.test.ts b/src/bundled/goose/sessions.test.ts index 4e4ce93cd4..8686cdd709 100644 --- a/src/bundled/goose/sessions.test.ts +++ b/src/bundled/goose/sessions.test.ts @@ -124,6 +124,31 @@ const host = (cap: number) => { }; describe("AgentSessions", () => { + it("maps conversation and one-off keys to their native session IDs", async () => { + const { fake, sessions, process } = setup({}, true); + const delivery = sessions.deliver("thread", "one", 1); + const once = sessions.runOnce("watch", () => "two"); + try { + await vi.waitFor(() => expect(fake.geese[0]?.turns).toHaveLength(2)); + const turns = fake.geese[0]?.turns ?? []; + const normal = turns.find((turn) => turn.text === "one"); + const standalone = turns.find((turn) => turn.text === "two"); + expect(normal?.sessionId).toBeDefined(); + expect(standalone?.sessionId).toBeDefined(); + expect(sessions.sessionId("thread")).toBe(normal?.sessionId); + expect(sessions.sessionId("watch #1")).toBe(standalone?.sessionId); + expect(sessions.sessionId("missing")).toBeUndefined(); + for (const turn of turns) turn.finish(); + await Promise.all([delivery, once]); + expect(sessions.sessionId("watch #1")).toBeUndefined(); + } finally { + sessions.dispose(); + await process.shutdown(); + await Promise.all([delivery, once]); + } + expect(sessions.sessionId("thread")).toBeUndefined(); + }); + it("serves each conversation from its own session in one process", async () => { const { fake, sessions } = setup(); await sessions.deliver("a", "one", 1); diff --git a/src/bundled/goose/sessions.ts b/src/bundled/goose/sessions.ts index bb8d82cad1..013f4cfd04 100644 --- a/src/bundled/goose/sessions.ts +++ b/src/bundled/goose/sessions.ts @@ -4,7 +4,8 @@ import { AgentSessions as AcpAgentSessions, type SessionsOptions as AcpSessionsOptions, } from "../../features/agent-harness/acp/agent-sessions"; -import type { AcpProcess } from "../../features/agent-harness/acp/process"; +import type { AgentProtection } from "../../features/agent-protection/service"; +import { nativeProtectionBridge } from "../../features/agent-protection/native-bridge"; import { GooseProcess, type GooseHost, type ServeMcp } from "./goose"; import type { SavedConversation, @@ -26,22 +27,54 @@ export { * home folder when a session first needs it, and again after it exits. One * runtime shares one. */ export class SharedProcess { - private ready: Promise | undefined; + private ready: Promise | undefined; constructor( private readonly host: GooseHost, /** Starts a session's MCP server for its agent's tools. */ private readonly serve: ServeMcp, + private readonly protection?: AgentProtection, ) {} /** The running process. One that failed to start is started again on the * next call. */ - get(): Promise { + get(): Promise { + this.protection?.assertAvailable(); if (this.ready) return this.ready; - const ready = (async () => - GooseProcess.start(this.host.spawn, { - cwd: await this.host.resolveDirectory("~"), - serve: this.serve, - }))(); + const ready = (async () => { + const bridge = + this.protection && + (await nativeProtectionBridge(this.serve, this.protection)); + try { + const goose = await GooseProcess.start( + (id, options) => + this.protection + ? this.protection.spawn(options ?? {}) + : this.host.spawn(id, options), + { + cwd: await this.host.resolveDirectory("~"), + serve: this.serve, + ...(bridge && { + protected: true, + env: { + ...bridge.env, + BASH_ENV: null, + ENV: null, + DYLD_INSERT_LIBRARIES: null, + DYLD_LIBRARY_PATH: null, + DYLD_FRAMEWORK_PATH: null, + LD_PRELOAD: null, + LD_LIBRARY_PATH: null, + }, + }), + }, + ); + void goose.exited.finally(() => bridge?.close()); + return goose; + } catch (error) { + await bridge?.close(); + throw error; + } + })(); const forget = () => { if (this.ready === ready) this.ready = undefined; }; @@ -51,9 +84,14 @@ export class SharedProcess { } /** Stops it, failing every session in it. */ stop() { + void this.shutdown().catch(() => undefined); + } + async shutdown() { const ready = this.ready; this.ready = undefined; - void ready?.then((goose) => goose.kill()).catch(() => undefined); + const goose = await ready; + await goose?.kill(); + await goose?.exited; } } diff --git a/src/bundled/goose/setup.test.ts b/src/bundled/goose/setup.test.ts index 421b7da5ec..44155f9e94 100644 --- a/src/bundled/goose/setup.test.ts +++ b/src/bundled/goose/setup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Spawn } from "./goose"; -import { fakeSpawn } from "./goose-testing"; +import { fakeSpawn, flush } from "./goose-testing"; import { GooseSetup, keyLabel } from "./setup"; const ANTHROPIC = { @@ -76,6 +76,90 @@ const setup = (options: Parameters[0] = {}) => { }; describe("GooseSetup", () => { + it.each([false, true])( + "waits for the protected review worker to exit after completion (failure: %s)", + async (fails) => { + const { fake, setup: goose } = setup(); + let killed!: () => void; + const killing = new Promise((resolve) => { + killed = resolve; + }); + const kill = vi.fn(async () => killed()); + const spawn = async (options: Parameters[1]) => { + const process = await fake.spawn("goose", options); + return { + ...process, + kill, + write: async (data: string) => { + const request = JSON.parse(data); + if (request.method !== "_buzz/security/review") + return process.write(data); + expect(request.params).toEqual({ + id: "qwen3", + prompt: { system: "Review", input: "Evidence" }, + }); + options?.onStdout?.( + `${JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + ...(fails + ? { error: { code: -32603, message: "Review failed" } } + : { result: { text: "Approved", model: { id: "qwen3" } } }), + })}\n`, + ); + }, + }; + }; + let settled = false; + const result = goose + .review( + { id: "qwen3" }, + { + system: "Review", + input: "Evidence", + outputSchema: { type: "object", required: ["decision"] }, + }, + new AbortController().signal, + spawn, + ) + .then( + (value) => { + settled = true; + return value; + }, + (error: unknown) => { + settled = true; + throw error; + }, + ); + const outcome = fails + ? expect(result).rejects.toThrow("Review failed") + : expect(result).resolves.toMatchObject({ text: "Approved" }); + try { + await killing; + await flush(); + expect(kill).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + } finally { + fake.geese[0]?.exit(null); + await outcome; + } + expect(settled).toBe(true); + }, + ); + + it("refuses model review without a protection-plugin worker", async () => { + const { fake, setup: goose } = setup(); + expect(() => + goose.review( + { provider: "ollama", id: "qwen3" }, + { system: "Review", input: "Evidence" }, + new AbortController().signal, + ), + ).toThrow("Protected Goose review worker is unavailable"); + expect(fake.geese).toHaveLength(0); + }); + it("lists the providers set up here, by name, with goose's defaults", async () => { const { fake, setup: goose } = setup(); const listed = await goose.providers(); diff --git a/src/bundled/goose/setup.ts b/src/bundled/goose/setup.ts index 6c5107d590..f9f4bb9448 100644 --- a/src/bundled/goose/setup.ts +++ b/src/bundled/goose/setup.ts @@ -3,6 +3,10 @@ // call runs a short-lived `goose-acp`; keys go to goose's secret store, never // into Buzz. import type { ModelChoice } from "../../features/agents/ModelPicker"; +import type { + ProtectedSpawn, + ReviewPrompt, +} from "../../features/agent-protection/models"; import type { AgentProviderSnapshot, Models, @@ -289,6 +293,42 @@ export class GooseSetup { return this.listing; } + review( + model: { provider?: string; id: string }, + prompt: ReviewPrompt, + signal: AbortSignal, + spawn?: ProtectedSpawn, + ) { + signal.throwIfAborted(); + const host = this.host; + if (!spawn || !host) + throw new Error("Protected Goose review worker is unavailable"); + return (async () => { + const cwd = await host.resolveDirectory("~"); + signal.throwIfAborted(); + const goose = await GooseProcess.start( + (_id, options) => spawn(options ?? {}), + { cwd, protected: true, signal }, + ); + const cancel = () => void goose.kill(); + signal.addEventListener("abort", cancel, { once: true }); + try { + signal.throwIfAborted(); + const result = (await goose.request( + "_buzz/security/review", + { ...model, prompt: { system: prompt.system, input: prompt.input } }, + 65_000, + )) as { text: string; model: { provider: string; id: string } }; + signal.throwIfAborted(); + return result; + } finally { + signal.removeEventListener("abort", cancel); + await goose.kill(); + await goose.exited; + } + })(); + } + /** Databricks catalogs can lag its endpoints and AI Gateway services; * configured providers use live discovery for both Databricks versions. * A provider not set up offers none: its catalog's would not run. */ diff --git a/src/features/agent-harness/acp/agent-sessions.ts b/src/features/agent-harness/acp/agent-sessions.ts index febb9ae0f7..4c58f08302 100644 --- a/src/features/agent-harness/acp/agent-sessions.ts +++ b/src/features/agent-harness/acp/agent-sessions.ts @@ -164,6 +164,10 @@ export class AgentSessions { active(key: string) { return this.opening.has(key) || !!this.live.get(key)?.session.working; } + /** The harness session serving a conversation or one-off turn. */ + sessionId(key: string) { + return (this.live.get(key) ?? this.once.get(key))?.session.id; + } /** Conversations with a session, whether each is working, and when each * last took in a message or ended a turn. */ snapshot(): { @@ -441,6 +445,21 @@ export class AgentSessions { /** Closes its sessions, ending their turns; the process keeps serving other * agents. */ + async shutdown() { + this.disposed = true; + const live = [...this.live.values(), ...this.once.values()].filter( + (entry): entry is Live => !!entry, + ); + for (const [key, entry] of this.live) { + clearTimeout(entry.timer); + this.live.delete(key); + this.shown.delete(key); + } + await Promise.all( + live.map((entry) => entry.process.close(entry.session, true)), + ); + this.options.onChange?.(); + } dispose() { this.disposed = true; for (const [key, live] of this.live) this.drop(key, live); diff --git a/src/features/agent-harness/acp/process.ts b/src/features/agent-harness/acp/process.ts index 3f9e0c41f9..7e22fc567d 100644 --- a/src/features/agent-harness/acp/process.ts +++ b/src/features/agent-harness/acp/process.ts @@ -318,21 +318,25 @@ export class AcpProcess { /** Ends a session. Its turn, if any, is cancelled first. The harness is * asked to close it only when it says it can. */ - async close(session: AcpSession) { + async close(session: AcpSession, strict = false) { if (!this.sessions.delete(session.id)) return; session.fail("The session was closed"); void session.server?.close(); if (!this.alive) return; this.notify("session/cancel", { sessionId: session.id }); - if (this.hooks.closeSession !== "always" && !this.advertises("close")) + if (this.hooks.closeSession !== "always" && !this.advertises("close")) { + if (strict) + throw new Error("The harness cannot acknowledge session retirement"); return; + } await this.request( "session/close", { sessionId: session.id }, REQUEST_TIMEOUT_MS, - ).catch((error) => - console.warn(`${this.hooks.label} did not close a session`, error), - ); + ).catch((error) => { + if (strict) throw error; + console.warn(`${this.hooks.label} did not close a session`, error); + }); } kill() { From 5cbb3761114b9baa71d6e102c7f3f478addcc46b Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig Date: Thu, 29 Oct 2026 14:55:42 -0500 Subject: [PATCH 2/2] Fix Goose review serialization and duplicate input approvals Project review requests to the strict native schema and suppress auxiliary session-title inference for protected workers. Buzz keeps its existing fallback title. Runtime routing and one-use action review remain unchanged. Co-authored-by: Codex Signed-off-by: Alex Rosenzweig --- src/bundled/goose/goose.test.ts | 20 ++++++++++++++++++++ src/bundled/goose/goose.ts | 6 +++++- src/bundled/goose/setup.test.ts | 4 +++- src/bundled/goose/setup.ts | 6 +++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/bundled/goose/goose.test.ts b/src/bundled/goose/goose.test.ts index 4b82e35680..be88312e75 100644 --- a/src/bundled/goose/goose.test.ts +++ b/src/bundled/goose/goose.test.ts @@ -73,6 +73,26 @@ afterEach(() => { }); describe("GooseProcess", () => { + it.each([false, true])( + "suppresses auxiliary title inference only for protected workers (%s)", + async (protectedWorker) => { + const fake = fakeSpawn(); + const goose = await GooseProcess.start(fake.spawn, { + cwd: "/work", + protected: protectedWorker, + env: { GOOSE_DISABLE_SESSION_NAMING: "false" }, + }); + try { + expect(fake.geese[0]?.env.GOOSE_DISABLE_SESSION_NAMING).toBe( + protectedWorker ? "true" : "false", + ); + } finally { + await goose.kill(); + await goose.exited; + } + }, + ); + it("starts goose-acp without the app's Buzz identity or goose choice", async () => { const { process } = await start(); expect(process.options.cwd).toBe("/work"); diff --git a/src/bundled/goose/goose.ts b/src/bundled/goose/goose.ts index 92815275c8..779b51c0a9 100644 --- a/src/bundled/goose/goose.ts +++ b/src/bundled/goose/goose.ts @@ -179,7 +179,11 @@ export const GooseProcess = { ) => { const goose = await AcpProcess.start(spawn, GOOSE, { ...launch, - env: { ...databricks(), ...launch.env }, + env: { + ...databricks(), + ...launch.env, + ...(launch.protected && { GOOSE_DISABLE_SESSION_NAMING: "true" }), + }, }); const cancel = () => void goose.kill(); launch.signal?.addEventListener("abort", cancel, { once: true }); diff --git a/src/bundled/goose/setup.test.ts b/src/bundled/goose/setup.test.ts index 44155f9e94..d42a7c0603 100644 --- a/src/bundled/goose/setup.test.ts +++ b/src/bundled/goose/setup.test.ts @@ -95,6 +95,7 @@ describe("GooseSetup", () => { if (request.method !== "_buzz/security/review") return process.write(data); expect(request.params).toEqual({ + provider: "ollama", id: "qwen3", prompt: { system: "Review", input: "Evidence" }, }); @@ -111,9 +112,10 @@ describe("GooseSetup", () => { }; }; let settled = false; + const model = { runtime: "buzz.goose", provider: "ollama", id: "qwen3" }; const result = goose .review( - { id: "qwen3" }, + model, { system: "Review", input: "Evidence", diff --git a/src/bundled/goose/setup.ts b/src/bundled/goose/setup.ts index f9f4bb9448..d9b62e5a1a 100644 --- a/src/bundled/goose/setup.ts +++ b/src/bundled/goose/setup.ts @@ -316,7 +316,11 @@ export class GooseSetup { signal.throwIfAborted(); const result = (await goose.request( "_buzz/security/review", - { ...model, prompt: { system: prompt.system, input: prompt.input } }, + { + provider: model.provider, + id: model.id, + prompt: { system: prompt.system, input: prompt.input }, + }, 65_000, )) as { text: string; model: { provider: string; id: string } }; signal.throwIfAborted();