From dab9d1b39e77c051fd4425b4ac346b8df95a9c56 Mon Sep 17 00:00:00 2001 From: Jon Halstead Date: Thu, 27 Aug 2026 01:55:04 -0400 Subject: [PATCH] fix: keep Jarvis replies off /proc map_files in containers Workspace tools defaulted to Path(__file__).parents[2], which is / in the Docker /app layout. Chat then walked /proc/1/map_files and died with EPERM. Pin the root to the repo, skip proc/sys/dev, and fail-open on blocked introspection. Co-authored-by: Cursor --- Dockerfile | 1 + deploy/pilot/Dockerfile.aais | 1 + deploy/platform/Dockerfile | 1 + render.yaml | 2 + src/evolving_workbench.py | 30 ++++++--- src/jarvis_operator.py | 120 ++++++++++++++++++++++----------- src/patch_apply_engine.py | 13 ++-- src/patch_execution_preview.py | 13 ++-- src/workspace_root.py | 84 +++++++++++++++++++++++ tests/test_workspace_root.py | 83 +++++++++++++++++++++++ 10 files changed, 286 insertions(+), 62 deletions(-) create mode 100644 src/workspace_root.py create mode 100644 tests/test_workspace_root.py diff --git a/Dockerfile b/Dockerfile index 2b43b961..c7c6939e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ AAIS_RUNTIME_DIR=/app/.runtime/aais-data \ + AAIS_WORKSPACE_ROOT=/app \ PATH=/usr/local/bin:$PATH \ PORT=8000 diff --git a/deploy/pilot/Dockerfile.aais b/deploy/pilot/Dockerfile.aais index 310f89e4..03bce894 100644 --- a/deploy/pilot/Dockerfile.aais +++ b/deploy/pilot/Dockerfile.aais @@ -4,6 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ AAIS_RUNTIME_DIR=/app/.runtime/aais-data \ + AAIS_WORKSPACE_ROOT=/app \ PIP_NO_CACHE_DIR=1 WORKDIR /app diff --git a/deploy/platform/Dockerfile b/deploy/platform/Dockerfile index 7365c60e..ed77cbc7 100644 --- a/deploy/platform/Dockerfile +++ b/deploy/platform/Dockerfile @@ -3,6 +3,7 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ + AAIS_WORKSPACE_ROOT=/app \ PIP_NO_CACHE_DIR=1 WORKDIR /app diff --git a/render.yaml b/render.yaml index e905b0df..6854c284 100644 --- a/render.yaml +++ b/render.yaml @@ -193,6 +193,8 @@ services: value: "8000" - key: AAIS_RUNTIME_DIR value: /app/.runtime/aais-data + - key: AAIS_WORKSPACE_ROOT + value: /app - key: JARVIS_DATA_DIR value: /app/.runtime/aais-data - key: ENVIRONMENT diff --git a/src/evolving_workbench.py b/src/evolving_workbench.py index 637bb503..d94e95dd 100644 --- a/src/evolving_workbench.py +++ b/src/evolving_workbench.py @@ -22,6 +22,8 @@ def _wrap_ul_payload(payload: dict) -> dict: from typing import Any from uuid import uuid4 +from src.workspace_root import resolve_workspace_root + PRIMARY_PROJECT_ENV = "AAIS_PRIMARY_PROJECT" APPROVAL_AUDIT_FILENAME = "evolving-approval-audit.json" MAX_SYMBOL_RESULTS = 40 @@ -39,6 +41,13 @@ def _wrap_ul_payload(payload: dict) -> dict: "build", "dist", "_archives", + "proc", + "sys", + "dev", + "map_files", + "run", + "boot", + "lost+found", } CODE_EXTENSIONS = { @@ -203,12 +212,10 @@ def __init__(self, workspace_root: str | Path | None = None): self.workspace_root = Path(workspace_root) if workspace_root else None def _resolve_workspace_root(self) -> Path: - configured = os.getenv("AAIS_WORKSPACE_ROOT") - if configured: - return Path(configured).expanduser().resolve() - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + module_file=Path(__file__), + ) def _preferred_project_name(self) -> str: configured = str(os.getenv(PRIMARY_PROJECT_ENV, "")).strip() @@ -225,7 +232,11 @@ def _preferred_project_name(self) -> str: def _iter_visible_files(self) -> list[str]: root = self._resolve_workspace_root() visible: list[str] = [] - for current_root, dirs, files in os.walk(root): + + def _skip_unreadable(_error: OSError) -> None: + return None + + for current_root, dirs, files in os.walk(root, onerror=_skip_unreadable): dirs[:] = [ directory for directory in dirs @@ -233,7 +244,10 @@ def _iter_visible_files(self) -> list[str]: ] for filename in files: path = Path(current_root) / filename - relative = _normalize_posix_path(path.relative_to(root)) + try: + relative = _normalize_posix_path(path.relative_to(root)) + except (OSError, ValueError): + continue if relative: visible.append(relative) return sorted(visible) diff --git a/src/jarvis_operator.py b/src/jarvis_operator.py index 43fa57ff..0a2e2bac 100644 --- a/src/jarvis_operator.py +++ b/src/jarvis_operator.py @@ -43,6 +43,7 @@ from src.patch_execution_preview import PatchExecutionPreview from src.patch_review_store import PatchReviewStore from src.project_infi_law import ProjectInfiLaw +from src.workspace_root import resolve_workspace_root from src.forge_repo_governance import ( build_forge_contractor_payload, build_forge_eval_payload, @@ -267,6 +268,14 @@ def _filter_workspace_context_for_forge( "build", "dist", "_archives", + # Container/host pseudo-fs — walking these hits /proc/*/map_files (EPERM). + "proc", + "sys", + "dev", + "map_files", + "run", + "boot", + "lost+found", } TEXT_EXTENSIONS = { @@ -2730,13 +2739,11 @@ def __init__(self, workspace_root: str | Path | None = None): def _resolve_workspace_root(self): """Resolve the workspace root that Jarvis may inspect.""" - if os.getenv(WORKSPACE_ROOT_ENV): - return Path(os.getenv(WORKSPACE_ROOT_ENV)).expanduser().resolve() - - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + env_var=WORKSPACE_ROOT_ENV, + module_file=Path(__file__), + ) def _preferred_project_name(self): """Return the project folder that should rank highest in ambiguous searches.""" @@ -2765,7 +2772,11 @@ def _iter_files(self): """Yield candidate files under the workspace root, skipping bulky/system dirs.""" root = self._resolve_workspace_root() - for current_root, dirs, files in os.walk(root): + def _skip_unreadable(_error: OSError) -> None: + # Fail-open: container /proc/*/map_files raises EPERM; keep chat alive. + return None + + for current_root, dirs, files in os.walk(root, onerror=_skip_unreadable): dirs[:] = [ directory for directory in dirs @@ -2779,21 +2790,28 @@ def _iter_files(self): def _is_text_file(self, path: Path): """Check whether a file is safe and useful to preview/search as text.""" - if not path.is_file(): - return False + try: + if not path.is_file(): + return False - normalized = str(path).replace("/", "\\").lower() - if "\\training\\out\\" in normalized or "\\checkpoint-" in normalized: - return False + normalized = str(path).replace("/", "\\").lower() + if "\\training\\out\\" in normalized or "\\checkpoint-" in normalized: + return False + # Defense-in-depth if a walk somehow enters proc/sys/dev. + parts = {part.lower() for part in path.parts} + if parts & {"proc", "sys", "dev", "map_files"}: + return False - if path.stat().st_size > MAX_FILE_BYTES: - return False + if path.stat().st_size > MAX_FILE_BYTES: + return False - suffix = path.suffix.lower() - if suffix in TEXT_EXTENSIONS: - return True + suffix = path.suffix.lower() + if suffix in TEXT_EXTENSIONS: + return True - return path.name.lower().startswith("readme") + return path.name.lower().startswith("readme") + except OSError: + return False def _read_text_file(self, path: Path, max_chars: int | None = MAX_FILE_CHARS): """Read a bounded text preview from disk.""" @@ -2951,7 +2969,10 @@ def search( kind = "path" snippet = f"Exact file match in {relative_path}" - content = self._read_text_file(path, max_chars=None) + try: + content = self._read_text_file(path, max_chars=None) + except (OSError, ValueError): + continue lower_content = content.lower() content_score = _score_text_match(query_tokens, lower_content) @@ -5167,21 +5188,25 @@ def build_workspace_context( return None preferred_project = self.workspace_tools._preferred_project_name() - search_result = self.workspace_tools.search( - query, - limit=result_limit, - project_name=preferred_project, - prefer_project=preferred_project, - ) - results = search_result.get("results", []) - scoped_project = preferred_project if results else None - if not results: + try: search_result = self.workspace_tools.search( query, limit=result_limit, + project_name=preferred_project, prefer_project=preferred_project, ) results = search_result.get("results", []) + scoped_project = preferred_project if results else None + if not results: + search_result = self.workspace_tools.search( + query, + limit=result_limit, + prefer_project=preferred_project, + ) + results = search_result.get("results", []) + except OSError: + # Fail-open: blocked /proc map reads must not abort a Jarvis reply. + return None if not results: return None @@ -5345,21 +5370,24 @@ def _build_visual_workspace_context( return None preferred_project = self.workspace_tools._preferred_project_name() - search_result = self.workspace_tools.search( - cleaned_query, - limit=result_limit, - project_name=preferred_project, - prefer_project=preferred_project, - ) - results = search_result.get("results", []) - scoped_project = preferred_project if results else None - if not results: + try: search_result = self.workspace_tools.search( cleaned_query, limit=result_limit, + project_name=preferred_project, prefer_project=preferred_project, ) results = search_result.get("results", []) + scoped_project = preferred_project if results else None + if not results: + search_result = self.workspace_tools.search( + cleaned_query, + limit=result_limit, + prefer_project=preferred_project, + ) + results = search_result.get("results", []) + except OSError: + return None if not results: return None @@ -6105,7 +6133,13 @@ def handle_command( if lower.startswith("search workspace for "): query = cleaned[21:].strip() - search_result = self.workspace_tools.search(query, limit=6) + try: + search_result = self.workspace_tools.search(query, limit=6) + except OSError: + return { + "response": "Workspace search is unavailable in this runtime (filesystem introspection blocked).", + "tool_result": {"type": "workspace_search", "query": query, "results": []}, + } lines = [ f"- {result['relative_path']}: {result['snippet']}" for result in search_result["results"] @@ -6122,7 +6156,13 @@ def handle_command( if lower.startswith("find file "): query = cleaned[10:].strip() - search_result = self.workspace_tools.search(query, limit=6) + try: + search_result = self.workspace_tools.search(query, limit=6) + except OSError: + return { + "response": "File search is unavailable in this runtime (filesystem introspection blocked).", + "tool_result": {"type": "workspace_search", "query": query, "results": []}, + } lines = [ f"- {result['relative_path']}: {result['snippet']}" for result in search_result["results"] diff --git a/src/patch_apply_engine.py b/src/patch_apply_engine.py index e8d48702..4763d71d 100644 --- a/src/patch_apply_engine.py +++ b/src/patch_apply_engine.py @@ -3,12 +3,12 @@ from datetime import datetime from src.datetime_compat import UTC import hashlib -import os from pathlib import Path from typing import Any +from src.workspace_root import resolve_workspace_root + -WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" DISALLOWED_AFTER_SNIPPET_MARKERS = ( "review-first patch placeholder", "review first patch placeholder", @@ -34,11 +34,10 @@ def configure_workspace_root(self, workspace_root: str | Path | None) -> None: self.workspace_root = Path(workspace_root) if workspace_root else None def _resolve_workspace_root(self) -> Path: - if os.getenv(WORKSPACE_ROOT_ENV): - return Path(os.getenv(WORKSPACE_ROOT_ENV)).expanduser().resolve() - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + module_file=Path(__file__), + ) def _resolve_target_path(self, relative_path: str) -> tuple[Path, str]: root = self._resolve_workspace_root() diff --git a/src/patch_execution_preview.py b/src/patch_execution_preview.py index f20d643b..a05b670b 100644 --- a/src/patch_execution_preview.py +++ b/src/patch_execution_preview.py @@ -1,12 +1,12 @@ from __future__ import annotations import hashlib -import os from pathlib import Path from typing import Any +from src.workspace_root import resolve_workspace_root + -WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" MAX_PREVIEW_BYTES = 256_000 MAX_EXCERPT_CHARS = 220 @@ -32,11 +32,10 @@ def configure_workspace_root(self, workspace_root: str | Path | None) -> None: self.workspace_root = Path(workspace_root) if workspace_root else None def _resolve_workspace_root(self) -> Path: - if os.getenv(WORKSPACE_ROOT_ENV): - return Path(os.getenv(WORKSPACE_ROOT_ENV)).expanduser().resolve() - if self.workspace_root is not None: - return self.workspace_root.expanduser().resolve() - return Path(__file__).resolve().parents[2] + return resolve_workspace_root( + self.workspace_root, + module_file=Path(__file__), + ) def _resolve_target_path(self, relative_path: str) -> tuple[Path, str]: root = self._resolve_workspace_root() diff --git a/src/workspace_root.py b/src/workspace_root.py new file mode 100644 index 00000000..a0417e1e --- /dev/null +++ b/src/workspace_root.py @@ -0,0 +1,84 @@ +"""Safe workspace-root resolution for Jarvis local file tools. + +Mythic: Operator Workshop Boundary +Engineering: WorkspaceRootResolver + +Inputs: optional env override, optional explicit root, calling module path +Outputs: absolute Path confined to the repo (never filesystem root) +Constraints: read-only resolution; never walk `/`, `/proc`, `/sys`, `/dev` +Failure modes: missing/invalid override → fall back to repo root; EPERM on +proc map introspection must not reach chat (walkers skip unsafe dirs) +""" + +from __future__ import annotations + +import os +from pathlib import Path + +WORKSPACE_ROOT_ENV = "AAIS_WORKSPACE_ROOT" + +# Never descend into these when walking a workspace. Container sandboxes often +# raise EPERM on `/proc//map_files/...` (PID 1 is common under Docker). +UNSAFE_WALK_DIR_NAMES = frozenset( + { + "proc", + "sys", + "dev", + "map_files", + "run", + "boot", + "lost+found", + } +) + + +def _looks_like_repo_root(candidate: Path) -> bool: + return (candidate / "pyproject.toml").is_file() or ( + (candidate / "src").is_dir() and ((candidate / "app").is_dir() or (candidate / "aais").is_dir()) + ) + + +def default_repo_root(*, module_file: Path | None = None) -> Path: + """Locate the Project Infinity repo root from a module under ``src/``.""" + start = (module_file or Path(__file__)).resolve() + for parent in start.parents: + if _looks_like_repo_root(parent): + return parent + # Modules directly under src/ → parents[1]; nested src// → parents[2]. + if start.parent.name == "src": + return start.parents[1] + if len(start.parents) > 1 and start.parents[1].name == "src": + return start.parents[2] + return start.parents[1] + + +def _is_filesystem_root(path: Path) -> bool: + resolved = path.resolve() + return resolved == Path(resolved.anchor) + + +def resolve_workspace_root( + explicit: str | Path | None = None, + *, + env_var: str = WORKSPACE_ROOT_ENV, + module_file: Path | None = None, +) -> Path: + """Resolve the operator-visible workspace root. + + Prefer ``AAIS_WORKSPACE_ROOT`` (or ``env_var``), then an explicit constructor + root, then the repo root inferred from ``module_file``. Filesystem root + (``/``) is rejected because workspace walks would enter ``/proc`` and fail + chat turns with ``[Errno 1] Operation not permitted: '/proc/1/map_files/...'``. + """ + fallback = default_repo_root(module_file=module_file or Path(__file__)) + configured = os.getenv(env_var) + if configured: + candidate = Path(configured).expanduser().resolve() + elif explicit is not None: + candidate = Path(explicit).expanduser().resolve() + else: + candidate = fallback + + if _is_filesystem_root(candidate) or not candidate.is_dir(): + return fallback + return candidate diff --git a/tests/test_workspace_root.py b/tests/test_workspace_root.py new file mode 100644 index 00000000..c85e62cd --- /dev/null +++ b/tests/test_workspace_root.py @@ -0,0 +1,83 @@ +"""Regression tests for workspace root resolution and proc-map fail-open.""" + +from __future__ import annotations + +import errno +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from src.jarvis_operator import WorkspaceTools +from src.workspace_root import default_repo_root, resolve_workspace_root + + +class TestWorkspaceRootResolver(unittest.TestCase): + def test_default_repo_root_is_project_infinity_not_filesystem_parent(self): + root = default_repo_root(module_file=Path(__file__)) + self.assertTrue((root / "pyproject.toml").is_file()) + self.assertTrue((root / "src" / "jarvis_operator.py").is_file()) + self.assertNotEqual(root, Path(root.anchor)) + + def test_workspace_tools_default_root_stays_inside_repo(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("AAIS_WORKSPACE_ROOT", None) + tools = WorkspaceTools() + root = tools._resolve_workspace_root() + self.assertTrue((root / "src" / "jarvis_operator.py").is_file()) + self.assertNotEqual(str(root), "/") + + def test_filesystem_root_override_falls_back_to_repo(self): + with patch.dict(os.environ, {"AAIS_WORKSPACE_ROOT": "/"}, clear=False): + root = resolve_workspace_root(module_file=Path(__file__)) + self.assertTrue((root / "pyproject.toml").is_file()) + self.assertNotEqual(root, Path("/")) + + +class TestWorkspaceWalkFailOpen(unittest.TestCase): + def test_iter_files_skips_proc_map_files_tree(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "notes.md").write_text("hello jarvis", encoding="utf-8") + map_dir = root / "proc" / "1" / "map_files" + map_dir.mkdir(parents=True) + (map_dir / "5f76c53f6000-5f76c53f7000").write_text("do-not-read", encoding="utf-8") + + tools = WorkspaceTools(workspace_root=root) + found = [path.name for path in tools._iter_files()] + + self.assertIn("notes.md", found) + self.assertNotIn("5f76c53f6000-5f76c53f7000", found) + + def test_is_text_file_fail_open_on_eperm(self): + tools = WorkspaceTools(workspace_root=Path("/tmp")) + blocked = Path("/proc/1/map_files/5f76c53f6000-5f76c53f7000") + error = PermissionError(errno.EPERM, "Operation not permitted", str(blocked)) + with patch.object(Path, "is_file", side_effect=error): + self.assertFalse(tools._is_text_file(blocked)) + + def test_search_survives_eperm_during_walk(self): + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "readme.md").write_text("remember project notes", encoding="utf-8") + tools = WorkspaceTools(workspace_root=root) + real_walk = os.walk + + def walk_with_eperm(top, *args, **kwargs): + onerror = kwargs.get("onerror") + if onerror is not None: + onerror( + PermissionError( + errno.EPERM, + "Operation not permitted", + "/proc/1/map_files/5f76c53f6000-5f76c53f7000", + ) + ) + yield from real_walk(top, *args, **kwargs) + + with patch("src.jarvis_operator.os.walk", side_effect=walk_with_eperm): + result = tools.search("remember project", limit=5) + + self.assertGreaterEqual(len(result["results"]), 1) + self.assertIn("remember project notes", result["results"][0]["snippet"])