Studio: round 7b -- tighten device-login log filter + harden timeout kill

Two more P1 follow-ups from the round 7 reviewer pass:

1. Device-login log filter no longer leaks sensitive lines.

   The old `_safe_to_forward` used unanchored substring matches like
   `"logged in"`, so a line such as

     Not logged in: refresh_token=rt_LEAK auth.json=/home/u/.codex/auth.json

   slipped through the safe-vocabulary filter and was streamed to the
   browser. A malicious codex shim earlier on PATH can print that
   line trivially, defeating the "opaque output stays in backend
   logs" safety guarantee the route claimed.

   Round 7b fix: anchored regex set (must start with one of the
   known upstream phrases) plus an explicit blocklist for
   refresh_token / access_token / api_key / secret / auth.json / the
   codex config dir / "not logged in" / "not authenticated". A line
   that matches the blocklist is dropped regardless of which safe
   pattern would otherwise have accepted it. New tests reconstruct
   the regex set inline and assert both the leak cases drop and the
   clean upstream phrases pass.

2. `_run_cli` timeout cleanup no longer 500s on a kill race.

   `_run_cli` would call `proc.kill()` after `os.killpg(pid, SIGTERM)`
   reaped the process group. If the SIGTERM landed first, the
   subsequent `proc.kill()` raised `ProcessLookupError` and bubbled
   out of `_run_cli`, turning `/api/codex/status` into a 500 during
   a timeout race. The device-login cleanup at codex_provider.py
   already wraps the same destructive call in a try / except. Mirror
   that exception guard here so the two timeout paths behave the
   same.
This commit is contained in:
Daniel Han 2026-05-25 14:45:08 +00:00
commit dee1b68b6d
3 changed files with 144 additions and 18 deletions

View file

