OpenHands / OpenHands/software-agent-sdk

[Agent Profile] Profiles can't scope which secrets an agent receives

Open
#5,030 3 comments 0 reactions 1 assignee View on GitHub

@neubig is already working on this.

Since Sep 14, 2026.

enhancement ready-for-dev security
Dominant language
Python
Stars
1.1k
Forks
539
Avg merge
1d 19h
Merged PRs (30d)
137

Description

Follow-up to OpenHands/OpenHands#17234, and the "secrets" half of OSS-10492's first goal: "I can create a Code Exploration Agent that has a custom prompt with access to specific tools, secrets, and LLMs."

Problem or Motivation

An agent profile cannot restrict which of the user's saved secrets its conversations receive. Every conversation gets every secret in the registry, whatever agent it was launched with.

That undercuts the point of a purpose-built profile. A "Code Exploration" agent that should only read a repo still receives the deploy tokens, the Datadog keys and the production database URL. The same profile assigned to a Slackbot automation (OSS-10492 goal 3, OSS-8613) hands that whole set to anyone who can talk to the bot.

This is not blocked on the "profiles are secret-free at rest" invariant. Secrets already reach a conversation as names, not values: canvas builds request.secrets as LookupSecret entries — {kind: "LookupSecret", url: "/api/settings/secrets/<name>", headers} (src/api/agent-server-adapter.ts, buildCustomSecrets) — and the agent-server resolves each value from its own store at spawn time. A profile field holding secret names is the same shape as the existing mcp_server_refs (names of user-configured MCP servers) and disabled_skills (names of skills). No secret value or ciphertext goes near the profile.

Prior decision, and why it doesn't settle this

Least-privilege secret injection was dropped as task 6 of OpenHands/OpenHands#15722, with this reasoning:

registry secrets are added by the user for the agent to use … no real design exists for a per-conversation allowlist that doesn't either default back to "everything" (no-op) or force users to manually curate secrets per conversation (breaks the "just ask it" UX).

That objection is about granularity, and a profile-level list is what dissolves it. The profile is the amortization point: curate once when defining the agent, inherited by every conversation and every automation launched from it. An automation especially — it has no human in the loop to curate per run.

The "defaults back to everything" half is answered by making that the deliberate default: null = all, exactly like mcp_server_refs. It is a no-op until someone builds a scoped agent on purpose, which is the same bargain tools: null strikes in OpenHands/OpenHands#17234.

Desired Behavior

Add secret_refs: list[str] | None to AgentProfileBase (both the openhands and acp variants — ACP needs it most, see below), with the tri-state mcp_server_refs already establishes:

  • null (default) — every saved secret, today's behavior, byte-identical.
  • [] — none.
  • a non-null list — only the named secrets.

Enforcement belongs server-side, in conversation_service.start_conversation: it already holds the resolved profile and request.secrets in the same scope, and already filters that dict (the managed-Codex branch does durable_secrets = dict(request.secrets) then pops a key). Filtering there means the restriction holds no matter what a client sends.

Strict: nothing is added back. An earlier draft of this issue proposed unioning an ACP profile's provider credential (ANTHROPIC_API_KEY, Codex's auth.json, …) back in, since it rides the same request.secrets channel. That was dropped: the credential is an ordinary saved secret — the ACP credential form writes it through the same PUT /api/settings/secrets — so it appears in the editor's picker like any other, and a union would let a user clear it, save, and have the server put it back, with the editor showing a state that was never true. It also injected the provider's base URL, which is optional proxy routing rather than authentication.

An ACP profile that omits its own credential therefore fails to authenticate — loudly (ACPAuthRequired, with a re-auth CTA) and recoverably. The editor keeps the common path working by selecting those credentials by default when scoping starts, and reselecting them when acp_server changes; the user can still clear them.

A ref that names no existing secret needs no "dangling" concept. Unlike mcp_server_refs, which 422s on a dangling ref, an allow-list entry naming a deleted secret is simply never matched — the correct outcome, since the secret does not exist. The editor showing the live secret list is what prevents authoring one.

Where this actually reduces exposure

state.secret_registry is the single source for both agent kinds, so narrowing it is a hard capability boundary for both. They differ only in when secrets are delivered, not in whether the boundary bites:

  • ACP: SecretRegistry.get_all_secrets_as_env_vars injects the entire registry into the subprocess eagerly at startup — its own docstring flags this ("least-privilege scoping … is deferred to #1039 task 6"). Narrowing the registry narrows that injection directly.

  • The OpenHands agent: delivery is lazy, per bash command, but the reach is the same. Two things change when the registry is filtered:

    1. The system prompt stops advertising the excluded secrets. agent.py feeds state.secret_registry.get_secret_infos() into the <CUSTOM_SECRETS> section, which lists every secret by name and description — so the agent is never told they exist.
    2. The agent can no longer obtain them. terminal/impl.py calls get_secrets_as_env_vars(command), which iterates self.secret_sources; a name outside the registry can never be exported, whatever the agent types.

    Lazy delivery is not a scope limit today: matching is key.lower() in text.lower(), a plain substring test, so the agent can pull any registry secret just by naming it (and a secret named GH or KEY matches nearly any command).

Acceptance Criteria
  • AgentProfileBase gains secret_refs: list[str] | None, defaulting to null, on both variants.
  • A profile with secret_refs: null starts conversations with exactly the secrets it does today.
  • A profile with a non-null list starts conversations with only the named secrets.
  • secret_refs: [] yields no user secrets.
  • Scoping is strict for both agent kinds: no name outside secret_refs reaches the agent.
  • The editor selects an ACP profile's provider credentials by default when scoping starts, and reselects them when the provider changes — without preventing the user from clearing them.
  • The filter is applied server-side in start_conversation, so a client sending extra secrets cannot widen the set.
  • A ref naming a non-existent secret is a no-op, not an error.
  • Existing profiles (no secret_refs key) load and launch unchanged; no schema-version bump is needed for an optional field with a default.
  • The Agent Profile editor offers a secret selector over the user's saved secrets, with the same Standard/Choose shape as the tool selector, gated on backends whose profile model accepts the field (AgentProfileBase is extra="forbid").
  • Unit tests cover the tri-state, the ACP provider-credential union, and the server-side enforcement.
Out of scope
  • os.environ inheritance into ACP subprocesses (acp_agent.py still does a wholesale env.update(os.environ); OpenHands/OpenHands#15722 task 4). That leaks platform-internal env such as K8s service-account tokens — a categorically different exposure from user-authorized registry secrets, and secret_refs does nothing about it.
  • Reference-only credential storage / delivery — the storage axis, tracked by OpenHands/software-agent-sdk#4288. This issue is the scoping axis: which subset a given agent gets.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.