Skip to content

Enforce profile-selected secret delivery in local and Docker runtimes - #5015

Merged
neubig merged 8 commits into
feat/agent-server-docker-runtimefrom
factory/profile-secret-boundary
Sep 13, 2026
Merged

Enforce profile-selected secret delivery in local and Docker runtimes#5015
neubig merged 8 commits into
feat/agent-server-docker-runtimefrom
factory/profile-secret-boundary

Conversation

@neubig

@neubig neubig commented Sep 12, 2026

Copy link
Copy Markdown
Member

HUMAN:


AGENT:

Why

Profile-only automation launches did not receive selected saved secrets. Docker materialization also bypassed the profile allow-list in OpenHands/OpenHands#4931. As a result, the factory embedded its gateway grants in uploaded bundles instead of receiving them through its profile.

Summary

  • Integrate Make use of microagents configurable in codeact_agent OpenHands#4931's secret_refs contract and apply one selector before local construction or Docker materialization. Stored selected names take precedence over caller-supplied aliases; excluded lookups never run.
  • Supply only selected conversation secrets to scoped shell commands and mask stdout/stderr before publishing or persisting them, including values split across reads. Unscoped shell behavior is unchanged.
  • Keep user secret values out of profile definitions and system prompts. Existing registry metadata advertises only selected names.

REST API contract changes

