Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions backend/run-network.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 15 additions & 1 deletion backend/tmux_hooks/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.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:
Expand All @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion backend/tmux_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down