qwen-code/docs/design/2026-06-12-session-shell-permission-policy.md
Dragon 067cfbba62
docs: consolidate design docs and plans under docs/ (#6417)
Design docs and implementation plans were scattered across .qwen/design,
.qwen/plans, and docs/superpowers. The .qwen/ locations are git-ignored, so
docs written there never got tracked, while docs/design already held the
richer, version-controlled set. Consolidate everything under docs/design and
docs/plans, relocate two stray root docs into docs/design, and repoint the
references left dangling by the move (moved-doc cross-links and a few source
comments).

Also update AGENTS.md and the feat-dev skill so the documented workflow writes
new design docs and plans to the tracked docs/ locations.

Co-authored-by: DragonnZhang <dragonzhang1024@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 06:05:05 +00:00

4.1 KiB

title date status
Session Shell Permission Policy 2026-06-12 implemented

Session Shell Permission Policy

Problem

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.

Goals

  • Disable direct session shell by default.
  • Require explicit operator opt-in with qwen serve --enable-session-shell.
  • Require bearer-token configuration before the opt-in becomes effective.
  • Require a client id that is registered on the addressed session.
  • Apply the same policy at the REST route, ACP HTTP dispatcher, and bridge execution sink.
  • Keep normal agent shell tool approvals and permission mediation unchanged.

Non-Goals

  • Do not route direct shell through PermissionMediator.
  • Do not change prompt submission, prompt queueing, or SDK pending prompt behavior.
  • Do not add a shell-specific rate limiter.
  • Do not add an environment-variable alias for the opt-in flag.

Design

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.

Error Contract

REST:

  • no token: 401, code: token_required
  • disabled: 403, code/errorKind: session_shell_disabled
  • missing client id: 403, code/errorKind: client_id_required
  • malformed or unbound client id: existing 400 invalid_client_id
  • unknown session: existing 404 SessionNotFoundError mapping

ACP:

  • disabled: RPC.INVALID_REQUEST, data.errorKind: session_shell_disabled
  • missing session binding client id: RPC.INVALID_REQUEST, data.errorKind: client_id_required
  • unowned session and invalid client id keep existing JSON-RPC mappings

Compatibility

DaemonSessionClient.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.

Test Coverage

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.