Compared with base OpenAPI 1f494dfac753 for public /api/** paths.

--- base public OpenAPI
+++ head public OpenAPI
@@ -958,0 +959 @@
+schema AgentProfileDiagnostics property secret_refs optional schema=anyOf=[type="array" items=type="string",type="null"]

Issue Number

Fixes OpenHands/OpenHands#5014. Integrates OpenHands/OpenHands#4931 (#5030).

How to Test

uv run --frozen --group dev pytest tests/agent_server/docker_runtime/test_mediation.py tests/agent_server/test_agent_profile_conv_start.py tests/sdk/profiles/test_secret_refs.py tests/agent_server/test_bash_service.py tests/agent_server/test_runtime_router.py -q

The local/Docker live probe used the public AsyncAgentServerClient to create a profile-only conversation and execute the same environment assertion in each runtime. Both exited zero, exposed only SELECTED_TOKEN, excluded UNRELATED_TOKEN, masked printed values, retained profile provenance, and persisted encrypted selected values. No real credentials were used. Commands, resource limits, and evidence are in .pr/profile-secret-boundary.md and .pr/profile-secret-live.json.

Video/Screenshots

Machine-readable live evidence: .pr/profile-secret-live.json; both runtimes report PROFILE_SECRET_BOUNDARY_PASS.

Type

  • Bug fix
  • Feature

Notes

Stacked on OpenHands/OpenHands#5008. Review OpenHands/OpenHands#4931's profile selection first, then OpenHands/OpenHands#4966 -> OpenHands/OpenHands#3403 -> OpenHands/OpenHands#5008 -> this integration. This branch merges the existing OpenHands/OpenHands#4931 rather than reimplementing its schema. The integration's own changes are separate commits. Companion documentation: OpenHands/docs#793.

Factory consumption is in OpenHands/extensions#562. Upstream GitHub credentials remain in the trusted gateway; profiles carry only their role-specific gateway grant. Token permissions must be restricted at issuance independently of secret selection. Local workspaces share their host and are not security sandboxes.

Current validation

Final focused validation: 88 tests passed. Both real local/Docker environment probes passed, and the disposable probe servers/container were stopped. Native stack OpenHands/OpenHands#4969 includes this PR after OpenHands/OpenHands#5008.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:e747da2-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-e747da2-python \
  ghcr.io/openhands/agent-server:e747da2-python

All tags pushed for this build

ghcr.io/openhands/agent-server:e747da2-golang-amd64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-golang-amd64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-golang-amd64
ghcr.io/openhands/agent-server:e747da2-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:e747da2-golang-arm64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-golang-arm64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-golang-arm64
ghcr.io/openhands/agent-server:e747da2-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:e747da2-java-amd64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-java-amd64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-java-amd64
ghcr.io/openhands/agent-server:e747da2-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:e747da2-java-arm64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-java-arm64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-java-arm64
ghcr.io/openhands/agent-server:e747da2-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:e747da2-python-amd64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-python-amd64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-python-amd64
ghcr.io/openhands/agent-server:e747da2-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:e747da2-python-arm64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-python-arm64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-python-arm64
ghcr.io/openhands/agent-server:e747da2-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:e747da2-python-slim-amd64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-python-slim-amd64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-python-slim-amd64
ghcr.io/openhands/agent-server:e747da2-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-amd64
ghcr.io/openhands/agent-server:e747da2-python-slim-arm64
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-python-slim-arm64
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-python-slim-arm64
ghcr.io/openhands/agent-server:e747da2-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-arm64
ghcr.io/openhands/agent-server:e747da2-golang
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-golang
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-golang
ghcr.io/openhands/agent-server:e747da2-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:e747da2-java
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-java
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-java
ghcr.io/openhands/agent-server:e747da2-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:e747da2-python-slim
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-python-slim
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-python-slim
ghcr.io/openhands/agent-server:e747da2-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim
ghcr.io/openhands/agent-server:e747da2-python
ghcr.io/openhands/agent-server:e747da213909b9ac19a94ac4cb31b8b5f8d1881a-python
ghcr.io/openhands/agent-server:factory-profile-secret-boundary-python
ghcr.io/openhands/agent-server:e747da2-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., e747da2-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., e747da2-python-amd64) are also available if needed

simonrosenberg and others added 2 commits September 9, 2026 19:16
An agent profile could not restrict its conversations' secrets: a
"Code Exploration" agent that should only read a repo still received the
deploy tokens and the production database URL, and the same profile driving
an automation handed that set to anyone who could reach the bot.

secret_refs holds names only — values live in the secrets store and reach a
conversation as LookupSecrets — so the profile stays secret-free at rest, the
same way mcp_server_refs references MCP servers. Tri-state matches it too:
null = all, [] = none, a list = those names.

Enforced in start_conversation rather than client-side, so a caller sending
more secrets than the profile allows cannot widen the agent's scope. An ACP
profile always additionally receives its own provider credentials, derived
from ACP_PROVIDERS: they travel the same channel as the user's saved secrets,
and filtering them out would leave the subprocess unable to authenticate.

No equivalent carve-out exists for the OpenHands variant, and a test pins
that: every other credential it needs rides a channel secret_refs never sees.

Fixes #17236

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ACP provider-credential union made the stored list mean something other
than what it said. The credential is an ordinary saved secret — the ACP
credential form writes it through the same secrets endpoint — so it appears in
the profile editor's picker like any other. A user could clear it, save, and
the server would 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.

secret_refs is now exactly the allow-list: no derived additions, for either
agent kind. An ACP profile that omits its own credential fails to
authenticate, loudly and recoverably, which is the honest outcome of the
configuration the user saved. The editor selects those credentials by default
when scoping starts, so the common path still works and the guardrail stays
visible.

Drops allowed_secret_names entirely — with nothing derived, the allow-list is
the stored field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@neubig
neubig added this pull request to stack #4969 September 12, 2026 22:04
@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   bash_service.py2633089%94–96, 198, 200, 233, 236–238, 250–253, 264, 360, 365–366, 391–396, 464–465, 501–502, 506, 539–540
   conversation_router.py2661694%72–73, 314, 396, 442, 502, 672–675, 687–690, 730, 768
   conversation_service.py123513289%199–200, 209, 236–237, 241–242, 247, 393–394, 397–398, 414–415, 429, 595–596, 657, 739, 761, 768–769, 858, 912, 915, 940, 991–992, 999, 1031–1032, 1048, 1079, 1083, 1097, 1123–1126, 1132–1133, 1142, 1144, 1210, 1220, 1245, 1253–1254, 1258–1259, 1267, 1294, 1300, 1394, 1400, 1405, 1411, 1419–1420, 1429–1432, 1441, 1453, 1461, 1490, 1496–1497, 1500–1502, 1529, 1581, 1631–1632, 1636, 1727–1728, 1799, 1854–1856, 1858–1859, 1862–1863, 1900, 1974–1975, 2006–2008, 2011–2012, 2016–2018, 2021–2022, 2026–2028, 2031–2032, 2061, 2070, 2113, 2123–2125, 2185, 2188, 2215, 2225, 2230–2233, 2247, 2258, 2270–2271, 2303, 2399, 2456, 2514, 2529–2530, 2908, 2961, 2964
   dependencies.py42198%85
   profile_secrets.py14286%28–29
openhands-sdk/openhands/sdk/profiles
   agent_profile.py105694%319, 322, 389, 395, 400, 428
   resolver.py132398%150, 204, 465
TOTAL44397808082% 

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 6afbda621f75e60a231049455d44204a22267f3e
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/60d190e2-ca76-4403-9f22-4dd0546e578a

This comment was posted by an AI agent (OpenHands).

all-hands-bot
all-hands-bot previously approved these changes Sep 12, 2026

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Taste Rating: 🟢 Good taste

Reviewed the full diff across all 16 files. The PR enforces profile-selected secret delivery in both local and Docker runtimes, adds bash output masking for scoped shell commands, and keeps profiles secret-free at rest.

Key findings:

  • select_profile_secrets is well-designed: stored saved names take precedence over caller-supplied values, preventing alias attacks. An unscoped profile (secret_refs=None) preserves existing behavior. Backward compatible — old profiles without the field load as unrestricted.
  • The bash masking approach is thorough: mask_secrets_in_output(" ") pre-resolves all registered sources (not just command-referenced ones) so compile_stream_mask() covers values that could appear in output without being named in the command (e.g. tokens in git remote URLs). Per-stream StreamOutputMask instances correctly handle secrets split across chunk boundaries, and flush() releases held-back text at process exit.
  • _resolve_agent_from_profile return type change from 2-tuple to 3-tuple is fully propagated across all call sites and tests.
  • Docker mediation path correctly applies the same select_profile_secrets filter before serialization, and the test verifies canary values do not appear in the wire format.
  • The dependencies.py wiring (secret_registry=event_service.get_conversation().state.secret_registry) correctly passes the live registry by reference; update_secrets mutates the dict in place, so the bash service sees updates.

No material bugs, security issues, or design flaws found. The changes are well-contained: secret_refs defaults to None, the BashEventService masking only activates when a registry is wired, and the agent's TerminalTool path is unaffected.

[RISK ASSESSMENT]

  • Overall PR: 🟢 LOW
    • secret_refs defaults to None → no behavior change for existing profiles or eval runs.
    • BashEventService is the UI terminal-tab API, not the agent's TerminalTool, so benchmark performance is not affected.
    • The select_profile_secrets filter only runs on the agent_profile_id launch path, which is not the typical eval path.

VERDICT: ✅ Worth merging

KEY INSIGHT: The side-effect pattern of calling mask_secrets_in_output(" ") to pre-resolve uncached sources before compiling the stream mask is correct but indirect — a dedicated resolve_all() method would be more self-documenting, though not worth blocking on.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

@all-hands-bot
all-hands-bot dismissed their stale review September 12, 2026 22:45

Automated review used the wrong decision (APPROVED instead of COMMENT) and is dismissed. Findings are reposted as a comment.

@github-actions

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: e747da213909b9ac19a94ac4cb31b8b5f8d1881a
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/156fc059-f7d6-48d4-93b1-7eec76e61edc

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Review Summary

This PR enforces profile-selected secret delivery across local and Docker runtimes, adds output masking for scoped bash commands, and introduces the secret_refs allow-list field on agent profiles. The implementation is clean, well-tested, and the security design is sound.

Key observations

Security design — solid:

  • select_profile_secrets correctly makes stored values authoritative over caller-supplied values, preventing aliasing attacks where a caller widens the scope by supplying a different LookupSecret under an allowed name.
  • The secret_refs field is a strict allow-list with no implicit additions — an ACP profile that omits its provider credential simply doesn'''t receive it, which is the right failure mode (loud, recoverable).
  • Output masking via StreamOutputMask correctly handles secrets split across read boundaries, and the mask_secrets_in_output(" ") pre-resolution ensures all secret values are cached before the stream masks are compiled.
  • Proper serialize_secret() usage on StaticSecret — no hand-rolled redaction.
  • Secret values never appear in profile definitions or system prompts; only names are advertised.

Backward compatibility — fine:

  • secret_refs is additive with default=None; old profiles load unchanged (verified by test_a_profile_without_the_key_loads_unrestricted).
  • No schema version bump needed for an optional additive field.
  • Non-profile conversations (agent_profile_id is None) are completely unaffected — select_profile_secrets is only called inside the profile branch.
  • TypeScript client marks the field optional (?).

Tests — thorough and real:

  • test_scoped_bash_injects_registry_and_masks_split_output runs a real subprocess, verifies env injection, split-output masking, stderr masking, and persistence safety (no secret in event files).
  • test_profile_secret_scope_precedes_docker_lookup exercises the full Docker mediation path with real profile resolution, serialization, and prompt rendering.
  • test_start_conversation_drops_secrets_the_profile_disallows verifies the server-side filter on the local path.

Eval-risk flag

This PR modifies bash service output handling (secret masking) and environment variable injection from the secret registry. While the no-secrets path is functionally unchanged (StreamOutputMask(None, 0) is a no-op), this touches terminal/stdout handling which could plausibly affect benchmark/evaluation performance. No eval monitor link is provided in the PR description.

Per the repo'''s review policy, I'''m leaving a COMMENT rather than APPROVE. A human maintainer should decide whether lightweight evals are needed to confirm no regression in benchmark scenarios where secrets are configured.

Risk Assessment

LOW — No material bugs or security issues found. The design is principled and the implementation is well-tested. The only flag is the eval-risk consideration for bash output handling changes.

[RISK ASSESSMENT]

  • [Overall PR] Risk Assessment: LOW
    The code is correct and the security model is sound. The eval-risk flag is procedural (bash/terminal output handling is in the eval-sensitive category) rather than indicating a concrete problem.

VERDICT: No material findings. Code is ready for merge pending human maintainer eval decision.

KEY INSIGHT: The two-function split (filter_profile_secrets in the SDK as a pure utility, select_profile_secrets in the agent-server as a store-resolving enforcer) cleanly separates the concerns of client-side filtering from server-side enforcement.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing. See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.
    Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it'''s merge-ready.
    Was this review helpful? React with thumbs up or thumbs down to give feedback.

@neubig
neubig removed this pull request from stack #4969 September 13, 2026 00:16
@neubig
neubig force-pushed the factory/profile-secret-boundary branch from e747da2 to 379d41a Compare September 13, 2026 00:16
Base automatically changed from feat/runtime-session-credential to feat/agent-server-docker-runtime September 13, 2026 00:16
@neubig
neubig merged commit 994f319 into feat/agent-server-docker-runtime Sep 13, 2026
@neubig
neubig deleted the factory/profile-secret-boundary branch September 13, 2026 00:16
@neubig
neubig restored the factory/profile-secret-boundary branch September 13, 2026 00:17
@neubig

neubig commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Consolidated during the factory stack audit (#5016). GitHub automatically marked this PR merged into a feature branch when its commits became ancestors; this was not a merge to main. Current native stack OpenHands/OpenHands#5018 is OpenHands/OpenHands#4966 (shared API/lifecycle contracts) -> OpenHands/OpenHands#5017 (shared profile-secret enforcement, also using OpenHands/OpenHands#4931) -> OpenHands/OpenHands#3403 (final Docker implementation, including Docker lifecycle/release/credential handoff). Please review that stack; this PR is no longer a separate merge prerequisite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enforce profile-selected secrets before Docker runtime materialization

4 participants