Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -473,13 +473,104 @@ def _cleanup_inactive_browser_sessions():
|
||||
logger.warning("Error cleaning up inactive session %s: %s", task_id, e)
|
||||
|
||||
|
||||
def _reap_orphaned_browser_sessions():
|
||||
"""Scan for orphaned agent-browser daemon processes from previous runs.
|
||||
|
||||
When the Python process that created a browser session exits uncleanly
|
||||
(SIGKILL, crash, gateway restart), the in-memory ``_active_sessions``
|
||||
tracking is lost but the node + Chromium processes keep running.
|
||||
|
||||
This function scans the tmp directory for ``agent-browser-*`` socket dirs
|
||||
left behind by previous runs, reads the daemon PID files, and kills any
|
||||
daemons that are still alive but not tracked by the current process.
|
||||
|
||||
Called once on cleanup-thread startup — not every 30 seconds — to avoid
|
||||
races with sessions being actively created.
|
||||
"""
|
||||
import glob
|
||||
|
||||
tmpdir = _socket_safe_tmpdir()
|
||||
pattern = os.path.join(tmpdir, "agent-browser-h_*")
|
||||
socket_dirs = glob.glob(pattern)
|
||||
# Also pick up CDP sessions
|
||||
socket_dirs += glob.glob(os.path.join(tmpdir, "agent-browser-cdp_*"))
|
||||
|
||||
if not socket_dirs:
|
||||
return
|
||||
|
||||
# Build set of session_names currently tracked by this process
|
||||
with _cleanup_lock:
|
||||
tracked_names = {
|
||||
info.get("session_name")
|
||||
for info in _active_sessions.values()
|
||||
if info.get("session_name")
|
||||
}
|
||||
|
||||
reaped = 0
|
||||
for socket_dir in socket_dirs:
|
||||
dir_name = os.path.basename(socket_dir)
|
||||
# dir_name is "agent-browser-{session_name}"
|
||||
session_name = dir_name.removeprefix("agent-browser-")
|
||||
if not session_name:
|
||||
continue
|
||||
|
||||
# Skip sessions that we are actively tracking
|
||||
if session_name in tracked_names:
|
||||
continue
|
||||
|
||||
pid_file = os.path.join(socket_dir, f"{session_name}.pid")
|
||||
if not os.path.isfile(pid_file):
|
||||
# No PID file — just a stale dir, remove it
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
daemon_pid = int(Path(pid_file).read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
continue
|
||||
|
||||
# Check if the daemon is still alive
|
||||
try:
|
||||
os.kill(daemon_pid, 0) # signal 0 = existence check
|
||||
except ProcessLookupError:
|
||||
# Already dead, just clean up the dir
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
continue
|
||||
except PermissionError:
|
||||
# Alive but owned by someone else — leave it alone
|
||||
continue
|
||||
|
||||
# Daemon is alive and not tracked — orphan. Kill it.
|
||||
try:
|
||||
os.kill(daemon_pid, signal.SIGTERM)
|
||||
logger.info("Reaped orphaned browser daemon PID %d (session %s)",
|
||||
daemon_pid, session_name)
|
||||
reaped += 1
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
# Clean up the socket directory
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
|
||||
if reaped:
|
||||
logger.info("Reaped %d orphaned browser session(s) from previous run(s)", reaped)
|
||||
|
||||
|
||||
def _browser_cleanup_thread_worker():
|
||||
"""
|
||||
Background thread that periodically cleans up inactive browser sessions.
|
||||
|
||||
Runs every 30 seconds and checks for sessions that haven't been used
|
||||
within the BROWSER_SESSION_INACTIVITY_TIMEOUT period.
|
||||
On first run, also reaps orphaned sessions from previous process lifetimes.
|
||||
"""
|
||||
# One-time orphan reap on startup
|
||||
try:
|
||||
_reap_orphaned_browser_sessions()
|
||||
except Exception as e:
|
||||
logger.warning("Orphan reap error: %s", e)
|
||||
|
||||
while _cleanup_running:
|
||||
try:
|
||||
_cleanup_inactive_browser_sessions()
|
||||
|
||||
@@ -21,6 +21,7 @@ into the user's project directory.
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -64,23 +65,72 @@ _GIT_TIMEOUT: int = max(10, min(60, int(os.getenv("HERMES_CHECKPOINT_TIMEOUT", "
|
||||
# Max files to snapshot — skip huge directories to avoid slowdowns.
|
||||
_MAX_FILES = 50_000
|
||||
|
||||
# Valid git commit hash pattern: 4–40 hex chars (short or full SHA-1/SHA-256).
|
||||
_COMMIT_HASH_RE = re.compile(r'^[0-9a-fA-F]{4,64}$')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _validate_commit_hash(commit_hash: str) -> Optional[str]:
|
||||
"""Validate a commit hash to prevent git argument injection.
|
||||
|
||||
Returns an error string if invalid, None if valid.
|
||||
Values starting with '-' would be interpreted as git flags
|
||||
(e.g., '--patch', '-p') instead of revision specifiers.
|
||||
"""
|
||||
if not commit_hash or not commit_hash.strip():
|
||||
return "Empty commit hash"
|
||||
if commit_hash.startswith("-"):
|
||||
return f"Invalid commit hash (must not start with '-'): {commit_hash!r}"
|
||||
if not _COMMIT_HASH_RE.match(commit_hash):
|
||||
return f"Invalid commit hash (expected 4-64 hex characters): {commit_hash!r}"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_file_path(file_path: str, working_dir: str) -> Optional[str]:
|
||||
"""Validate a file path to prevent path traversal outside the working directory.
|
||||
|
||||
Returns an error string if invalid, None if valid.
|
||||
"""
|
||||
if not file_path or not file_path.strip():
|
||||
return "Empty file path"
|
||||
# Reject absolute paths — restore targets must be relative to the workdir
|
||||
if os.path.isabs(file_path):
|
||||
return f"File path must be relative, got absolute path: {file_path!r}"
|
||||
# Resolve and check containment within working_dir
|
||||
abs_workdir = _normalize_path(working_dir)
|
||||
resolved = (abs_workdir / file_path).resolve()
|
||||
try:
|
||||
resolved.relative_to(abs_workdir)
|
||||
except ValueError:
|
||||
return f"File path escapes the working directory via traversal: {file_path!r}"
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shadow repo helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_path(path_value: str) -> Path:
|
||||
"""Return a canonical absolute path for checkpoint operations."""
|
||||
return Path(path_value).expanduser().resolve()
|
||||
|
||||
|
||||
def _shadow_repo_path(working_dir: str) -> Path:
|
||||
"""Deterministic shadow repo path: sha256(abs_path)[:16]."""
|
||||
abs_path = str(Path(working_dir).resolve())
|
||||
abs_path = str(_normalize_path(working_dir))
|
||||
dir_hash = hashlib.sha256(abs_path.encode()).hexdigest()[:16]
|
||||
return CHECKPOINT_BASE / dir_hash
|
||||
|
||||
|
||||
def _git_env(shadow_repo: Path, working_dir: str) -> dict:
|
||||
"""Build env dict that redirects git to the shadow repo."""
|
||||
normalized_working_dir = _normalize_path(working_dir)
|
||||
env = os.environ.copy()
|
||||
env["GIT_DIR"] = str(shadow_repo)
|
||||
env["GIT_WORK_TREE"] = str(Path(working_dir).resolve())
|
||||
env["GIT_WORK_TREE"] = str(normalized_working_dir)
|
||||
env.pop("GIT_INDEX_FILE", None)
|
||||
env.pop("GIT_NAMESPACE", None)
|
||||
env.pop("GIT_ALTERNATE_OBJECT_DIRECTORIES", None)
|
||||
@@ -100,7 +150,17 @@ def _run_git(
|
||||
exits while preserving the normal ``ok = (returncode == 0)`` contract.
|
||||
Example: ``git diff --cached --quiet`` returns 1 when changes exist.
|
||||
"""
|
||||
env = _git_env(shadow_repo, working_dir)
|
||||
normalized_working_dir = _normalize_path(working_dir)
|
||||
if not normalized_working_dir.exists():
|
||||
msg = f"working directory not found: {normalized_working_dir}"
|
||||
logger.error("Git command skipped: %s (%s)", " ".join(["git"] + list(args)), msg)
|
||||
return False, "", msg
|
||||
if not normalized_working_dir.is_dir():
|
||||
msg = f"working directory is not a directory: {normalized_working_dir}"
|
||||
logger.error("Git command skipped: %s (%s)", " ".join(["git"] + list(args)), msg)
|
||||
return False, "", msg
|
||||
|
||||
env = _git_env(shadow_repo, str(normalized_working_dir))
|
||||
cmd = ["git"] + list(args)
|
||||
allowed_returncodes = allowed_returncodes or set()
|
||||
try:
|
||||
@@ -110,7 +170,7 @@ def _run_git(
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
cwd=str(Path(working_dir).resolve()),
|
||||
cwd=str(normalized_working_dir),
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
stdout = result.stdout.strip()
|
||||
@@ -125,9 +185,14 @@ def _run_git(
|
||||
msg = f"git timed out after {timeout}s: {' '.join(cmd)}"
|
||||
logger.error(msg, exc_info=True)
|
||||
return False, "", msg
|
||||
except FileNotFoundError:
|
||||
logger.error("Git executable not found: %s", " ".join(cmd), exc_info=True)
|
||||
return False, "", "git not found"
|
||||
except FileNotFoundError as exc:
|
||||
missing_target = getattr(exc, "filename", None)
|
||||
if missing_target == "git":
|
||||
logger.error("Git executable not found: %s", " ".join(cmd), exc_info=True)
|
||||
return False, "", "git not found"
|
||||
msg = f"working directory not found: {normalized_working_dir}"
|
||||
logger.error("Git command failed before execution: %s (%s)", " ".join(cmd), msg, exc_info=True)
|
||||
return False, "", msg
|
||||
except Exception as exc:
|
||||
logger.error("Unexpected git error running %s: %s", " ".join(cmd), exc, exc_info=True)
|
||||
return False, "", str(exc)
|
||||
@@ -154,7 +219,7 @@ def _init_shadow_repo(shadow_repo: Path, working_dir: str) -> Optional[str]:
|
||||
)
|
||||
|
||||
(shadow_repo / "HERMES_WORKDIR").write_text(
|
||||
str(Path(working_dir).resolve()) + "\n", encoding="utf-8"
|
||||
str(_normalize_path(working_dir)) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
logger.debug("Initialised checkpoint repo at %s for %s", shadow_repo, working_dir)
|
||||
@@ -229,7 +294,7 @@ class CheckpointManager:
|
||||
if not self._git_available:
|
||||
return False
|
||||
|
||||
abs_dir = str(Path(working_dir).resolve())
|
||||
abs_dir = str(_normalize_path(working_dir))
|
||||
|
||||
# Skip root, home, and other overly broad directories
|
||||
if abs_dir in ("/", str(Path.home())):
|
||||
@@ -254,7 +319,7 @@ class CheckpointManager:
|
||||
Returns a list of dicts with keys: hash, short_hash, timestamp, reason,
|
||||
files_changed, insertions, deletions. Most recent first.
|
||||
"""
|
||||
abs_dir = str(Path(working_dir).resolve())
|
||||
abs_dir = str(_normalize_path(working_dir))
|
||||
shadow = _shadow_repo_path(abs_dir)
|
||||
|
||||
if not (shadow / "HEAD").exists():
|
||||
@@ -311,7 +376,12 @@ class CheckpointManager:
|
||||
|
||||
Returns dict with success, diff text, and stat summary.
|
||||
"""
|
||||
abs_dir = str(Path(working_dir).resolve())
|
||||
# Validate commit_hash to prevent git argument injection
|
||||
hash_err = _validate_commit_hash(commit_hash)
|
||||
if hash_err:
|
||||
return {"success": False, "error": hash_err}
|
||||
|
||||
abs_dir = str(_normalize_path(working_dir))
|
||||
shadow = _shadow_repo_path(abs_dir)
|
||||
|
||||
if not (shadow / "HEAD").exists():
|
||||
@@ -364,7 +434,19 @@ class CheckpointManager:
|
||||
|
||||
Returns dict with success/error info.
|
||||
"""
|
||||
abs_dir = str(Path(working_dir).resolve())
|
||||
# Validate commit_hash to prevent git argument injection
|
||||
hash_err = _validate_commit_hash(commit_hash)
|
||||
if hash_err:
|
||||
return {"success": False, "error": hash_err}
|
||||
|
||||
abs_dir = str(_normalize_path(working_dir))
|
||||
|
||||
# Validate file_path to prevent path traversal outside the working dir
|
||||
if file_path:
|
||||
path_err = _validate_file_path(file_path, abs_dir)
|
||||
if path_err:
|
||||
return {"success": False, "error": path_err}
|
||||
|
||||
shadow = _shadow_repo_path(abs_dir)
|
||||
|
||||
if not (shadow / "HEAD").exists():
|
||||
@@ -413,7 +495,7 @@ class CheckpointManager:
|
||||
(directory containing .git, pyproject.toml, package.json, etc.).
|
||||
Falls back to the file's parent directory.
|
||||
"""
|
||||
path = Path(file_path).resolve()
|
||||
path = _normalize_path(file_path)
|
||||
if path.is_dir():
|
||||
candidate = path
|
||||
else:
|
||||
|
||||
@@ -924,8 +924,8 @@ def execute_code(
|
||||
|
||||
# --- Local execution path (UDS) --- below this line is unchanged ---
|
||||
|
||||
# Import interrupt event from terminal_tool (cooperative cancellation)
|
||||
from tools.terminal_tool import _interrupt_event
|
||||
# Import per-thread interrupt check (cooperative cancellation)
|
||||
from tools.interrupt import is_interrupted as _is_interrupted
|
||||
|
||||
# Resolve config
|
||||
_cfg = _load_config()
|
||||
@@ -1114,7 +1114,7 @@ def execute_code(
|
||||
|
||||
status = "success"
|
||||
while proc.poll() is None:
|
||||
if _interrupt_event.is_set():
|
||||
if _is_interrupted():
|
||||
_kill_process_group(proc)
|
||||
status = "interrupted"
|
||||
break
|
||||
|
||||
@@ -80,20 +80,18 @@ def register_credential_file(
|
||||
|
||||
# Resolve symlinks and normalise ``..`` before the containment check so
|
||||
# that traversal like ``../. ssh/id_rsa`` cannot escape HERMES_HOME.
|
||||
try:
|
||||
resolved = host_path.resolve()
|
||||
hermes_home_resolved = hermes_home.resolve()
|
||||
resolved.relative_to(hermes_home_resolved) # raises ValueError if outside
|
||||
except ValueError:
|
||||
from tools.path_security import validate_within_dir
|
||||
|
||||
containment_error = validate_within_dir(host_path, hermes_home)
|
||||
if containment_error:
|
||||
logger.warning(
|
||||
"credential_files: rejected path traversal %r "
|
||||
"(resolves to %s, outside HERMES_HOME %s)",
|
||||
"credential_files: rejected path traversal %r (%s)",
|
||||
relative_path,
|
||||
resolved,
|
||||
hermes_home_resolved,
|
||||
containment_error,
|
||||
)
|
||||
return False
|
||||
|
||||
resolved = host_path.resolve()
|
||||
if not resolved.is_file():
|
||||
logger.debug("credential_files: skipping %s (not found)", resolved)
|
||||
return False
|
||||
@@ -142,7 +140,8 @@ def _load_config_files() -> List[Dict[str, str]]:
|
||||
cfg = read_raw_config()
|
||||
cred_files = cfg.get("terminal", {}).get("credential_files")
|
||||
if isinstance(cred_files, list):
|
||||
hermes_home_resolved = hermes_home.resolve()
|
||||
from tools.path_security import validate_within_dir
|
||||
|
||||
for item in cred_files:
|
||||
if isinstance(item, str) and item.strip():
|
||||
rel = item.strip()
|
||||
@@ -151,20 +150,19 @@ def _load_config_files() -> List[Dict[str, str]]:
|
||||
"credential_files: rejected absolute config path %r", rel,
|
||||
)
|
||||
continue
|
||||
host_path = (hermes_home / rel).resolve()
|
||||
try:
|
||||
host_path.relative_to(hermes_home_resolved)
|
||||
except ValueError:
|
||||
host_path = hermes_home / rel
|
||||
containment_error = validate_within_dir(host_path, hermes_home)
|
||||
if containment_error:
|
||||
logger.warning(
|
||||
"credential_files: rejected config path traversal %r "
|
||||
"(resolves to %s, outside HERMES_HOME %s)",
|
||||
rel, host_path, hermes_home_resolved,
|
||||
"credential_files: rejected config path traversal %r (%s)",
|
||||
rel, containment_error,
|
||||
)
|
||||
continue
|
||||
if host_path.is_file():
|
||||
resolved_path = host_path.resolve()
|
||||
if resolved_path.is_file():
|
||||
container_path = f"/root/.hermes/{rel}"
|
||||
result.append({
|
||||
"host_path": str(host_path),
|
||||
"host_path": str(resolved_path),
|
||||
"container_path": container_path,
|
||||
})
|
||||
except Exception as e:
|
||||
|
||||
@@ -165,12 +165,12 @@ def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:
|
||||
)
|
||||
|
||||
# Validate containment after resolution
|
||||
from tools.path_security import validate_within_dir
|
||||
|
||||
scripts_dir = get_hermes_home() / "scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
resolved = (scripts_dir / raw).resolve()
|
||||
try:
|
||||
resolved.relative_to(scripts_dir.resolve())
|
||||
except ValueError:
|
||||
containment_error = validate_within_dir(scripts_dir / raw, scripts_dir)
|
||||
if containment_error:
|
||||
return (
|
||||
f"Script path escapes the scripts directory via traversal: {raw!r}"
|
||||
)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Shared interrupt signaling for all tools.
|
||||
"""Per-thread interrupt signaling for all tools.
|
||||
|
||||
Provides a global threading.Event that any tool can check to determine
|
||||
if the user has requested an interrupt. The agent's interrupt() method
|
||||
sets this event, and tools poll it during long-running operations.
|
||||
Provides thread-scoped interrupt tracking so that interrupting one agent
|
||||
session does not kill tools running in other sessions. This is critical
|
||||
in the gateway where multiple agents run concurrently in the same process.
|
||||
|
||||
The agent stores its execution thread ID at the start of run_conversation()
|
||||
and passes it to set_interrupt()/clear_interrupt(). Tools call
|
||||
is_interrupted() which checks the CURRENT thread — no argument needed.
|
||||
|
||||
Usage in tools:
|
||||
from tools.interrupt import is_interrupted
|
||||
@@ -12,17 +16,61 @@ Usage in tools:
|
||||
|
||||
import threading
|
||||
|
||||
_interrupt_event = threading.Event()
|
||||
# Set of thread idents that have been interrupted.
|
||||
_interrupted_threads: set[int] = set()
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def set_interrupt(active: bool) -> None:
|
||||
"""Called by the agent to signal or clear the interrupt."""
|
||||
if active:
|
||||
_interrupt_event.set()
|
||||
else:
|
||||
_interrupt_event.clear()
|
||||
def set_interrupt(active: bool, thread_id: int | None = None) -> None:
|
||||
"""Set or clear interrupt for a specific thread.
|
||||
|
||||
Args:
|
||||
active: True to signal interrupt, False to clear it.
|
||||
thread_id: Target thread ident. When None, targets the
|
||||
current thread (backward compat for CLI/tests).
|
||||
"""
|
||||
tid = thread_id if thread_id is not None else threading.current_thread().ident
|
||||
with _lock:
|
||||
if active:
|
||||
_interrupted_threads.add(tid)
|
||||
else:
|
||||
_interrupted_threads.discard(tid)
|
||||
|
||||
|
||||
def is_interrupted() -> bool:
|
||||
"""Check if an interrupt has been requested. Safe to call from any thread."""
|
||||
return _interrupt_event.is_set()
|
||||
"""Check if an interrupt has been requested for the current thread.
|
||||
|
||||
Safe to call from any thread — each thread only sees its own
|
||||
interrupt state.
|
||||
"""
|
||||
tid = threading.current_thread().ident
|
||||
with _lock:
|
||||
return tid in _interrupted_threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compatible _interrupt_event proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
# Some legacy call sites (code_execution_tool, process_registry, tests)
|
||||
# import _interrupt_event directly and call .is_set() / .set() / .clear().
|
||||
# This shim maps those calls to the per-thread functions above so existing
|
||||
# code keeps working while the underlying mechanism is thread-scoped.
|
||||
|
||||
class _ThreadAwareEventProxy:
|
||||
"""Drop-in proxy that maps threading.Event methods to per-thread state."""
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return is_interrupted()
|
||||
|
||||
def set(self) -> None: # noqa: A003
|
||||
set_interrupt(True)
|
||||
|
||||
def clear(self) -> None:
|
||||
set_interrupt(False)
|
||||
|
||||
def wait(self, timeout: float | None = None) -> bool:
|
||||
"""Not truly supported — returns current state immediately."""
|
||||
return self.is_set()
|
||||
|
||||
|
||||
_interrupt_event = _ThreadAwareEventProxy()
|
||||
|
||||
43
tools/path_security.py
Normal file
43
tools/path_security.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared path validation helpers for tool implementations.
|
||||
|
||||
Extracts the ``resolve() + relative_to()`` and ``..`` traversal check
|
||||
patterns previously duplicated across skill_manager_tool, skills_tool,
|
||||
skills_hub, cronjob_tools, and credential_files.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_within_dir(path: Path, root: Path) -> Optional[str]:
|
||||
"""Ensure *path* resolves to a location within *root*.
|
||||
|
||||
Returns an error message string if validation fails, or ``None`` if the
|
||||
path is safe. Uses ``Path.resolve()`` to follow symlinks and normalize
|
||||
``..`` components.
|
||||
|
||||
Usage::
|
||||
|
||||
error = validate_within_dir(user_path, allowed_root)
|
||||
if error:
|
||||
return json.dumps({"error": error})
|
||||
"""
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
root_resolved = root.resolve()
|
||||
resolved.relative_to(root_resolved)
|
||||
except (ValueError, OSError) as exc:
|
||||
return f"Path escapes allowed directory: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
def has_traversal_component(path_str: str) -> bool:
|
||||
"""Return True if *path_str* contains ``..`` traversal components.
|
||||
|
||||
Quick check for obvious traversal attempts before doing full resolution.
|
||||
"""
|
||||
parts = Path(path_str).parts
|
||||
return ".." in parts
|
||||
@@ -96,6 +96,8 @@ class ProcessSession:
|
||||
# Watcher/notification metadata (persisted for crash recovery)
|
||||
watcher_platform: str = ""
|
||||
watcher_chat_id: str = ""
|
||||
watcher_user_id: str = ""
|
||||
watcher_user_name: str = ""
|
||||
watcher_thread_id: str = ""
|
||||
watcher_interval: int = 0 # 0 = no watcher configured
|
||||
notify_on_complete: bool = False # Queue agent notification on exit
|
||||
@@ -695,7 +697,7 @@ class ProcessRegistry:
|
||||
and output snapshot.
|
||||
"""
|
||||
from tools.ansi_strip import strip_ansi
|
||||
from tools.terminal_tool import _interrupt_event
|
||||
from tools.interrupt import is_interrupted as _is_interrupted
|
||||
|
||||
try:
|
||||
default_timeout = int(os.getenv("TERMINAL_TIMEOUT", "180"))
|
||||
@@ -732,7 +734,7 @@ class ProcessRegistry:
|
||||
result["timeout_note"] = timeout_note
|
||||
return result
|
||||
|
||||
if _interrupt_event.is_set():
|
||||
if _is_interrupted():
|
||||
result = {
|
||||
"status": "interrupted",
|
||||
"output": strip_ansi(session.output_buffer[-1000:]),
|
||||
@@ -981,6 +983,8 @@ class ProcessRegistry:
|
||||
"session_key": s.session_key,
|
||||
"watcher_platform": s.watcher_platform,
|
||||
"watcher_chat_id": s.watcher_chat_id,
|
||||
"watcher_user_id": s.watcher_user_id,
|
||||
"watcher_user_name": s.watcher_user_name,
|
||||
"watcher_thread_id": s.watcher_thread_id,
|
||||
"watcher_interval": s.watcher_interval,
|
||||
"notify_on_complete": s.notify_on_complete,
|
||||
@@ -1042,6 +1046,8 @@ class ProcessRegistry:
|
||||
detached=True, # Can't read output, but can report status + kill
|
||||
watcher_platform=entry.get("watcher_platform", ""),
|
||||
watcher_chat_id=entry.get("watcher_chat_id", ""),
|
||||
watcher_user_id=entry.get("watcher_user_id", ""),
|
||||
watcher_user_name=entry.get("watcher_user_name", ""),
|
||||
watcher_thread_id=entry.get("watcher_thread_id", ""),
|
||||
watcher_interval=entry.get("watcher_interval", 0),
|
||||
notify_on_complete=entry.get("notify_on_complete", False),
|
||||
@@ -1060,6 +1066,8 @@ class ProcessRegistry:
|
||||
"session_key": session.session_key,
|
||||
"platform": session.watcher_platform,
|
||||
"chat_id": session.watcher_chat_id,
|
||||
"user_id": session.watcher_user_id,
|
||||
"user_name": session.watcher_user_name,
|
||||
"thread_id": session.watcher_thread_id,
|
||||
"notify_on_complete": session.notify_on_complete,
|
||||
})
|
||||
|
||||
@@ -219,13 +219,15 @@ def _validate_file_path(file_path: str) -> Optional[str]:
|
||||
Validate a file path for write_file/remove_file.
|
||||
Must be under an allowed subdirectory and not escape the skill dir.
|
||||
"""
|
||||
from tools.path_security import has_traversal_component
|
||||
|
||||
if not file_path:
|
||||
return "file_path is required."
|
||||
|
||||
normalized = Path(file_path)
|
||||
|
||||
# Prevent path traversal
|
||||
if ".." in normalized.parts:
|
||||
if has_traversal_component(file_path):
|
||||
return "Path traversal ('..') is not allowed."
|
||||
|
||||
# Must be under an allowed subdirectory
|
||||
@@ -242,15 +244,12 @@ def _validate_file_path(file_path: str) -> Optional[str]:
|
||||
|
||||
def _resolve_skill_target(skill_dir: Path, file_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve a supporting-file path and ensure it stays within the skill directory."""
|
||||
from tools.path_security import validate_within_dir
|
||||
|
||||
target = skill_dir / file_path
|
||||
try:
|
||||
resolved = target.resolve(strict=False)
|
||||
skill_dir_resolved = skill_dir.resolve()
|
||||
resolved.relative_to(skill_dir_resolved)
|
||||
except ValueError:
|
||||
return None, "Path escapes skill directory boundary."
|
||||
except OSError as e:
|
||||
return None, f"Invalid file path '{file_path}': {e}"
|
||||
error = validate_within_dir(target, skill_dir)
|
||||
if error:
|
||||
return None, error
|
||||
return target, None
|
||||
|
||||
|
||||
|
||||
@@ -447,17 +447,8 @@ def _get_category_from_path(skill_path: Path) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _estimate_tokens(content: str) -> int:
|
||||
"""
|
||||
Rough token estimate (4 chars per token average).
|
||||
|
||||
Args:
|
||||
content: Text content
|
||||
|
||||
Returns:
|
||||
Estimated token count
|
||||
"""
|
||||
return len(content) // 4
|
||||
# Token estimation — use the shared implementation from model_metadata.
|
||||
from agent.model_metadata import estimate_tokens_rough as _estimate_tokens
|
||||
|
||||
|
||||
def _parse_tags(tags_value) -> List[str]:
|
||||
@@ -947,9 +938,10 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
|
||||
# If a specific file path is requested, read that instead
|
||||
if file_path and skill_dir:
|
||||
from tools.path_security import validate_within_dir, has_traversal_component
|
||||
|
||||
# Security: Prevent path traversal attacks
|
||||
normalized_path = Path(file_path)
|
||||
if ".." in normalized_path.parts:
|
||||
if has_traversal_component(file_path):
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
@@ -962,24 +954,13 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||
target_file = skill_dir / file_path
|
||||
|
||||
# Security: Verify resolved path is still within skill directory
|
||||
try:
|
||||
resolved = target_file.resolve()
|
||||
skill_dir_resolved = skill_dir.resolve()
|
||||
if not resolved.is_relative_to(skill_dir_resolved):
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Path escapes skill directory boundary.",
|
||||
"hint": "Use a relative path within the skill directory",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
traversal_error = validate_within_dir(target_file, skill_dir)
|
||||
if traversal_error:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Invalid file path: '{file_path}'",
|
||||
"hint": "Use a valid relative path within the skill directory",
|
||||
"error": traversal_error,
|
||||
"hint": "Use a relative path within the skill directory",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
@@ -1427,8 +1427,12 @@ def terminal_tool(
|
||||
if _gw_platform and not check_interval:
|
||||
_gw_chat_id = _gse("HERMES_SESSION_CHAT_ID", "")
|
||||
_gw_thread_id = _gse("HERMES_SESSION_THREAD_ID", "")
|
||||
_gw_user_id = _gse("HERMES_SESSION_USER_ID", "")
|
||||
_gw_user_name = _gse("HERMES_SESSION_USER_NAME", "")
|
||||
proc_session.watcher_platform = _gw_platform
|
||||
proc_session.watcher_chat_id = _gw_chat_id
|
||||
proc_session.watcher_user_id = _gw_user_id
|
||||
proc_session.watcher_user_name = _gw_user_name
|
||||
proc_session.watcher_thread_id = _gw_thread_id
|
||||
proc_session.watcher_interval = 5
|
||||
process_registry.pending_watchers.append({
|
||||
@@ -1437,6 +1441,8 @@ def terminal_tool(
|
||||
"session_key": session_key,
|
||||
"platform": _gw_platform,
|
||||
"chat_id": _gw_chat_id,
|
||||
"user_id": _gw_user_id,
|
||||
"user_name": _gw_user_name,
|
||||
"thread_id": _gw_thread_id,
|
||||
"notify_on_complete": True,
|
||||
})
|
||||
@@ -1457,10 +1463,14 @@ def terminal_tool(
|
||||
watcher_platform = _gse2("HERMES_SESSION_PLATFORM", "")
|
||||
watcher_chat_id = _gse2("HERMES_SESSION_CHAT_ID", "")
|
||||
watcher_thread_id = _gse2("HERMES_SESSION_THREAD_ID", "")
|
||||
watcher_user_id = _gse2("HERMES_SESSION_USER_ID", "")
|
||||
watcher_user_name = _gse2("HERMES_SESSION_USER_NAME", "")
|
||||
|
||||
# Store on session for checkpoint persistence
|
||||
proc_session.watcher_platform = watcher_platform
|
||||
proc_session.watcher_chat_id = watcher_chat_id
|
||||
proc_session.watcher_user_id = watcher_user_id
|
||||
proc_session.watcher_user_name = watcher_user_name
|
||||
proc_session.watcher_thread_id = watcher_thread_id
|
||||
proc_session.watcher_interval = effective_interval
|
||||
|
||||
@@ -1470,6 +1480,8 @@ def terminal_tool(
|
||||
"session_key": session_key,
|
||||
"platform": watcher_platform,
|
||||
"chat_id": watcher_chat_id,
|
||||
"user_id": watcher_user_id,
|
||||
"user_name": watcher_user_name,
|
||||
"thread_id": watcher_thread_id,
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Defense against context-window overflow operates at three levels:
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import uuid
|
||||
|
||||
from tools.budget_config import (
|
||||
@@ -79,7 +80,7 @@ def _write_to_sandbox(content: str, remote_path: str, env) -> bool:
|
||||
marker = _heredoc_marker(content)
|
||||
storage_dir = os.path.dirname(remote_path)
|
||||
cmd = (
|
||||
f"mkdir -p {storage_dir} && cat > {remote_path} << '{marker}'\n"
|
||||
f"mkdir -p {shlex.quote(storage_dir)} && cat > {shlex.quote(remote_path)} << '{marker}'\n"
|
||||
f"{content}\n"
|
||||
f"{marker}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user