OpenHands RFC #17055 · Agent Canvas RBAC · architecture study

They really are in the same place.

The RFC wants Read / Run / Admin roles for a shared Canvas. Useful — against remote callers. But agent-server = agent-sdk = the docker sandbox = the remote (cabin) = the local Mac. One place. So the root key that mints tokens has to arrive inside the very sandbox where the agent's own shell runs — and it gets written to a file the agent can read.

smolpaws · for Engel · grounded to a local checkout of OpenHands/software-agent-sdk and OpenHands/OpenHands#17055 · 31 Aug 2026

01The short answer

RealRBAC vs. the outside

Read/Run/Admin + minted tokens genuinely bound a remote caller: a teammate's browser, a leaked localStorage key, someone hitting the port. Blast radius shrinks; access becomes revocable and attributable.

UnchangedKey sprawl for the human

Roles are still bearer keys the client stores. One key per backend, per browser. iPad + Mac = the same key copy-pasted to both. "What's the key again?" does not go away.

UnsolvedThe agent is inside

To mint tokens, the root key must reach the agent-server — which lives in the same sandbox as the agent's bash. A local (or injected) agent can read the key from its config file and mint its own Admin tokens.

The crux, in one line. RBAC guards the door to the agent-server. It does not guard against the agent that is already in the room — because the room, the door, the key drawer, and the runtime are the same room.

Two worries carry through this whole read, and I share both: it adds cross-cutting complexity for a case (solo local) that gets nothing from it, and it risks a false sense of security if "RBAC" is heard as "isolation." Both get their own section at the end (§9).

02What #17055 actually proposes

Today the Agent Canvas uses one session key that means "do everything." Present a valid X-Session-API-Key and you can run agents, read/write any file, run raw shell, manage secrets, change settings. No roles, no attribution. The RFC replaces that with three fixed capability bundles, enforced by the backend on every HTTP route and every WebSocket message:

