Skip to content

Commit aa4fcdb

Browse files
committed
feat(token-id-capture): require a bearer token on the token read route
The read route serves a rollout's raw training tokens on the same server the harness calls to generate. Inside a trusted cluster that is acceptable. Once the harness runs in a sandbox whose only egress is that server it is not: the harness could read its own training data, or another rollout's. token_id_capture_read_token requires a bearer token on the route. Comparison is constant-time, since that token is the only thing between an untrusted harness and every rollout's training data. Left unset the route stays open and logs a warning at startup, so an existing deployment keeps working and the gap is visible rather than silent. Set it before running a harness in a sandbox. Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
1 parent 0954c70 commit aa4fcdb

4 files changed

Lines changed: 56 additions & 4 deletions

File tree

‎nemo_gym/base_responses_api_model.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
)
7070
from nemo_gym.token_id_capture import (
7171
CaptureContext,
72+
TokenIdCaptureConfig,
7273
capture_tokens,
7374
reset_token_sink,
7475
set_token_sink,
@@ -1290,7 +1291,11 @@ def install_model_call_capture(
12901291
token_store=token_store,
12911292
)
12921293
if token_store is not None:
1293-
install_token_capture_routes(app, token_store)
1294+
install_token_capture_routes(
1295+
app,
1296+
token_store,
1297+
read_token=TokenIdCaptureConfig.model_validate(global_config_dict or {}).token_id_capture_read_token,
1298+
)
12941299

12951300

12961301
# --- Run-level capture helpers (rollout-collection side) ---

‎nemo_gym/token_id_capture/config.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ class TokenIdCaptureConfig(BaseModel):
5656
# Shared fallback directory (also used by evaluation capture).
5757
model_call_capture_dir: Path | None = None
5858

59+
# Bearer token required by the token read route. The route serves a rollout's raw training
60+
# tokens on the same app the harness calls to generate, which is fine inside a trusted cluster
61+
# and not fine once the harness runs in a sandbox whose only egress is this server -- it could
62+
# read its own training data, or another rollout's. Set this before the sandbox work; when unset
63+
# the route stays open and logs a warning once.
64+
token_id_capture_read_token: str | None = None
65+
5966
@model_validator(mode="after")
6067
def _validate(self) -> "TokenIdCaptureConfig":
6168
if not self.token_id_capture_enabled:

‎nemo_gym/token_id_capture/routes.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
2525

2626
import asyncio
2727
import logging
28+
from hmac import compare_digest
2829
from typing import Any
2930

30-
from fastapi import APIRouter, HTTPException, Response
31+
from fastapi import APIRouter, Header, HTTPException, Response
3132

3233
from nemo_gym.token_id_capture.config import TokenIdCaptureConfig
3334
from nemo_gym.token_id_capture.store import TokenCaptureStore
@@ -44,19 +45,33 @@ def make_token_store(global_config_dict: Any) -> TokenCaptureStore | None:
4445
return TokenCaptureStore(config.resolved_dir())
4546

4647

47-
def install_token_capture_routes(app: Any, store: TokenCaptureStore) -> None:
48+
def install_token_capture_routes(app: Any, store: TokenCaptureStore, read_token: str | None = None) -> None:
4849
"""Register the token read route.
4950
5051
The route serves a rollout's raw training tokens on the same app the harness
5152
calls to generate. That is acceptable inside a trusted cluster; it is not
5253
acceptable once the harness runs in a sandbox whose only egress is this
5354
server, because it could read its own training data -- or another rollout's.
55+
``read_token`` requires a bearer token; when it is unset the route stays open
56+
and warns once, so existing deployments keep working and the gap is visible.
5457
"""
58+
if not read_token:
59+
logger.warning(
60+
"The token-capture read route is unauthenticated. Anything that can reach this model "
61+
"server can read captured training tokens for any rollout. Set token_id_capture_read_token "
62+
"before running an agent harness in a sandbox whose only egress is this server."
63+
)
5564

5665
router = APIRouter()
5766

5867
@router.get("/ng-capture/tokens/{rollout_id}")
59-
async def get_tokens(rollout_id: str) -> Response:
68+
async def get_tokens(rollout_id: str, authorization: str | None = Header(default=None)) -> Response:
69+
if read_token:
70+
expected = f"Bearer {read_token}"
71+
# Constant-time compare: the token is the only thing standing between an untrusted
72+
# harness and every rollout's training data.
73+
if authorization is None or not compare_digest(authorization, expected):
74+
raise HTTPException(status_code=401, detail="token capture read route requires a bearer token")
6075
try:
6176
entries = await asyncio.to_thread(store.read_entries, rollout_id)
6277
except ValueError as exc:

‎tests/unit_tests/test_token_id_capture.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,3 +656,28 @@ def test_lineage_resolves_a_tool_using_turn_echoed_in_anthropic_shape():
656656
parent = lineage.resolve(next_request)
657657
assert parent is not None and parent.call_id == "call-1"
658658
assert parent.cum_tokens == [1, 2, 3]
659+
660+
661+
def test_read_route_requires_a_token_when_one_is_configured(tmp_path):
662+
"""The route serves raw training tokens on the same app the harness calls to
663+
generate. Inside a trusted cluster that is fine; once the harness runs in a
664+
sandbox whose only egress is this server it could read its own training data,
665+
or another rollout's."""
666+
config = dict(_both_enabled(tmp_path))
667+
config["token_id_capture_read_token"] = "s3cret" # pragma: allowlist secret
668+
client = TestClient(_server(config).setup_webserver())
669+
client.post("/ng-rollout/auth0-roll0/v1/responses", json={"input": "hi"})
670+
671+
assert client.get("/ng-capture/tokens/auth0-roll0").status_code == 401
672+
assert client.get("/ng-capture/tokens/auth0-roll0", headers={"Authorization": "Bearer wrong"}).status_code == 401
673+
674+
ok = client.get("/ng-capture/tokens/auth0-roll0", headers={"Authorization": "Bearer s3cret"})
675+
assert ok.status_code == 200
676+
assert TokenEntry.model_validate_json(ok.text.splitlines()[0]).generation_token_ids == GTOKS
677+
678+
679+
def test_read_route_stays_open_when_no_token_is_configured(tmp_path):
680+
"""Existing deployments keep working; the gap is logged rather than enforced."""
681+
client = TestClient(_server(_both_enabled(tmp_path)).setup_webserver())
682+
client.post("/ng-rollout/auth1-roll0/v1/responses", json={"input": "hi"})
683+
assert client.get("/ng-capture/tokens/auth1-roll0").status_code == 200

0 commit comments

Comments
 (0)