Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Unified patch context lines intentionally contain one space.
runtime/*.patch -whitespace
176 changes: 176 additions & 0 deletions docs/agent-protection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# 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.
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.
17 changes: 15 additions & 2 deletions scripts/build-goose-acp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
14 changes: 14 additions & 0 deletions src/bundled/goose/goose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
33 changes: 29 additions & 4 deletions src/bundled/goose/goose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | null>>;
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) {
Expand Down
1 change: 1 addition & 0 deletions src/bundled/goose/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 26 additions & 1 deletion src/bundled/goose/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -66,6 +89,8 @@ export const apply: PluginModule["apply"] = (ctx) => {

agents.register<Config>({
id: TYPE_ID,
retire: (pubkey) => runtime.retire(pubkey),
protection: true,
title: "Goose",
icon: GooseLogoIcon,
description:
Expand Down
Loading
Loading