@ -218,7 +218,23 @@ async def _run_cli(args: list[str], *, timeout: float = 4.0) -> tuple[int, str,
proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
except Exception:
pass
proc.kill()
# proc.kill() can race with the process-group SIGTERM above:
# if the child has already been reaped between the killpg and
# this line, proc.kill() raises ProcessLookupError on POSIX
# and turns /api/codex/status into a 500 during a timeout.
# Match the broader exception guard already used in the
# device-login cleanup path.
try:
proc.kill()
except ProcessLookupError:
pass
except Exception as exc:
logger.warning(
"codex_availability.kill_failed",
args = args,
exc_type = type(exc).__name__,
error = str(exc),
)
try:
await asyncio.wait_for(proc.wait(), timeout = 1.0)
except Exception:

View file

@ -1425,26 +1425,44 @@ async def stream_codex_device_login() -> AsyncGenerator[dict[str, Any], None]:
# through Studio's authenticated SSE stream. The URL and code
# extracted above are emitted separately as `device_url` /
# `device_code` events and are not affected by this filter.
safe_log_patterns: tuple[str, ...] = (
"welcome to codex",
"initializing",
"open this",
"open:",
"open the",
"verification",
"enter this one-time code",
"enter the code",
"waiting",
"successfully logged in",
"logged in",
"signed in",
"browser opened",
"press ctrl",
# Anchored regexes so the line must START with one of the upstream
# `codex login --device-auth` phrases. A substring match like the
# old "logged in" check is too loose: a malicious shim could print
# `Not logged in: refresh_token=rt_LEAK auth.json=/home/u/.codex/`
# and the substring `logged in` would let the line through, leaking
# auth artefacts into the browser. Start anchors plus a blocklist
# of known sensitive substrings close that hole.
safe_log_res: tuple[Any, ...] = (
re.compile(r"^welcome to codex\b", re.IGNORECASE),
re.compile(r"^initializing\b", re.IGNORECASE),
re.compile(r"^open (?:this|the verification)", re.IGNORECASE),
re.compile(r"^open:\s*https?://", re.IGNORECASE),
re.compile(r"^enter (?:this one-time code|the code)\b", re.IGNORECASE),
re.compile(r"^waiting\b", re.IGNORECASE),
re.compile(r"^successfully (?:logged|signed) in\b", re.IGNORECASE),
re.compile(r"^(?:logged|signed) in\b", re.IGNORECASE),
re.compile(r"^browser opened\b", re.IGNORECASE),
re.compile(r"^press ctrl", re.IGNORECASE),
)
# Strict blocklist: any of these substrings in the line means the
# log entry contains sensitive auth state, a path under the codex
# config dir, or an explicit "not logged in" failure -- none of
# which the user-facing SSE stream should mirror, regardless of
# whether some other prefix matched.
unsafe_log_re = re.compile(
r"\bnot\s+(?:logged|signed)\s+in\b|"
r"\bnot\s+authenticated\b|"
r"refresh[_-]?token|access[_-]?token|"
r"\bapi[_-]?key\b|\bsecret\b|"
r"\bauth\.json\b|"
r"/\.codex/|\\\.codex\\",
re.IGNORECASE,
)
def _safe_to_forward(text: str) -> bool:
lowered = text.lower()
return any(pat in lowered for pat in safe_log_patterns)
if unsafe_log_re.search(text):
return False
return any(pattern.search(text) for pattern in safe_log_res)
try:
assert proc.stdout is not None

View file

@ -2027,3 +2027,95 @@ class TestDeviceUrlAllowlist:
assert not _is_allowed_device_url("not a url")
assert not _is_allowed_device_url("")
assert not _is_allowed_device_url("javascript:alert(1)")
# ── Round 7: tightened device-login log filter ──────────────────────
class TestDeviceLoginLogFilter:
"""The login-output filter must not forward sensitive lines a
malicious codex shim could print -- including 'Not logged in:'
leaks that match the old loose 'logged in' substring test, plus
refresh tokens, auth.json paths, and the codex config dir.
"""
def _safe_to_forward(self):
# _safe_to_forward is defined inside stream_codex_device_login;
# re-extracting it requires us to import it through the source
# module path. Easier: replicate the production regex set in
# the test directly so a regression in the source list is
# caught when the production source is loaded.
import importlib
mod = importlib.reload(
importlib.import_module("core.inference.codex_provider")
)
# Walk the source string to find the patterns; they live inside
# the generator. Use a stable proxy: read the regex literals.
import re
src = open(mod.__file__).read()
# Smoke check: the source has anchored regex (^) for the safe
# phrases AND an unsafe-content blocklist.
assert "safe_log_res" in src
assert "unsafe_log_re" in src
assert "not\\s+(?:logged|signed)\\s+in" in src or \
"not\\\\s+(?:logged|signed)\\\\s+in" in src
return None
def test_safe_log_source_has_anchored_patterns_and_blocklist(self):
self._safe_to_forward()
def test_blocklist_rejects_known_leaks(self):
# Reconstruct the production regex set the same way stream_codex
# _device_login does, then assert each attacker string is dropped.
import re
unsafe_log_re = re.compile(
r"\bnot\s+(?:logged|signed)\s+in\b|"
r"\bnot\s+authenticated\b|"
r"refresh[_-]?token|access[_-]?token|"
r"\bapi[_-]?key\b|\bsecret\b|"
r"\bauth\.json\b|"
r"/\.codex/|\\\.codex\\",
re.IGNORECASE,
)
for line in [
"Not logged in: refresh_token=rt_LEAK auth.json=/home/u/.codex/auth.json",
"logged in (refresh_token=abc)",
"Open this: https://auth.openai.com/codex/device but access_token=hunter2",
"Logged in - secret=hunter2",
"API_KEY=sk-x logged in",
"Reading /home/u/.codex/auth.json",
]:
assert unsafe_log_re.search(line), f"line should match unsafe: {line!r}"
def test_safe_phrases_pass_when_clean(self):
import re
safe_log_res = (
re.compile(r"^welcome to codex\b", re.IGNORECASE),
re.compile(r"^initializing\b", re.IGNORECASE),
re.compile(r"^open (?:this|the verification)", re.IGNORECASE),
re.compile(r"^open:\s*https?://", re.IGNORECASE),
re.compile(r"^enter (?:this one-time code|the code)\b", re.IGNORECASE),
re.compile(r"^waiting\b", re.IGNORECASE),
re.compile(r"^successfully (?:logged|signed) in\b", re.IGNORECASE),
re.compile(r"^(?:logged|signed) in\b", re.IGNORECASE),
re.compile(r"^browser opened\b", re.IGNORECASE),
re.compile(r"^press ctrl", re.IGNORECASE),
)
for clean in [
"Welcome to codex",
"Initializing device auth...",
"Open this URL: https://auth.openai.com/codex/device",
"Open: https://auth.openai.com/codex/device",
"Enter this one-time code:",
"Waiting for authentication...",
"Successfully logged in",
"Logged in using ChatGPT",
"Browser opened",
"Press Ctrl+C to cancel",
]:
assert any(pat.search(clean) for pat in safe_log_res), \
f"clean line should match safe: {clean!r}"