TnsAI
Security

Server Hardening

tnsai-server exposes the framework over HTTP + WebSocket. Every input — HTTP body, WS frame, query param — is potentially attacker-controlled if the listener is reachable. The hardening surface in com.tnsai.server.security (TNS-302) is built on five concentric defences, every one of which is

enabled by default:

  1. Bind policy — listener binds to loopback unless explicitly opted out.
  2. Bearer auth — every HTTP request and WS upgrade requires a token.
  3. Origin allowlist — the WS upgrade rejects unrecognised browser origins.
  4. Per-session capability tokens — only the client that created a session can drive it.
  5. Workspace allowlist/api/index accepts only paths under the configured workspace roots, with a file-count cap.

The five layers are independent. A misconfiguration of any one (e.g. you forget to set a Bearer token) does not collapse the others.

Bind policy

Default: 127.0.0.1 (loopback). Opt in to a non-loopback bind explicitly:

# Loopback (default — no flag)
java -jar tnsai-server.jar

# Bind to all interfaces
java -jar tnsai-server.jar --host 0.0.0.0 --allow-public

# Bind to a specific interface
java -jar tnsai-server.jar --host 192.168.1.10 --allow-public
SourceVariable / flag
CLI--host <addr> / --allow-public
EnvTNSAI_HOST=<addr> / TNSAI_ALLOW_PUBLIC=true

Without --allow-public, a non-loopback host crashes the JVM at startup with IllegalStateException. This is intentional — the default refuses to make anything reachable beyond the host machine.

A non-loopback bind also requires a Bearer token (next section); starting with --allow-public and no TNSAI_TOKEN is rejected.

Bearer authentication

Every HTTP request and every WS upgrade must carry Authorization: Bearer <TNSAI_TOKEN> when a token is configured. Missing or mismatched tokens return 401. Health probes (/health, /health/live, /health/ready) are explicitly exempt so orchestrators can probe without a secret.

TNSAI_TOKEN=$(openssl rand -hex 32) \
  java -jar tnsai-server.jar

When TNSAI_TOKEN is unset, auth is disabled — but only valid combined with the loopback bind. The constructor of TnsServer rejects the non-loopback + no-token combination.

Token comparison is constant-time over equal-length values. Length itself is not secret, so length-mismatch shortcuts; value-byte timing is the part that matters.

Origin allowlist (WebSocket)

Browsers attach Origin: <scheme://host[:port]> on every cross-origin WS handshake. The default OriginPolicy.loopback() admits:

  • Missing or null Origin (native non-browser clients).
  • http://localhost[:port], https://localhost[:port], and the 127.0.0.1 / ::1 variants.

Any other Origin gets 403 FORBIDDEN_ORIGIN at upgrade time. Add explicit production origins via:

TNSAI_ALLOWED_ORIGINS="https://app.tnsai.dev,https://staging.tnsai.dev" \
  java -jar tnsai-server.jar

The matcher is case-insensitive and not prefix-greedy: localhost.evil.com is rejected even though it has localhost as a substring.

Per-session capability tokens

The first request to touch a sessionId mints a random 32-byte URL-safe token. The server returns it via the X-Session-Capability response header (HTTP) or via the WS upgrade response. Every subsequent request with the same sessionId must present the same token; otherwise 403 FORBIDDEN_SESSION.

This is the layer that prevents one client on the same listener from attaching to another client's session and reading their tool outputs.

HTTP transport

# 1. First call — server mints a capability and echoes it.
curl -i -X POST http://127.0.0.1:7777/api/index \
  -H "Authorization: Bearer $TNSAI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"alice-1","path":"./project"}'
# < X-Session-Capability: AbCdEf...

# 2. Subsequent calls present that capability.
curl -X POST http://127.0.0.1:7777/api/search \
  -H "Authorization: Bearer $TNSAI_TOKEN" \
  -H "X-Session-Capability: AbCdEf..." \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"alice-1","query":"hello"}'

WebSocket transport

The capability is supplied as a query string on the upgrade URL:

ws://127.0.0.1:7777/chat?sessionId=alice-1&capability=AbCdEf...

The first WS upgrade for a sessionId mints; the response carries X-Session-Capability. Subsequent upgrades must echo the same value or get 403. After upgrade, the connection is bound to its sessionId — frames that try to drive a different sessionId on the same socket are rejected with FORBIDDEN_SESSION.

Tokens are in-memory; a server restart invalidates every capability, which matches the in-memory nature of SessionManager itself.

Workspace allowlist

/api/index accepts a directory path from the request body. Without an allowlist, that endpoint is a "give me your disk" gadget — a hostile client can POST /api/index {"path":"/"} then POST /api/search to exfiltrate contents.

The default WorkspaceConfig.cwd() allows only paths under the JVM's working directory. Configure additional roots:

# Multiple roots (PATH-separator)
TNSAI_WORKSPACE_ROOT="/srv/projects/foo:/srv/projects/bar" \
  java -jar tnsai-server.jar

# Lower the file-count cap
TNSAI_WORKSPACE_MAX_FILES=10000 \
  java -jar tnsai-server.jar

Each request path is canonicalised via Path.toRealPath() before the allowlist check. Symlinks that escape the workspace are resolved and then rejected — there is no way to sneak content in via a child of an allowed directory whose ancestor is a symlink to /etc.

/api/index also rejects 400 OUTSIDE_WORKSPACE when the directory tree exceeds maxFileCount (default 50,000). The walk short-circuits on the first count past the cap, so the rejection is constant-time in the size of the offending tree.

Programmatic configuration

If you embed TnsServer in another process, every layer is composable on the builder:

TnsServer server = TnsServer.builder()
    .port(7777)
    .bindPolicy(new BindPolicy("0.0.0.0", true))
    .authConfig(AuthConfig.withToken(System.getenv("TNSAI_TOKEN")))
    .originPolicy(OriginPolicy.with(
            "https://app.tnsai.dev",
            "https://staging.tnsai.dev"))
    .workspaceConfig(WorkspaceConfig.roots(
            Path.of("/srv/projects/foo"),
            Path.of("/srv/projects/bar")).withMaxFileCount(20_000))
    .llmClientSupplier(...)
    .build();
server.start();

Acceptance reference

The five reject paths covered by the integration test:

Reject caseStatusCode
HTTP without Bearer token401UNAUTHENTICATED
HTTP with mismatched session capability403FORBIDDEN_SESSION
/api/index with path outside workspace400OUTSIDE_WORKSPACE
WS upgrade with disallowed Origin403FORBIDDEN_ORIGIN
Non-loopback bind without --allow-publicstartup IllegalStateException

What's not in v1

  • Capability rotation — tokens live for the lifetime of the server. Rotation hooks are a follow-up issue.
  • Admin role — every capability owns its own session and only its own session. There is no /api/audit?all=true carve-out yet.
  • Token persistence — capabilities are in-memory; a server restart invalidates every session.
  • CIDR allowlist on the bind side — the policy is binary loopback / public; finer-grained network ACLs are out of scope.

See also

  • Sandbox — isolated execution primitive used by tools that agents drive after the listener has authenticated their request.
  • Approvals and Annotations — per-action approval gates, applied after auth + capability admit the request.
  • Cost GovernanceCostBudget per tenant / agent, applied after auth identifies who the request belongs to.

On this page