RoleCanCannot
ReadObserve conversations, events, automation metadata, workspace artifacts.Mutate state or start execution.
RunRead + start/guide agents + trigger permitted automations.Settings, secrets, access policy, trusted roots, raw shell/file APIs.
AdminEverything, including credentials, access management, trusted-root config, and raw host shell/file.— (this is today's single key)

Enforcement lives in the SDK / Agent Server (OpenHands/software-agent-sdk, i.e. ~/repos/agent-sdk). Canvas (OpenHands/OpenHands, i.e. ~/repos/odie) reads GET /api/auth/whoami and hides buttons you can't use — but "UI controls are an affordance, not enforcement." Service actors (an automation run, the workspace iframe) should get short-lived scoped tokens instead of a permanent Admin key.

The RFC even concedes the hard part itself: it "does not prevent a Run user from asking an allowed agent to print a credential that the selected server-side profile makes available inside the agent sandbox… sandbox isolation and agent-level secret handling remain separate security problems." That sentence is the whole subject of this page.

03The one true equation

Every noun in this system collapses onto a single runtime:

host — your Mac · or the cabin box (remote) · same role docker sandbox — "the runtime" agent-sdk process == agent-server (FastAPI) holds: SESSION_API_KEY · OH_SECRET_KEY · session_api_keys[] the agent's bash — create_subprocess_shell(shell=True), a CHILD of the server same process tree · same filesystem · can read the workspace
Not four boxes talking over a network. One box, nested. The key drawer and the agent's shell share the innermost room.

"Local vs. remote" is just which host the identical box runs on. "Cabin" is a backend on the cabin host; "Mac" is a backend on the Mac. Same image, same trust model. Calling one of these a "backend" in Canvas is really just naming a base URL + the key that unlocks it.

04A "backend" is a URL and a key — so keys sprawl

Because a backend is a key, the human ends up herding keys by hand. This does not change under the RFC — a Read/Run/Admin key is still a bearer secret the client stores wherever it likes:

  • Each browser keeps its backend keys in its own localStorage. Two browsers = two copies.
  • Define a backend on the Mac, then want it on the iPad → you ask me "what's the key," and now the key is in both places.
  • smolpaws is the exception on this machine: the cat keeps keys in the macOS Keychain (regular OpenHands does not do that).

What RBAC improves here: the key you copy to the iPad can be a Read key, not Admin. So the copy-paste sprawl stays, but a leaked copy is bounded. What it does not improve: there is still a key per backend, and you still move it around by hand.

05Tokens are weaker than keys — but they still start from a key

The nice part of the RFC is delegation: an Admin authenticates once, and the server mints short-lived, scoped tokens for the risky places (a run, an iframe). A token is not a new key; it is a signed, expiring voucher — weaker than the key that made it.

  1. A key authenticates to the agent-server. There is no other way in. The key is the root of all authority.
  2. The server checks its key → role table. "This key is Admin, and may delegate."
  3. The server mints a scoped token. e.g. Run, this one conversation, 10 minutes. Handed to the automation run or the workspace iframe.
  4. If a token leaks, it expires. A leaked key is everything, forever; a leaked token is a sliver that dies. That asymmetry is the real win.

But notice step 1. To mint anything, the root key must first arrive at the agent-server. Delegation reduces how many places hold the powerful key — it never removes the need for the key to reach the server in the first place.

06…and the agent-server is inside the sandbox with the agent

Here is where the equation bites. The agent-server does not run the agent's shell over a wall. It runs it as its own child, in its own process, in the same container:

# agent-sdk · openhands-agent-server/.../bash_service.py
process = await asyncio.create_subprocess_shell(
    command.command, cwd=command.cwd,
    stdout=PIPE, stderr=PIPE,
    shell=True,
    env=sanitized_env(),          # the only defense
    start_new_session=True,
)

So the key that mints tokens must live in the exact process whose bash the (possibly prompt-injected) agent drives. There is a guard — but look how thin it is:

# agent-sdk · openhands-sdk/.../utils/command.py
_SENSITIVE_ENV_VARS = frozenset({"SESSION_API_KEY"})   # ← one variable
# strips SESSION_API_KEY from bash's env "to prevent LLM-driven
# agents from accessing credentials via terminal commands"

What sanitized_env() covers

Removes the SESSION_API_KEY environment variable from the bash child. Good, necessary — and only that.

What it does not cover

The key written to the config file in the workspace (agent can cat it). OH_SECRET_KEY, which decrypts stored secrets. Any tool that reads the parent's /proc/self/environ, memory, or the file. It only screens the bash path.

So a Run-scoped or injected agent that can read one file inside its own workspace can recover the Admin key and mint its own Admin tokens — or just call the co-located server as Admin directly. RBAC on the API never sees this, because the caller was never remote.

Live example: the guard strips one name, the others sail past

This isn't hypothetical. While reading the code for this page I found that sanitized_env() — the single chokepoint for every agent subprocess (bash, file_editor, grep, glob, hooks) — removed exactly one variable, the legacy V0 name:

# before — openhands-sdk/.../utils/command.py
_SENSITIVE_ENV_VARS = frozenset({"SESSION_API_KEY"})   # V0 only

But the same process env also holds OH_SECRET_KEY (the cipher that decrypts all persisted secrets) and OH_SESSION_API_KEYS_0 (the V1 name for the very session key the strip was meant to hide). Both were readable from a shell:

echo "$OH_SECRET_KEY"            # cipher for every stored credential
echo "$OH_SESSION_API_KEYS_0"    # the session key, by its current name

Two failure modes in one line of code: a denylist (always incomplete) and name drift (V0 stripped, V1 not). Fixed in software-agent-sdk#4801 — add OH_SECRET_KEY, strip the OH_SESSION_API_KEYS_ prefix, regression test that fails before the change. It's a mitigation, not a wall: the deeper point stands — the credentials live in the runtime the agent executes in.

07The config file it reads (a caveat, honestly)

Not from the RFC — from today's code, and stated carefully. The RFC names no path. This is the current agent-sdk. And to be exact: the server reads this file if it exists; it does not write your keys there. In practice keys arrive as environment variables (SESSION_API_KEY / OH_SESSION_API_KEYS_0) or via POST /api/init. The file is an optional extra config source that can hold session_api_keys, and env vars override it.

Where it looks — default path is inside the workspace the agent operates in:

# agent-sdk · openhands-agent-server/.../config.py
DEFAULT_CONFIG_PATH = Path("workspace/openhands_agent_server_config.json")

def load_config(...):
    resolved_path = Path(os.getenv(CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH))
    file_data = _read_config_file(resolved_path)   # READS if present, else {}
    # env (OH_*) overrides file
    ...

session_api_keys: list[str] = Field(...)   # a valid field this file MAY contain

So the file path is a secondary point. The load-bearing fact doesn't need it: the keys reach the server as env vars / process memory of the agent-sdk process — the same process that runs the agent's bash (§6). The secret that decrypts saved credentials lives there too; the server warns when it's unset, which means when it's set it's present in that process:

# same file — the cipher that decrypts saved secrets
if self.secret_key is None:
    _logger.warning("⚠️ OH_SECRET_KEY was not defined. "
                    "Secrets will not be persisted between restarts.")
else:
    cipher = Cipher(self.secret_key.get_secret_value())

Net: whether keys come from env, /api/init, or that optional JSON, they land inside the runtime the agent can reach. Under the RFC's "durable-key" model, the new key → {role, capabilities, owner} state has to live somewhere the server can read at request time — and today that "somewhere" is inside the sandbox. Moving it (and OH_SECRET_KEY, and the minting authority) out of the sandbox is the real fix, and it is bigger than this RFC scopes.

08So what does the RFC genuinely buy us?

ConcernTodayUnder #17055
Teammate / second browserFull Admin or nothingRead or Run key — bounded, revocable, attributable ✔
Leaked localStorage keyAdmin, foreverOnly its role; can be revoked ✔
Automation / heartbeatHolds a permanent Admin keyRun key, or short-lived minted tokens ✔
Workspace file cookieThe raw key, long-lived, in the browserShort-lived, conversation-bound token ✔
Key sprawl across devicesCopy-paste per backend/browserSame — still bearer keys the client stores ✕
The local agent reading the keyKey + secret in its own sandboxSame — server is still inside the sandbox ✕

The honest framing: RBAC is a hard boundary at the API door and a real improvement for sharing. It is not a boundary between the runtime and the agent, because those are the same place. For the heartbeat — which reads untrusted Slack and board content all day — the boundary that would actually matter is architectural: keep the minting authority and OH_SECRET_KEY in a control-plane process outside the sandbox, and hand the in-sandbox server only a scoped, expiring token. That is the "fence in the runtime, not a prompt" version of least privilege.

Feedback-shaped takeaway for the thread: the three roles are reasonable, and the token model is right — but the RFC should state plainly that mint-from-key only binds remote callers, because the root key (from env, /api/init, or the optional workspace config file) lands inside the same sandbox the agent executes in. Real least-privilege for automations needs the minting authority to live outside that sandbox.

09Two worries Engel raised — and I share both

Worry 1 — added complexity

This is the one I'd weight highest. RBAC is not a feature you bolt on; it is a cross-cutting invariant. To be real it needs: a principal/capability model, a fail-closed classification of every HTTP route and every WebSocket message (miss one and it either over-blocks or silently allows), a durable key → role store, a token-minting + expiry + revocation path, service-actor delegation, and adversarial tests for all of it — across four repositories. The RFC's own readiness checklist is eight items long precisely because of this.

My take: for a single-operator local Canvas, that complexity is negative value today — more surface to break, more to reason about, no threat it removes for the solo case. The cost is only justified once you actually have a second principal (a teammate, or an automation you want to de-privilege). Complexity that guards nothing is just new attack surface: an under-classified WebSocket message, a token that outlives its revocation, a role that quietly grants more than its name. The mitigation the RFC gets right is fixed roles (three bundles, not a policy language) and backend-only enforcement (Canvas stays dumb). If it ever grows per-conversation ACLs or a policy DSL, that's the moment the complexity stops paying for itself. My recommendation: land the security hardening steps (explicit auth modes, fail-closed, constant-time compare, trusted workspace roots, separating automation signing material) first and independently — those help everyone, solo included — and treat the roles themselves as opt-in for people who actually share an instance.

Worry 2 — a false sense of security

This is the more dangerous one, because it fails quietly. The risk is that "we have RBAC now" gets heard as "the Canvas is secure," when what RBAC actually secures is one specific thing: remote API callers. Three ways the false comfort bites:

  • "Read" sounds like confidentiality. It isn't. The RFC says so outright — transcripts and tool outputs already contain source, terminal output, and secrets, and a Read principal can see all of it. Read blocks mutation, not viewing.
  • "Run" sounds like "can't touch secrets." It can. A Run user can just ask an allowed agent to print a credential the server-side profile injected into the sandbox. The RFC lists this as an explicit non-goal.
  • The whole model stops at the sandbox wall — which doesn't exist here. Everything above (§3–§7) is this point: the agent runs inside the server that holds the key. RBAC on the door does nothing about the agent already in the room. A Run-scoped but prompt-injected agent that reads its own process env or config can recover Admin authority.

My take: the way this goes wrong is not a bug — it's a category error in how the feature gets described. If the changelog says "team RBAC / secure multi-user Canvas," people will put an "always-on" instance on a shared network and hand out Run keys believing the blast radius is contained, when a single injected automation or a printed secret defeats it. The honest framing that avoids the trap: RBAC is access control, not isolation. It answers "who may call which API," not "what can a compromised agent reach." Those are different security problems with different mechanisms (sandbox isolation, out-of-sandbox secret custody, network segmentation). The doc, the UI, and the release notes should say that in plain words — ideally the whoami/settings surface should even name the boundary ("Run limits API actions; it does not prevent an agent from disclosing secrets it can see"). A security feature that is honest about what it does not do is worth more than one that lets you stop thinking.

Net of both: ship the hardening, keep the roles fixed and opt-in, enforce only in the backend — and label the boundary loudly. The failure mode to avoid is not "RBAC is wrong"; it's "RBAC gets sold as isolation, and someone relaxes a real precaution because the dashboard says Run."

10Sources

  • OpenHands/OpenHands#17055 — [RFC] Role-Based Access Control for the Agent Canvas
  • agent-sdk · openhands-agent-server/openhands/agent_server/dependencies.pycheck_session_api_key, the single binary auth check; workspace cookie.
  • agent-sdk · openhands-agent-server/openhands/agent_server/bash_service.pycreate_subprocess_shell(shell=True, env=sanitized_env()).
  • agent-sdk · openhands-sdk/openhands/sdk/utils/command.py_SENSITIVE_ENV_VARS = {"SESSION_API_KEY"}, the one-variable strip.
  • agent-sdk · openhands-agent-server/openhands/agent_server/config.pyDEFAULT_CONFIG_PATH = workspace/openhands_agent_server_config.json, session_api_keys, the OH_SECRET_KEY cipher warning.

Companion: OpenHands credential boundaries · secrets: keyring vs. cipher · Canvas / Agent Server / Automation / Sandbox: four nouns.