Security & Authentication

Amurg's security model places the hub as the central policy enforcement point. All authentication, authorization, and audit decisions are made server-side. The UI and runtime are not trusted for security enforcement.

User Authentication

Amurg supports two authentication providers, configured via auth.provider in the hub config:

Builtin Auth (default)

Username/password authentication with bcrypt password hashing and JWT tokens. Self-contained with no external dependencies.

Config Field Description
auth.jwt_secret HMAC-SHA256 signing key for JWT tokens (required, 32+ chars recommended)
auth.jwt_expiry Token lifetime (default: "24h")
auth.initial_admin Bootstrap admin credentials: {"username": "...", "password": "..."}
{
  "auth": {
    "provider": "builtin",
    "jwt_secret": "your-strong-random-secret-here-32-chars-min",
    "jwt_expiry": "24h",
    "initial_admin": {
      "username": "admin",
      "password": "change-me-in-production"
    }
  }
}

Clerk Auth (external)

When auth.provider is "clerk", the hub validates JWTs issued by Clerk. Users and organizations are auto-provisioned on first request via the ensureUserMiddleware. The login endpoint is disabled.

{
  "auth": {
    "provider": "clerk",
    "clerk_issuer": "https://your-app.clerk.accounts.dev",
    "clerk_secret_key": "sk_live_..."
  }
}

All authenticated API requests require a Bearer token in the Authorization header, regardless of provider.

Runtime Authentication

Runtimes authenticate using pre-shared tokens. Each runtime has a unique token configured in both the hub and runtime config files.

// Hub config: auth.runtime_tokens
{
  "auth": {
    "runtime_tokens": [
      { "runtime_id": "prod-runtime-01", "token": "rt-secret-token-1", "name": "Production" },
      { "runtime_id": "dev-runtime-01",  "token": "rt-secret-token-2", "name": "Development" }
    ],
    "runtime_token_secret": "hmac-secret-for-time-limited-tokens",
    "runtime_token_lifetime": "1h"
  }
}

The runtime sends its token in the runtime.hello message immediately after WebSocket connection. The hub validates the token and either accepts (with hello.ack) or rejects the connection. Tokens can be revoked by removing them from the hub config and restarting.

Token Refresh

When runtime_token_secret is set, the hub generates time-limited tokens and sends runtime.token_refresh messages before expiry. This avoids long-lived static tokens.

Authorization

Role-based access

Amurg has two user roles:

Role Capabilities
user Create sessions, send messages, close own sessions, view own session history, upload/download files
admin All user capabilities, plus: manage users, view all sessions, close any session, manage endpoint permissions, view audit logs, configure endpoint overrides, view all runtimes

Per-endpoint access control

Controlled by auth.default_endpoint_access:

Mode Behavior
"all" (default) All authenticated users can access all endpoints. Zero-config simplicity.
"none" Users must be explicitly granted access to each endpoint by an admin. Admins always see all endpoints.

When mode is "none", admins manage access via the permissions API:

# Grant access
curl -X POST http://localhost:8090/api/permissions \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "user-uuid", "endpoint_id": "ep-uuid"}'

# Revoke access
curl -X DELETE http://localhost:8090/api/permissions \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "user-uuid", "endpoint_id": "ep-uuid"}'

Session ownership

Sessions are bound to the creating user. Only the owner (or an admin) can view messages, close the session, or upload files. The hub enforces this on every request.

Endpoint Security Config

Every endpoint can declare a security block in the runtime configuration. This block travels from the runtime config through the protocol registration to the hub store, and is displayed in the UI. The hub can also set overrides via the admin API, which take precedence over runtime-declared config.

Field Type Description
allowed_paths string[] Filesystem paths the agent is allowed to access
denied_paths string[] Filesystem paths explicitly denied (takes precedence over allowed)
allowed_tools string[] Tools the agent is permitted to use (e.g. ["Bash", "Read", "Write"])
permission_mode string "skip" (no prompts), "strict" (always prompt), or "auto" (prompt for unknown tools)
cwd string Working directory for the agent process
env_whitelist string[] Environment variables allowed to be passed to the agent
{
  "endpoints": [
    {
      "id": "claude-code-prod",
      "profile": "claude-code",
      "name": "Claude Code (Production)",
      "security": {
        "permission_mode": "strict",
        "allowed_tools": ["Bash", "Read", "Write", "Glob", "Grep"],
        "allowed_paths": ["/home/deploy/project"],
        "denied_paths": ["/etc", "/root", "/var/lib"],
        "cwd": "/home/deploy/project",
        "env_whitelist": ["PATH", "HOME", "LANG"]
      }
    }
  ]
}

Hub Overrides

Admins can override the security config for any endpoint via PUT /api/admin/endpoints/{id}/config. Hub-side overrides take precedence over the runtime-declared config and are pushed to the runtime in real-time.

Permission Requests

When permission_mode is "strict" or "auto", agents can request user approval for tool actions at runtime. This provides fine-grained control over what tools agents use, matching the approval UX of native terminal-based agent CLIs.

How it works

  1. The agent attempts to use a tool (e.g. Bash) that requires approval.
  2. The adapter sends a permission.request through the session manager to the runtime, which forwards it to the hub.
  3. The hub records a permission.requested audit event and relays the request to the subscribed UI client via WebSocket.
  4. The UI displays an approve/deny banner with tool name, description, and an "always allow this tool" checkbox.
  5. The user clicks approve or deny. The UI sends a permission.response back through the hub to the runtime.
  6. The hub records a permission.granted or permission.denied audit event.
  7. The runtime resumes or aborts the tool execution based on the response.

Timeout behavior

The hub tracks pending permission requests with a 60-second timeout. If the user does not respond within 60 seconds, the request is automatically denied and a permission.timeout audit event is logged.

