diff --git a/backend/run-network.sh b/backend/run-network.sh index 0c9693c..a33888a 100755 --- a/backend/run-network.sh +++ b/backend/run-network.sh @@ -37,16 +37,16 @@ if [ -z "${VC_AUTH_TOKEN:-}" ]; then exit 1 fi -# --reload: hot-reload on backend code changes. With detach-on-shutdown the -# reload preserves running CLI sessions (they're rehydrated on the restart). -# --reload-dir . limits the watcher to backend/ so the session store under the -# project root (rapidly-written events.jsonl) never triggers reloads. +# NOTE: unlike the localhost run.sh, this network-exposed (0.0.0.0) mode does +# NOT enable uvicorn --reload. The autoreloader's file-watcher and child-process +# supervisor are extra attack surface that shouldn't run on a LAN-reachable +# service (and any writable-code scenario would become reload-triggered RCE). # --timeout-graceful-shutdown: long-lived SSE/poll/terminal-WS connections -# never drain on their own, so a reload (or stop) would hang on "Waiting for -# connections to close". 3s lets an in-flight request finish, then force-closes. +# never drain on their own, so a stop would hang on "Waiting for connections to +# close". 3s lets an in-flight request finish, then force-closes. exec .venv/bin/python -m uvicorn main:app \ --host 0.0.0.0 --port 8000 \ --ssl-keyfile ../frontend/.certs/dev-key.pem \ --ssl-certfile ../frontend/.certs/dev-cert.pem \ --log-level info \ - --reload --reload-dir . --timeout-graceful-shutdown 3 + --timeout-graceful-shutdown 3 diff --git a/backend/tmux_hooks/_common.py b/backend/tmux_hooks/_common.py index e7cdc36..38422c9 100644 --- a/backend/tmux_hooks/_common.py +++ b/backend/tmux_hooks/_common.py @@ -12,11 +12,25 @@ import json import os +import re import sys import time CTRL = os.environ.get("VC_CTRL", "") +# tool_use_id is interpolated into a decisions/.json path that reaches +# open()/os.remove()/os.replace(). It is normally a CLI-minted `toolu_*` token, +# but — like every other externally-derived path component in this codebase +# (session_id/handle, validated via tmux_runner._SESSION_ID_RE) — it must be +# validated so it can never escape the decisions/ dir via traversal or +# separators. Same charset/length bound as validate_session_id. +_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") + + +def safe_id(value: str) -> str: + """Return value if it is a safe single path component, else 'none'.""" + return value if value and _SAFE_ID_RE.match(value) else "none" + def read_input() -> dict: try: @@ -38,7 +52,7 @@ def append_event(event: dict) -> None: def decision_path(tool_use_id: str) -> str: - return os.path.join(CTRL, "decisions", f"{tool_use_id or 'none'}.json") + return os.path.join(CTRL, "decisions", f"{safe_id(tool_use_id)}.json") def read_mode() -> str: diff --git a/backend/tmux_runner.py b/backend/tmux_runner.py index a2b5938..2d9ed7a 100644 --- a/backend/tmux_runner.py +++ b/backend/tmux_runner.py @@ -671,7 +671,12 @@ def _write_decision(self, s: _TmuxSession, choice: str) -> bool | None: "tool_use_id": s.pending_tool_use_id}) return None allow = decision == "allow" - path = os.path.join(s.ctrl, "decisions", f"{s.pending_tool_use_id or 'none'}.json") + # pending_tool_use_id is interpolated into a filesystem path, so validate + # it as a safe single path component (matches the hook's _common.safe_id); + # fall back to 'none' rather than letting a malformed id escape decisions/. + tu_id = (s.pending_tool_use_id or "").strip() + decision_id = tu_id if _SESSION_ID_RE.match(tu_id) else "none" + path = os.path.join(s.ctrl, "decisions", f"{decision_id}.json") tmp = path + ".tmp" with open(tmp, "w") as f: json.dump({"decision": decision,