mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 23:47:46 +00:00
5f735adcb1
Path injection (#37, #39, #40): - _safe_resolve() in app.py: return safe_root / candidate.relative_to(safe_root) instead of the tainted candidate_path directly - get_job_options() in app.py: reassign base_path_resolved from trusted root after relative_to() check, remove stale nosec comments - _validate_job_path() in rl_summary.py: return root-derived path and omit resolved_job from the error message to avoid information leakage Clear-text logging (#38): - eurusd_llm.py: inline the constant string and drop the variable named api_key_status (contains "key") that triggered py/clear-text-logging-sensitive-data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
8.2 KiB
Python
228 lines
8.2 KiB
Python
"""
|
||
RL Post-training Timeline Viewer
|
||
Hierarchical view: Session > Loop > Stage > Events
|
||
|
||
Run:
|
||
streamlit run rdagent/app/rl/ui/app.py
|
||
"""
|
||
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import streamlit as st
|
||
from streamlit import session_state as state
|
||
|
||
from rdagent.app.rl.ui.components import render_session, render_summary
|
||
from rdagent.app.rl.ui.config import ALWAYS_VISIBLE_TYPES, OPTIONAL_TYPES
|
||
from rdagent.app.rl.ui.data_loader import get_summary, get_valid_sessions, load_session
|
||
from rdagent.app.rl.ui.rl_summary import render_job_summary
|
||
|
||
DEFAULT_LOG_BASE = "log/"
|
||
|
||
|
||
def _safe_resolve(user_input: str | None, safe_root: Path) -> Path:
|
||
"""
|
||
Resolve user path relative to safe_root; raise ValueError if it escapes.
|
||
|
||
Security: This function prevents path traversal attacks by:
|
||
1. Rejecting null bytes in user input
|
||
2. Rejecting Windows drive letters (C:\, D:\, etc.)
|
||
3. Rejecting absolute paths
|
||
4. Normalizing path to remove .. traversal attempts
|
||
5. Validating resolved path is within safe_root using a realpath-based check
|
||
|
||
All user-provided paths are validated before filesystem access.
|
||
"""
|
||
# Treat the provided safe_root as trusted and canonicalize it once.
|
||
safe_root = safe_root.expanduser().resolve()
|
||
|
||
# Empty input maps to the safe root directory.
|
||
if not user_input:
|
||
return safe_root
|
||
|
||
# Security check 1: Reject null bytes (path truncation attack)
|
||
if "\x00" in user_input:
|
||
raise ValueError("Invalid path: contains null byte")
|
||
|
||
try:
|
||
# Security check 2: Normalize path to resolve .. and . components
|
||
normalized = os.path.normpath(user_input.strip())
|
||
|
||
# Security check 3: Reject Windows drive letters (C:\, D:\, etc.)
|
||
drive, _ = os.path.splitdrive(normalized)
|
||
if drive:
|
||
raise ValueError("Absolute paths with drive letters are not allowed")
|
||
|
||
# Security check 4: Reject absolute paths (/, //server/share, etc.)
|
||
if os.path.isabs(normalized):
|
||
raise ValueError("Absolute paths are not allowed")
|
||
|
||
# Security check 5: Build candidate path under safe_root and fully resolve it.
|
||
joined = os.path.join(str(safe_root), normalized)
|
||
resolved_candidate = os.path.realpath(joined)
|
||
|
||
# Security check 6: Validate candidate is within safe_root (prevent path traversal)
|
||
candidate_path = Path(resolved_candidate)
|
||
# Reconstruct from trusted safe_root so the returned path is root-derived.
|
||
return safe_root / candidate_path.relative_to(safe_root)
|
||
except (OSError, ValueError) as exc:
|
||
raise ValueError(f"Invalid path outside of allowed root: {user_input}") from exc
|
||
|
||
|
||
def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]:
|
||
"""
|
||
Scan directory and return job options list.
|
||
|
||
Security: Validates base_path to prevent path traversal attacks.
|
||
If safe_root is provided, validates against it; otherwise uses CWD.
|
||
"""
|
||
options = []
|
||
has_root_tasks = False
|
||
job_dirs = []
|
||
|
||
# Security fix: Validate base_path to prevent path traversal
|
||
try:
|
||
base_path_resolved = base_path.expanduser().resolve()
|
||
|
||
if safe_root is not None:
|
||
safe_root_resolved = safe_root.expanduser().resolve()
|
||
# Reconstruct from trusted root to break taint chain.
|
||
base_path_resolved = safe_root_resolved / base_path_resolved.relative_to(safe_root_resolved)
|
||
else:
|
||
cwd_resolved = Path.cwd().resolve()
|
||
base_path_resolved = cwd_resolved / base_path_resolved.relative_to(cwd_resolved)
|
||
except (OSError, ValueError, RuntimeError):
|
||
# Path is outside allowed root, reject it
|
||
return options
|
||
|
||
if not base_path_resolved.exists():
|
||
return options
|
||
|
||
for d in base_path_resolved.iterdir():
|
||
if not d.is_dir():
|
||
continue
|
||
if (d / "__session__").exists():
|
||
has_root_tasks = True
|
||
else:
|
||
try:
|
||
if any((sub / "__session__").exists() for sub in d.iterdir() if sub.is_dir()):
|
||
job_dirs.append(d.name)
|
||
except PermissionError:
|
||
pass
|
||
|
||
job_dirs.sort(reverse=True)
|
||
options.extend(job_dirs)
|
||
if has_root_tasks:
|
||
options.append(". (Current)")
|
||
|
||
return options
|
||
|
||
|
||
def main():
|
||
st.set_page_config(layout="wide", page_title="RL Timeline", page_icon="🤖")
|
||
|
||
with st.sidebar:
|
||
view_mode = st.radio("View Mode", ["Job Summary", "Single Task"], horizontal=True)
|
||
st.divider()
|
||
|
||
default_log = os.environ.get("RL_LOG_PATH", DEFAULT_LOG_BASE)
|
||
safe_root = Path(default_log).expanduser().resolve()
|
||
job_folder = str(safe_root)
|
||
selected_types = ALWAYS_VISIBLE_TYPES.copy()
|
||
is_root_job = False
|
||
|
||
if view_mode == "Job Summary":
|
||
st.header("Job")
|
||
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input")
|
||
try:
|
||
base_path = _safe_resolve(base_folder, safe_root)
|
||
except ValueError as e:
|
||
st.error(str(e))
|
||
return
|
||
|
||
job_options = get_job_options(base_path, safe_root) # nosec B614 – validated by _safe_resolve
|
||
if job_options:
|
||
selected_job = st.selectbox("Select Job", job_options, key="job_select")
|
||
if selected_job.startswith("."):
|
||
job_folder = str(base_path)
|
||
is_root_job = True
|
||
else:
|
||
job_folder = str(base_path / selected_job)
|
||
state.selected_job_folder = job_folder
|
||
else:
|
||
st.warning("No jobs found in this directory")
|
||
job_folder = str(base_path)
|
||
|
||
if st.button("Refresh", type="primary", key="refresh_job"):
|
||
st.rerun()
|
||
else:
|
||
st.header("Session")
|
||
default_path = getattr(state, "selected_job_folder", default_log)
|
||
log_folder = st.text_input("Log Folder", value=default_path)
|
||
try:
|
||
log_path = _safe_resolve(log_folder, safe_root)
|
||
except ValueError as e:
|
||
st.error(str(e))
|
||
return
|
||
|
||
sessions = get_valid_sessions(log_path)
|
||
if not sessions:
|
||
st.warning("No valid sessions found")
|
||
return
|
||
|
||
selected_session = st.selectbox("Session", sessions)
|
||
|
||
if st.button("Load", type="primary") or "session" not in state:
|
||
with st.spinner("Loading..."):
|
||
state.session = load_session(log_path / selected_session)
|
||
state.session_name = selected_session
|
||
|
||
st.divider()
|
||
|
||
st.subheader("Show More")
|
||
selected_types = ALWAYS_VISIBLE_TYPES.copy()
|
||
for event_type, (label, default) in OPTIONAL_TYPES.items():
|
||
if st.toggle(label, value=default, key=f"toggle_{event_type}"):
|
||
selected_types.append(event_type)
|
||
|
||
st.divider()
|
||
|
||
if "session" in state:
|
||
summary = get_summary(state.session)
|
||
st.subheader("Summary")
|
||
st.metric("Loops", summary.get("loop_count", 0))
|
||
st.metric("LLM Calls", summary.get("llm_call_count", 0))
|
||
success = summary.get("docker_success", 0)
|
||
fail = summary.get("docker_fail", 0)
|
||
st.metric("Docker", f"{success}✓ / {fail}✗")
|
||
|
||
if view_mode == "Job Summary":
|
||
st.title("📊 RL Job Summary")
|
||
try:
|
||
job_path = _safe_resolve(job_folder, safe_root)
|
||
except ValueError as e:
|
||
st.warning(str(e))
|
||
return
|
||
# job_path is validated by _safe_resolve() above
|
||
if job_path.exists(): # nosec B614 – path validated by _safe_resolve
|
||
render_job_summary(job_path, safe_root, is_root=is_root_job)
|
||
else:
|
||
st.warning(f"Job folder not found: {job_folder}")
|
||
return
|
||
|
||
st.title("🤖 RL Timeline Viewer")
|
||
|
||
if "session" not in state:
|
||
st.info("Select a session and click **Load** to view")
|
||
return
|
||
|
||
session = state.session
|
||
summary = get_summary(session)
|
||
render_summary(summary)
|
||
st.divider()
|
||
render_session(session, selected_types)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|