fix(desktop): clamp internal agent access for every backend

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-31 14:20:28 -07:00
parent ec4c3ec92f
commit f45e8e80e2
17 changed files with 157 additions and 200 deletions

View File

@@ -586,7 +586,6 @@ pub async fn create_managed_agent(
let (requested_respond_to, respond_to_allowlist) =
crate::managed_agents::resolve_create_access(
&input.backend,
input.respond_to,
&input.respond_to_allowlist,
)?;

View File

@@ -1,24 +1,21 @@
//! Distribution policy for managed-agent inbound author access.
use super::{
validate_respond_to_allowlist, AgentDefinition, BackendKind, ManagedAgentRecord, RespondTo,
};
use super::{validate_respond_to_allowlist, AgentDefinition, ManagedAgentRecord, RespondTo};
/// Internal packaging sets `BUZZ_BUILD_INTERNAL`; OSS/custom builds do not.
pub(crate) fn internal_build() -> bool {
option_env!("BUZZ_DESKTOP_BUILD_INTERNAL").is_some()
}
pub(crate) fn owner_only_for_backend(backend: &BackendKind) -> bool {
owner_only_for_backend_with_policy(backend, internal_build())
pub(crate) fn owner_only() -> bool {
owner_only_with_policy(internal_build())
}
pub(crate) fn owner_only_for_backend_with_policy(backend: &BackendKind, internal: bool) -> bool {
internal && *backend == BackendKind::Local
pub(crate) fn owner_only_with_policy(internal: bool) -> bool {
internal
}
/// Normalize a persisted/projected instance. Provider instances remain
/// configurable because the internal policy applies only to local execution.
/// Normalize a persisted/projected instance for the current distribution.
pub(crate) fn normalize_managed_agent_access(record: &mut ManagedAgentRecord) -> bool {
normalize_managed_agent_access_with_policy(record, internal_build())
}
@@ -27,7 +24,7 @@ pub(crate) fn normalize_managed_agent_access_with_policy(
record: &mut ManagedAgentRecord,
internal: bool,
) -> bool {
if !owner_only_for_backend_with_policy(&record.backend, internal) {
if !owner_only_with_policy(internal) {
return false;
}
let changed =
@@ -38,8 +35,7 @@ pub(crate) fn normalize_managed_agent_access_with_policy(
}
/// Definitions are backend-neutral defaults. Internal builds store owner-only
/// defaults so every later local mint starts safe; provider instance policy is
/// still decided from its own backend at create/update/deploy time.
/// defaults so every later mint starts safe.
pub(crate) fn normalize_definition_access(record: &mut AgentDefinition) -> bool {
normalize_definition_access_with_policy(record, internal_build())
}
@@ -58,25 +54,18 @@ pub(crate) fn normalize_definition_access_with_policy(
}
pub(crate) fn resolve_create_access(
backend: &BackendKind,
requested_mode: Option<RespondTo>,
requested_allowlist: &[String],
) -> Result<(Option<RespondTo>, Vec<String>), String> {
resolve_create_access_with_policy(
backend,
requested_mode,
requested_allowlist,
internal_build(),
)
resolve_create_access_with_policy(requested_mode, requested_allowlist, internal_build())
}
pub(crate) fn resolve_create_access_with_policy(
backend: &BackendKind,
requested_mode: Option<RespondTo>,
requested_allowlist: &[String],
internal: bool,
) -> Result<(Option<RespondTo>, Vec<String>), String> {
if owner_only_for_backend_with_policy(backend, internal) {
if owner_only_with_policy(internal) {
return Ok((Some(RespondTo::OwnerOnly), Vec::new()));
}
let allowlist = validate_respond_to_allowlist(requested_allowlist)?;
@@ -107,7 +96,7 @@ pub(crate) fn apply_update_access_with_policy(
requested_allowlist: Option<&[String]>,
internal: bool,
) -> Result<(), String> {
if owner_only_for_backend_with_policy(&record.backend, internal) {
if owner_only_with_policy(internal) {
record.respond_to = RespondTo::OwnerOnly;
record.respond_to_allowlist.clear();
return Ok(());
@@ -133,6 +122,7 @@ pub(crate) fn apply_update_access_with_policy(
#[cfg(test)]
mod tests {
use super::*;
use crate::managed_agents::BackendKind;
fn record(backend: BackendKind) -> ManagedAgentRecord {
let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({
@@ -150,39 +140,58 @@ mod tests {
}
#[test]
fn internal_create_clamps_local_but_not_provider() {
let local = resolve_create_access_with_policy(
&BackendKind::Local,
Some(RespondTo::Anyone),
&["bad".into()],
true,
)
.unwrap();
assert_eq!(local, (Some(RespondTo::OwnerOnly), Vec::new()));
let provider = BackendKind::Provider {
id: "p".into(),
config: serde_json::json!({}),
};
fn internal_create_clamps_every_agent() {
let result =
resolve_create_access_with_policy(&provider, Some(RespondTo::Anyone), &[], true)
resolve_create_access_with_policy(Some(RespondTo::Anyone), &["bad".into()], true)
.unwrap();
assert_eq!(result, (Some(RespondTo::Anyone), Vec::new()));
assert_eq!(result, (Some(RespondTo::OwnerOnly), Vec::new()));
}
#[test]
fn internal_update_clamps_local_but_not_provider() {
let mut local = record(BackendKind::Local);
apply_update_access_with_policy(&mut local, None, None, true).unwrap();
assert_eq!(local.respond_to, RespondTo::OwnerOnly);
assert!(local.respond_to_allowlist.is_empty());
fn internal_update_clamps_local_and_provider() {
for (label, backend) in [
("local", BackendKind::Local),
(
"provider",
BackendKind::Provider {
id: "p".into(),
config: serde_json::json!({}),
},
),
] {
let mut record = record(backend);
apply_update_access_with_policy(&mut record, None, None, true).unwrap();
assert_eq!(
record.respond_to,
RespondTo::OwnerOnly,
"internal update did not clamp {label} agent",
);
assert!(
record.respond_to_allowlist.is_empty(),
"internal update did not clear {label} agent allowlist",
);
}
}
let mut provider = record(BackendKind::Provider {
id: "p".into(),
config: serde_json::json!({}),
});
apply_update_access_with_policy(&mut provider, None, None, true).unwrap();
assert_eq!(provider.respond_to, RespondTo::Anyone);
assert_eq!(provider.respond_to_allowlist, vec!["stale"]);
#[test]
fn internal_normalization_clamps_local_and_provider() {
for (label, backend) in [
("local", BackendKind::Local),
(
"provider",
BackendKind::Provider {
id: "p".into(),
config: serde_json::json!({}),
},
),
] {
let mut record = record(backend);
assert!(
normalize_managed_agent_access_with_policy(&mut record, true),
"internal normalization did not change {label} agent",
);
assert_eq!(record.respond_to, RespondTo::OwnerOnly);
assert!(record.respond_to_allowlist.is_empty());
}
}
}

View File

@@ -5,7 +5,7 @@ pub(crate) mod agent_snapshot;
pub(crate) mod team_snapshot;
pub(crate) use access_policy::{
apply_update_access, internal_build, normalize_definition_access,
normalize_managed_agent_access, owner_only_for_backend, resolve_create_access,
normalize_managed_agent_access, owner_only, resolve_create_access,
};
pub(crate) use agent_env::{
baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor,

View File

@@ -16,9 +16,10 @@ use crate::{
mod path;
pub(in crate::managed_agents) use path::build_augmented_path;
pub(crate) use path::compose_path_entries;
pub(crate) use path::should_skip_claude_executable;
pub(crate) use path::should_use_inherited;
pub(crate) use path::{
build_respond_to_env_with_policy, compose_path_entries, should_skip_claude_executable,
should_use_inherited, RespondToEnv,
};
mod metadata;
pub(crate) use metadata::{
@@ -33,8 +34,6 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair};
mod sweep;
pub(crate) use sweep::sweep_untracked_bundle_harnesses;
type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
mod process;
#[cfg(test)]
use process::{
@@ -297,6 +296,7 @@ pub fn build_managed_agent_summary(
.unwrap_or("")
.to_string();
let owner_only = super::owner_only();
Ok(ManagedAgentSummary {
pubkey: record.pubkey.clone(),
name: record.name.clone(),
@@ -336,12 +336,12 @@ pub fn build_managed_agent_summary(
start_on_app_launch: record.start_on_app_launch,
auto_restart_on_config_change: record.auto_restart_on_config_change,
log_path,
respond_to: if super::owner_only_for_backend(&record.backend) {
respond_to: if owner_only {
super::types::RespondTo::OwnerOnly
} else {
record.respond_to
},
respond_to_allowlist: if super::owner_only_for_backend(&record.backend) {
respond_to_allowlist: if owner_only {
Vec::new()
} else {
record.respond_to_allowlist.clone()
@@ -389,63 +389,7 @@ pub(crate) fn build_respond_to_env(
record: &ManagedAgentRecord,
owner_hex: Option<&str>,
) -> Result<RespondToEnv, String> {
build_respond_to_env_with_policy(
record,
owner_hex,
super::owner_only_for_backend(&record.backend),
)
}
fn build_respond_to_env_with_policy(
record: &ManagedAgentRecord,
owner_hex: Option<&str>,
enforced_owner_only: bool,
) -> Result<RespondToEnv, String> {
let respond_to = if enforced_owner_only {
super::types::RespondTo::OwnerOnly
} else {
record.respond_to
};
// Defensive re-validation: an on-disk record could have been hand-edited.
let normalized = if enforced_owner_only {
Vec::new()
} else {
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?
};
if respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() {
return Err(
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(),
);
}
let mut set: Vec<(&'static str, String)> = Vec::new();
let mut remove: Vec<&'static str> = Vec::new();
set.push(("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string()));
if respond_to == super::types::RespondTo::Allowlist {
set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(",")));
} else {
remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST");
}
// Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without
// it the harness can't resolve the owner, and owner-dependent gate modes
// would drop every event. Forwarding the workspace owner pubkey via
// BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records
// (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before.
if record.auth_tag.is_none() {
if let Some(owner) = owner_hex {
set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string()));
} else {
remove.push("BUZZ_ACP_AGENT_OWNER");
}
} else {
remove.push("BUZZ_ACP_AGENT_OWNER");
}
Ok((set, remove))
build_respond_to_env_with_policy(record, owner_hex, super::owner_only())
}
pub(crate) fn configure_runtime_cli(

View File

@@ -2,6 +2,51 @@
use std::path::PathBuf;
use super::super::{ManagedAgentRecord, RespondTo};
pub(crate) type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>);
pub(crate) fn build_respond_to_env_with_policy(
record: &ManagedAgentRecord,
owner_hex: Option<&str>,
enforced_owner_only: bool,
) -> Result<RespondToEnv, String> {
let respond_to = if enforced_owner_only {
RespondTo::OwnerOnly
} else {
record.respond_to
};
let normalized = if enforced_owner_only {
Vec::new()
} else {
super::super::validate_respond_to_allowlist(&record.respond_to_allowlist)?
};
if respond_to == RespondTo::Allowlist && normalized.is_empty() {
return Err(
"respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(),
);
}
let mut set = vec![("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string())];
let mut remove = Vec::new();
if respond_to == RespondTo::Allowlist {
set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(",")));
} else {
remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST");
}
if record.auth_tag.is_none() {
if let Some(owner) = owner_hex {
set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string()));
} else {
remove.push("BUZZ_ACP_AGENT_OWNER");
}
} else {
remove.push("BUZZ_ACP_AGENT_OWNER");
}
Ok((set, remove))
}
/// Return `true` when `path` is a Windows batch shim (`.cmd` or `.bat`,
/// case-insensitive) that cannot be passed directly to `CreateProcess`.
///

View File

@@ -117,7 +117,7 @@ fn unknown_command_returns_none() {
// ── build_respond_to_env tests ───────────────────────────────────────
use super::{build_respond_to_env, build_respond_to_env_with_policy};
use super::build_respond_to_env;
use crate::managed_agents::types::{ManagedAgentRecord, RespondTo};
/// Construct a minimal record fixture for env-building tests. Only the
@@ -237,24 +237,6 @@ fn build_env_anyone_omits_allowlist_var() {
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
}
#[test]
fn internal_policy_overrides_stale_anyone_record_at_runtime() {
let rec = fixture(
RespondTo::Anyone,
vec!["malformed stale allowlist".into()],
Some("tag".into()),
);
let (set, remove) = build_respond_to_env_with_policy(&rec, Some("owner"), true).unwrap();
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
assert_eq!(
set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
Some("owner-only")
);
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
}
#[test]
fn build_env_legacy_record_without_auth_tag_emits_agent_owner() {
let rec = fixture(RespondTo::OwnerOnly, vec![], None);

View File

@@ -146,7 +146,10 @@ with a TypeScript lookup table or an id comparison in a component.
shown; when it *is* remote they picked that host from the selector
themselves. Never synthesize a run location a surface doesn't have. Don't
expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI
copy.
copy. **Internal-build owner-only access is backend-independent.** When
`getAgentAccessOwnerOnly()` is true, every managed agent's access control is
locked to owner-only, including provider-backed agents. A provider backend
does not prove remote execution and must never create a policy carve-out.
## The tests that enforce this

View File

@@ -66,11 +66,7 @@ import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
import {
useAgentAccessOwnerOnlyQuery,
useBakedBuildEnvKeysQuery,
useRuntimeFileConfigQuery,
} from "../hooks";
import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { AgentHarnessField } from "./AgentHarnessField";
@@ -429,9 +425,6 @@ export function AgentDefinitionDialog({
setModel(nextPair.model);
}
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
enabled: open,
});
const localModeGate = React.useMemo(
() =>
computeLocalModeGate({
@@ -1025,7 +1018,6 @@ export function AgentDefinitionDialog({
hiddenEnvKeys={
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
}
agentAccessOwnerOnly={agentAccessOwnerOnly === true}
inheritedEnvVars={inheritedEnvVarsForAdvanced}
model={model}
modelTuningRuntimeId={runtime}

View File

@@ -63,9 +63,9 @@ import {
type RuntimeModelProviderSelection,
} from "./runtimeModelProviderSelection";
import { AgentCreationPreview } from "./AgentCreationPreview";
import { InternalAgentAccessField } from "./InternalAgentAccessField";
import type { EnvVarsValue } from "./EnvVarsEditor";
import { useRequiredCredentialState } from "./useRequiredCredentialState";
import { InternalAgentAccessField } from "./InternalAgentAccessField";
import { PersonaDropdownField } from "./PersonaDropdownField";
import {
MODEL_DISCOVERY_LOADING_VALUE,
@@ -911,7 +911,6 @@ export function AgentInstanceEditDialog({
)}
</div>
<div className="space-y-5">
{/* Agent name */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -939,18 +938,14 @@ export function AgentInstanceEditDialog({
/>
</div>
</div>
<InternalAgentAccessField
accessLocked={
agentAccessOwnerOnly === true && agent.backend.type === "local"
}
accessLocked={agentAccessOwnerOnly === true}
allowlist={respondToAllowlist}
disabled={updateMutation.isPending}
mode={respondTo}
onAllowlistChange={setRespondToAllowlist}
onModeChange={setRespondTo}
/>
{/* Provider (runtime) */}
<div className="space-y-1.5">
<label

View File

@@ -1,3 +1,4 @@
import { useAgentAccessOwnerOnlyQuery } from "../hooks";
import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/lib/cn";
import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor";
@@ -29,7 +30,6 @@ export function PersonaAdvancedFields({
requiredEnvKeys = [],
fileSatisfiedEnvKeys = [],
hiddenEnvKeys = [],
agentAccessOwnerOnly = false,
}: {
behaviorDraft: PersonaBehaviorDraft;
disabled: boolean;
@@ -50,8 +50,8 @@ export function PersonaAdvancedFields({
requiredEnvKeys?: readonly string[];
fileSatisfiedEnvKeys?: readonly string[];
hiddenEnvKeys?: readonly string[];
agentAccessOwnerOnly?: boolean;
}) {
const { data: agentAccessOwnerOnly = false } = useAgentAccessOwnerOnlyQuery();
const respondToMode = agentAccessOwnerOnly
? "owner-only"
: (behaviorDraft.respondTo ?? "owner-only");

View File

@@ -73,7 +73,7 @@ const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [
];
export const INTERNAL_AGENT_ACCESS_DISABLED_REASON =
"This build limits local agents to messages from you, so the access level cannot be changed.";
"This build limits agents to messages from you, so the access level cannot be changed.";
export function CreateAgentRespondToField({
mode,

View File

@@ -34,8 +34,7 @@ export function EditRespondToDialog({
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
enabled: open,
});
const accessLocked =
agentAccessOwnerOnly === true && agent?.backend.type === "local";
const accessLocked = agentAccessOwnerOnly === true;
const [respondTo, setRespondTo] = React.useState<RespondToMode>("owner-only");
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
[],

View File

@@ -390,23 +390,12 @@ test("internal local Welcome teammates accept enforced owner-only access", () =>
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, false), false);
});
test("provider Welcome teammates still require the lead allowlist", () => {
test("internal provider Welcome teammates accept enforced owner-only access", () => {
const teammate = makeAgent({
backend: { type: "provider", id: "remote", config: {} },
respondTo: "owner-only",
respondToAllowlist: [],
});
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, true), false);
assert.equal(
welcomeTeammateHasExpectedAccess(
{
...teammate,
respondTo: "allowlist",
respondToAllowlist: [PUB_B],
},
PUB_B,
true,
),
true,
);
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, true), true);
assert.equal(welcomeTeammateHasExpectedAccess(teammate, PUB_B, false), false);
});

View File

@@ -266,7 +266,7 @@ export function welcomeTeammateHasExpectedAccess(
leadPubkey: string,
agentAccessOwnerOnly: boolean,
) {
if (agentAccessOwnerOnly && teammate.backend.type === "local") {
if (agentAccessOwnerOnly) {
return (
teammate.respondTo === "owner-only" &&
teammate.respondToAllowlist.length === 0

View File

@@ -199,22 +199,27 @@ test("running teammates restart when their allowlist does not include the lead",
);
});
test("internal running teammates do not restart for enforced owner-only access", () => {
assert.equal(
welcomeTeammateNeedsRestart(
{
...honey,
backend: { type: "local" },
status: "running",
needsRestart: false,
respondTo: "owner-only",
respondToAllowlist: [],
},
fizz.pubkey,
true,
),
false,
);
test("internal running local and provider teammates do not restart for enforced owner-only access", () => {
for (const backend of [
{ type: "local" },
{ type: "provider", id: "remote", config: {} },
]) {
assert.equal(
welcomeTeammateNeedsRestart(
{
...honey,
backend,
status: "running",
needsRestart: false,
respondTo: "owner-only",
respondToAllowlist: [],
},
fizz.pubkey,
true,
),
false,
);
}
});
test("internal running teammates still restart for runtime changes", () => {

View File

@@ -1042,21 +1042,16 @@ export async function getRuntimeFileConfig(
/**
* Return the key names of all non-empty baked build env vars.
*
* Internal (Block) builds bake provider credentials into the binary at compile
* time. This returns the *key names only* — never the values — so dialogs can
* Internal (Block) builds bake provider credentials into the binary. This returns
* only the key names, never the values, so dialogs can
* treat them as satisfied without exposing secrets to the frontend.
*
* OSS builds return an empty array (no baked env).
*/
export async function getBakedBuildEnvKeys(): Promise<string[]> {
return invokeTauri<string[]>("get_baked_build_env_keys");
}
/** Return whether this build forces managed-agent access to owner-only. */
export async function getAgentAccessOwnerOnly(): Promise<boolean> {
return invokeTauri<boolean>("agent_access_owner_only");
}
export const getAgentAccessOwnerOnly = () =>
invokeTauri<boolean>("agent_access_owner_only");
/**
* A single baked build env entry.
*

View File

@@ -89,7 +89,7 @@ test.describe("agent definition dialog", () => {
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByTestId("new-agent-card").click();
await page.getByRole("menuitem", { name: "Create from scratch" }).click();
await page.getByRole("menuitem", { name: "Create agent" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Advanced", exact: true }).click();
@@ -102,7 +102,7 @@ test.describe("agent definition dialog", () => {
await expect(
dialog.getByTestId("agent-respond-to-disabled-reason"),
).toHaveText(
"This build limits local agents to messages from you, so the access level cannot be changed.",
"This build limits agents to messages from you, so the access level cannot be changed.",
);
});
});
@@ -136,7 +136,7 @@ test.describe("edit agent dialog", () => {
await expect(
page.getByTestId("agent-respond-to-disabled-reason"),
).toHaveText(
"This build limits local agents to messages from you, so the access level cannot be changed.",
"This build limits agents to messages from you, so the access level cannot be changed.",
);
});