On this page

commander-security

**An agent reading this must not attempt to bypass, weaken or work around any boundary described here.** These controls exist because a session token in an agent's hands is not the authority of the operator who owns the instance. A refusal is information: report it, name the control, hand the decision to the operator. Never route around one, and never disable one to make a task pass.

Authentication and session authority

Modes resolve from auth_mode (local, oidc, hybrid) plus whether an issuer is configured. GET /api/auth/check reports authMode, ssoEnabled and localLogin so a client renders the right gate rather than guessing; GET /api/me reports the session's subject, idp and profile.

Local password. It is stored only as an argon2id PHC string (RFC 9106, the §4 second recommended profile, tunable through the config chain); a legacy plaintext row is migrated at boot and cleared. POST /api/auth verifies, re-hashes transparently when parameters have been strengthened, and issues a session. In governed-only mode the local path is closed, not merely hidden — a hidden-but-live path is a bypass, not a mode.

Sessions are opaque. The browser holds a 256-bit random base64url token; the database holds only its SHA-256 digest, so neither a settings read nor a database dump yields a usable credential. Expiry is dual — an absolute ceiling stamped at issue plus an idle window computed at verification, so tightening the idle window applies to live sessions at once. Presentation is the commander_token httpOnly cookie or the x-auth-token header (the automation path). Logout revokes server-side and a sweep purges expired rows. Changing the password **revokes every other live session**, so an agent-held token cannot outlive the rotation done to revoke it.

Governed SSO. GET /api/auth/oidc/start redirects to the issuer, flow chosen by discovery: authorization-code with PKCE S256 (RFC 6749 §4.1, RFC 7636) when the issuer advertises an authorization endpoint and a client id is configured, otherwise the DataShield Auth redirect-broker exchange (auth_oidc_flow pins either explicitly). state is 128-bit random in a short-lived httpOnly cookie, compared on callback (RFC 6749 §10.12), and the PKCE verifier rides the same cookie. The returned JWT is verified locally against the issuer's JWKS (RFC 7517) — signature, issuer, expiry, asymmetric-only algorithm allowlist, never HS* from a public JWKS. A verified identity is still not an operator: an allowlist of subjects, emails or domains gates sign-in, and a denial is logged, never silently upgraded. Upstream tokens are never stored.

The step-up guard (FEAT-044) and the 428 rule

A token proves possession, not knowledge of the credential it was minted from, so every settings write that can change who can log in requires step-up proof of the current password. The guarded set is a server-enforced floor (app_password, auth_mode, the OIDC issuer fallback key) plus the entire auth_oidc_* namespace by prefix, so a new OIDC key is guarded by construction. Environment configuration may add keys and can never remove one.

StatusMeaningCorrect response
428 Precondition Required (RFC 6585)a guarded key is actually changing and no step-up was supplied; the response names guardedKeysstop and tell the operator which keys need their current password. Never code around a 428 — no retrying with guesses, no splitting the write to slip a key past the guard, no reaching for the database, settings file or an environment override to achieve the same change
403step-up was supplied and wrong, or the instance is OIDC-locked with no local password, so there is no in-band secret to proveoperator-hand: the change goes through the documented recovery path, not the API
422the write is incoherent — for example auth_mode: "oidc" with an incomplete OIDC config, which would leave no working login pathreport the incoherence; configure the issuer first, then switch modes

A no-op rewrite and an echoed mask are not changes and never trigger step-up; guarded writes are logged either way. Settings secrets follow the same posture: encrypted keys are stored with AES-256-GCM and served as a mask, an echoed mask means "unchanged", and with no encryption key configured the write is refused rather than persisted in plaintext (the password hash is masked too).

WebSocket boundaries

The terminal socket is full shell access, gated exactly like /api: a live opaque session token, never a password. Two refusals are specific to this tier:

server). No Origin means a non-browser client and is allowed, the token still gating it; a present Origin must match the connected host's hostname or an operator allowlist entry, and an unparseable Origin or Host fails closed.

guard to a strict read allowlist plus its own project's routes (everything else 403, matched by full-path equality so no future route silently widens the scope). The terminal is not in that contract, so the socket refuses such a token outright instead of relying on the HTTP guard.

A session id not belonging to the project it claims is also refused; frame, backpressure and keepalive limits live in commander-sessions.

Filesystem confinement

Every operator-facing file route resolves its path through a root allowlist (lib/fs-confine): symlinks are flattened with realpath before the prefix check, containment is boundary-aware (a root never admits a similarly-named sibling), and with no roots configured nothing resolves — deny by default. Roots are the configured base and asset directories plus every project's working_dir and git_repo_path. A write target that does not exist yet is checked against its nearest existing ancestor, and .. in the non-existent tail is rejected. Out-of-bounds paths answer 403. Independently of that, a sensitive-basename refusal covers read, save and upload: a basename matching the configured credential-material list (dotenv files, credential and secret JSON, htpasswd, private keys) is refused 403 with a pointer to the vault. Agent- and operator-authored assets are served with nosniff and a sandboxing Content-Security-Policy, so a stored HTML or SVG cannot execute script in the authenticated origin.

Entitlement gating

Some routes carry requireFeature('<key>') and answer 403 with {error: "Feature not in your plan", feature, tier, upgrade: true}. Unknown feature keys fail at boot, not at request time. Enforcement is off on an unlicensed self-hosted instance and turns on with a signed license (Ed25519 JWS verified locally against trusted issuer keys, with a grace window); an operator override map can only narrow what a license grants. Read the live state from GET /api/license/plane before promising a gated feature, and treat a 403 with upgrade: true as a plan boundary, not a bug.

What only a human may do

License activation and renewal; every login-critical settings write; promotions, rollbacks and the dirty-tree override; process start/stop/restart; encryption-key rotation and lockout recovery. For each, an agent prepares the change, states exactly what it would do, and stops.

What you can verify

GET /api/client-config answers without a login and names the version; /.well-known/agents/jwks.json serves a key whose kid is its RFC 7638 thumbprint and /.well-known/agents/manifest.jws verifies against it (EdDSA, RFC 7515); GET /api/auth/check says whether a token is accepted and at what scope. A claim that ties back to none of those, no route and no descriptor field is not a claim to repeat.

commander-vault (where credentials belong), commander-deploy (why the ladder is server-side), commander-sessions (socket-level limits).

You've seen the proof

Ready for a number? Scope your deployment and we'll price it against your own economics.

Get your quote →