Permission modes

Mode Behavior
"skip" All tool calls are auto-approved. No permission prompts are shown. Equivalent to --dangerously-skip-permissions.
"strict" Every tool call requires explicit user approval (unless the tool is in allowed_tools or the user has checked "always allow").
"auto" Tools in allowed_tools are auto-approved; unknown tools prompt the user.

Protocol messages

The permission flow uses two WebSocket message types. See the WebSocket Protocol page for full details.

// permission.request (Runtime → Hub → Client)
{
  "type": "permission.request",
  "session_id": "session-uuid",
  "payload": {
    "request_id": "req-uuid",
    "tool": "Bash",
    "description": "Execute: rm -rf /tmp/build",
    "resource": "/tmp/build"
  }
}

// permission.response (Client → Hub → Runtime)
{
  "type": "permission.response",
  "session_id": "session-uuid",
  "payload": {
    "request_id": "req-uuid",
    "approved": true,
    "always_allow": false
  }
}

Transport Security

All connections must be encrypted in transit. The hub supports TLS natively or via a reverse proxy.

Native TLS

{
  "server": {
    "addr": ":8443",
    "tls_cert": "/etc/amurg/tls/cert.pem",
    "tls_key": "/etc/amurg/tls/key.pem"
  }
}

Behind a reverse proxy

When behind nginx or Caddy, the hub can listen on plain HTTP internally. TLS is terminated at the proxy. See the Deployment Guide for reverse proxy configuration.

Production TLS

Never expose the hub on plain HTTP in production. Runtime connections also require TLS (configure the runtime's hub URL as wss://).

CORS Configuration

Cross-Origin Resource Sharing is controlled via server.allowed_origins. The hub sets Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers on all responses and handles preflight OPTIONS requests automatically.

{
  "server": {
    "allowed_origins": ["https://amurg.example.com"]
  }
}
Value Behavior
["*"] Allow all origins (default, suitable for development only)
["https://app.example.com"] Only allow requests from specified origin(s)

Turn Gating

When session.turn_based is enabled, the hub enforces single in-flight turns per session. If a user sends a message while the agent is responding, the hub rejects it with a turn_in_progress error. This prevents message interleaving in agents that do not handle concurrent input.

{
  "session": {
    "turn_based": true
  }
}

Turn completion is determined by adapter mechanics: process exit for job/CLI profiles, response end for HTTP, or idle timeout. Agents are not required to signal "done" explicitly.

Rate Limiting

The hub applies two layers of rate limiting:

Layer Scope Config Default
Login rate limit Per-IP on /api/auth/login Hard-coded 5 req/s, burst 10
API rate limit Per-user on all authenticated endpoints rate_limit.requests_per_second, rate_limit.burst 10 req/s, burst 20

When rate limited, the hub returns HTTP 429 (Too Many Requests).

Security Middleware Stack

Every API request passes through the following middleware chain:

Request
  |
  v
[Recoverer]          -- panic recovery
  |
  v
[RealIP]             -- extract client IP from X-Forwarded-For
  |
  v
[CORS]               -- set Access-Control-* headers
  |
  v
[Auth Middleware]     -- validate Bearer JWT, extract identity
  |
  v
[EnsureUser]         -- auto-provision (Clerk only)
  |
  v
[Rate Limiter]       -- per-user token bucket
  |
  v
[Admin Middleware]    -- (admin routes only) check role=admin
  |
  v
[Handler]            -- endpoint logic

Health check endpoints (/healthz, /readyz) and /api/auth/config skip authentication middleware. The login endpoint has its own per-IP rate limiter.

Audit Logging

The hub persists audit events to the database for security monitoring and compliance. Every audit event includes an id, org_id, action, created_at timestamp, and a structured detail field containing action-specific data.

Structured Detail

The detail field is a json.RawMessage (structured JSON object, not a string). This enables filtering, dashboards, and automated analysis. The endpoint_id and org_id fields are available as top-level properties on every audit event.

Event Key Fields in Detail Description
login.success user_id, username, IP Successful user authentication
login.failed username, IP Failed authentication attempt
message.sent user_id, session_id User sent a message to a session
session.create user_id, session_id, endpoint_id New session created
session.stop user_id, session_id Session stopped by user
session.idle_close session_id Session closed due to idle timeout
runtime.connect runtime_id Runtime connected to the hub
runtime.disconnect runtime_id Runtime disconnected from the hub
turn.completed session_id, duration_ms, exit_code (optional) Agent turn completed (with performance timing)
permission.requested session_id, request_id, tool Agent requested tool permission
permission.granted session_id, request_id, tool User approved tool permission
permission.denied session_id, request_id, tool User denied tool permission
permission.timeout session_id, request_id, tool Permission request timed out (auto-denied after 60s)

Querying audit events

Use the admin API with filter parameters for action prefix, session ID, or endpoint ID:

# All permission events
curl -s "http://localhost:8090/api/admin/audit?action=permission." \
  -H "Authorization: Bearer $TOKEN" | jq .

# Events for a specific session
curl -s "http://localhost:8090/api/admin/audit?session_id=session-uuid" \
  -H "Authorization: Bearer $TOKEN" | jq .

# Events for a specific endpoint
curl -s "http://localhost:8090/api/admin/audit?endpoint_id=ep-uuid" \
  -H "Authorization: Bearer $TOKEN" | jq .

Example audit event

{
  "id": "audit-uuid",
  "org_id": "default",
  "action": "permission.granted",
  "user_id": "user-uuid",
  "session_id": "session-uuid",
  "endpoint_id": "ep-uuid",
  "detail": {
    "request_id": "req-uuid",
    "tool": "Bash",
    "description": "Execute: ls -la /home/user"
  },
  "created_at": "2024-01-15T10:31:05Z"
}