.qwen/design/2026-06-12-session-shell-permission-policy.md
POST /session/:id/shell executes a shell command directly through the daemon,
without an LLM tool call or the normal agent permission mediation flow. Before
this change, the endpoint was a non-strict mutation and could be reached with a
daemon token plus a session id, or on the tokenless loopback developer default.
That is too much authority for a direct shell surface. A caller should not be able to execute shell commands unless the daemon operator explicitly enables the surface and the caller proves it is attached to the target session.
qwen serve --enable-session-shell.PermissionMediator.runQwenServe resolves and trims the bearer token once. After that it computes
one effective boolean:
sessionShellCommandEnabled =
opts.enableSessionShell === true && token !== undefined;
That value is threaded into the bridge, REST app, and ACP dispatcher. Embedded
callers that invoke createServeApp directly compute token presence using a
non-empty string check so token: '' behaves like no token for both strict
mutation gating and shell capability advertisement.
The REST route uses mutate({ strict: true }). On a tokenless loopback daemon,
the strict gate returns 401 token_required before the handler runs. When a
token is configured, the handler rejects disabled shell with
session_shell_disabled, then requires X-Qwen-Client-Id, then validates the
command body, and finally delegates to the bridge.
The ACP dispatcher keeps _qwen/session/shell dispatchable for old clients, but
does not advertise it in the initialize _qwen.methods list unless the
effective policy is enabled. Disabled ACP calls return a stable
session_shell_disabled JSON-RPC error without logging the command or calling
the bridge. Enabled calls still require the connection to own the session and
must use the bridge-stamped session binding client id.
The bridge enforces the final defense-in-depth check at
executeShellCommand(): disabled, missing client id, unknown session, then
unbound client id. Only after those checks pass does it publish shell events,
execute the command, or write shell history.
REST:
401, code: token_required403, code/errorKind: session_shell_disabled403, code/errorKind: client_id_required400 invalid_client_id404 SessionNotFoundError mappingACP:
RPC.INVALID_REQUEST, data.errorKind: session_shell_disabledRPC.INVALID_REQUEST,
data.errorKind: client_id_requiredDaemonSessionClient.shellCommand() continues to work when the daemon is
explicitly enabled and authenticated because the session client carries the
session-bound client id. Bare DaemonClient.shellCommand(sessionId, command)
must pass opts.clientId, otherwise it receives client_id_required.
The implementation is covered by focused bridge, REST, ACP transport, serve boot, and command-parser tests. The highest-value checks are default-disabled behavior, tokenless strict gating, capability advertisement, ACP initialize method filtering, bridge sink enforcement, and propagation of the session-bound client id.