OpenHands / OpenHands/software-agent-sdk
[Agent Profile] Profiles can't scope which secrets an agent receives
@neubig is already working on this.
Since Sep 14, 2026.
- 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_varsinjects 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:
- The system prompt stops advertising the excluded secrets.
agent.pyfeedsstate.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. - The agent can no longer obtain them.
terminal/impl.pycallsget_secrets_as_env_vars(command), which iteratesself.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 namedGHorKEYmatches nearly any command). - The system prompt stops advertising the excluded secrets.
Acceptance Criteria
-
AgentProfileBasegainssecret_refs: list[str] | None, defaulting tonull, on both variants. - A profile with
secret_refs: nullstarts 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_refsreaches 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_refskey) 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 (
AgentProfileBaseisextra="forbid"). - Unit tests cover the tri-state, the ACP provider-credential union, and the server-side enforcement.
Out of scope
os.environinheritance into ACP subprocesses (acp_agent.pystill does a wholesaleenv.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, andsecret_refsdoes 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.