Skip to content
Merged
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
25 changes: 24 additions & 1 deletion tests/tools/test_file_read_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,37 @@ def test_proc_fd_other_not_blocked(self):
self.assertFalse(_is_blocked_device_path("/proc/self/fd/3"))

def test_proc_sensitive_pseudo_files_blocked(self):
"""environ/cmdline/maps under /proc/<pid> must be blocked (issue #4427)."""
"""environ/cmdline/maps (and maps variants) under /proc/<pid> must be blocked (issue #4427)."""
for path in (
"/proc/self/environ",
"/proc/12345/environ",
"/proc/self/cmdline",
"/proc/99/cmdline",
"/proc/self/maps",
"/proc/1/maps",
"/proc/self/smaps",
"/proc/12345/smaps",
"/proc/self/smaps_rollup",
"/proc/99/smaps_rollup",
"/proc/self/numa_maps",
"/proc/1/numa_maps",
"/proc/self/mem",
"/proc/12345/mem",
"/proc/self/auxv",
"/proc/1/auxv",
"/proc/self/pagemap",
"/proc/99/pagemap",
):
self.assertTrue(_is_blocked_device(path), f"{path} should be blocked")

def test_proc_task_thread_sensitive_files_blocked(self):
"""Per-thread /proc/<pid>/task/<tid>/<file> aliases leak the same data."""
for path in (
"/proc/self/task/1234/maps",
"/proc/self/task/1234/smaps",
"/proc/self/task/1234/auxv",
"/proc/self/task/1234/pagemap",
"/proc/self/task/1234/environ",
):
self.assertTrue(_is_blocked_device(path), f"{path} should be blocked")

Expand Down
101 changes: 85 additions & 16 deletions tools/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,10 +362,27 @@ def _is_blocked_device_path(path: str) -> bool:
("/fd/0", "/fd/1", "/fd/2")
):
return True
# /proc/*/environ, /proc/*/cmdline, /proc/*/maps can leak secrets,
# command-line args, and memory layout from the host process (issue #4427)
# /proc/*/environ, /proc/*/cmdline, /proc/*/maps (and the maps variants
# smaps, smaps_rollup, numa_maps) can leak secrets, command-line args, and
# memory layout (ASLR bypass) from the host process (issue #4427).
# /proc/*/mem exposes raw process memory; block it as defense-in-depth even
# though it requires address knowledge to exploit usefully.
# /proc/*/auxv leaks AT_RANDOM (stack canary seed) plus AT_BASE/AT_PHDR
# load addresses — an ASLR oracle on par with maps. /proc/*/pagemap exposes
# virtual->physical translation. Both are blocked alongside the maps family.
# endswith matches both /proc/<pid>/X and /proc/<pid>/task/<tid>/X.
if normalized.startswith("/proc/") and normalized.endswith(
("/environ", "/cmdline", "/maps")
(
"/environ",
"/cmdline",
"/maps",
"/smaps",
"/smaps_rollup",
"/numa_maps",
"/mem",
"/auxv",
"/pagemap",
)
):
Comment on lines 374 to 386

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 /proc/*/stat missing from blocklist enables ASLR oracle bypass of maps/smaps/auxv blocks (security)

The _is_blocked_device_path function blocks /proc/*/maps, /smaps, /smaps_rollup, /numa_maps, /mem, /auxv, and /pagemap to prevent ASLR bypass and memory disclosure. However, /proc/<pid>/stat is NOT blocked, despite exposing start_code (text-segment base), end_code (text-segment end), and start_stack (stack base) — fields 26–28 per the Linux proc(5) man page. These are the same ASLR-sensitive addresses that maps would reveal. An attacker or model that cannot read /proc/self/maps can read /proc/self/stat and extract equivalent address information, defeating the block. The endswith-based check would naturally cover both /proc/<pid>/stat and /proc/<pid>/task/<tid>/stat if /stat were added to the blocked suffix tuple.

💡 Suggestion: Add /stat to the blocked suffix tuple in _is_blocked_device_path so the ASLR oracle is blocked alongside maps/smaps/auxv/pagemap. The endswith check already covers /proc/<pid>/stat and /proc/<pid>/task/<tid>/stat automatically.

📋 Prompt for AI Agents

In tools/file_tools.py, in function _is_blocked_device_path around lines 374-386, add /stat to the endswith tuple alongside the existing blocked /proc suffixes. The tuple currently contains ('/environ', '/cmdline', '/maps', '/smaps', '/smaps_rollup', '/numa_maps', '/mem', '/auxv', '/pagemap'). Insert '/stat' into the tuple. Also update the test fixtures in tests/tools/test_file_read_guards.py to add test cases for /proc/self/stat, /proc/12345/stat, and /proc/self/task/1234/stat in the existing test_proc_sensitive_pseudo_files_blocked and test_proc_task_thread_sensitive_files_blocked tests.

return True
return False
Expand Down Expand Up @@ -411,6 +428,55 @@ def _is_blocked_device(filepath: str, base_dir: str | Path | None = None) -> boo
return False


def _search_result_read_block_error(path: str, task_id: str = "default") -> str | None:
"""Return the read-safety error for a search result path.

Search backends may return paths relative to the task cwd, while
``get_read_block_error`` expects an already-resolved path when the task cwd
can differ from the Python process cwd. Mirror ``read_file_tool``'s path
resolution before applying the shared read guard.
"""
try:
resolved = _resolve_path_for_task(path, task_id)
except (OSError, ValueError, RuntimeError):
return get_read_block_error(path)
return get_read_block_error(str(resolved))


def _filter_read_blocked_search_results(result, task_id: str = "default") -> int:
"""Remove credential/cache/env paths from a SearchResult in-place."""
omitted = 0

if hasattr(result, "matches") and result.matches:
allowed_matches = []
for match in result.matches:
if _search_result_read_block_error(match.path, task_id):
omitted += 1
continue
allowed_matches.append(match)
result.matches = allowed_matches

if hasattr(result, "files") and result.files:
allowed_files = []
for file_path in result.files:
if _search_result_read_block_error(file_path, task_id):
omitted += 1
continue
allowed_files.append(file_path)
result.files = allowed_files

if hasattr(result, "counts") and result.counts:
allowed_counts = {}
for file_path, count in result.counts.items():
if _search_result_read_block_error(file_path, task_id):
omitted += 1
continue
allowed_counts[file_path] = count
result.counts = allowed_counts

return omitted
Comment on lines +446 to +477

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 search_tool results bypass /proc pseudo-file blocklist applied by read_file_tool (security)

read_file_tool blocks /proc pseudo-files (maps, mem, environ, auxv, pagemap, stat, etc.) via _is_blocked_device at line 1059. However, the new search result filtering in _filter_read_blocked_search_results (lines 446-477) and the search directory guard (line 1805) apply only get_read_block_error — which blocks credential stores, .env files, and Hermes cache paths, but NOT /proc pseudo-files. This means: (1) a search rooted in /proc/self/ is not blocked by the directory guard, and (2) search results containing paths like /proc/self/maps or /proc/12345/environ pass through the result filter. While content matches are redacted via redact_sensitive_text (line 1818), the file path metadata (including line numbers) remains in results. This creates an asymmetry between read_file_tool (dual guards) and search_tool (single guard), potentially leaking blocked-path metadata through search enumeration.

💡 Suggestion: Extend _filter_read_blocked_search_results to also call _is_blocked_device (or _is_blocked_device_path) for each search result path, mirroring the dual-guard pattern used in read_file_tool. Additionally, apply _is_blocked_device to the search directory in search_tool (alongside the existing get_read_block_error guard at line 1805) to block searches rooted in /proc/ directories.

📋 Prompt for AI Agents

In tools/file_tools.py: (1) In _search_result_read_block_error (lines 431-443), add a call to _is_blocked_device_path(str(resolved)) alongside the existing get_read_block_error(str(resolved)) call — return the first non-None error from either guard, so /proc pseudo-files are filtered from search results. (2) In search_tool around line 1805, add a call to _is_blocked_device(str(resolved_path)) for the search directory path alongside the existing get_read_block_error check, so searches rooted in /proc/self/ or similar sensitive directories are blocked before performing I/O.



# Paths that file tools should refuse to write to without going through the
# terminal tool's approval system. These match prefixes after os.path.realpath.
_SENSITIVE_PATH_PREFIXES = (
Expand Down Expand Up @@ -891,8 +957,6 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
image = overrides.get("modal_image") or config["modal_image"]
elif env_type == "daytona":
image = overrides.get("daytona_image") or config["daytona_image"]
elif env_type == "tenki":
image = overrides.get("tenki_image") or config["tenki_image"]
else:
image = ""

Expand Down Expand Up @@ -920,7 +984,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
logger.info("Creating new %s environment for task %s...", env_type, task_id[:8])

container_config = None
if env_type in _CONTAINER_BACKENDS:
if env_type in {"docker", "singularity", "modal", "daytona"}:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Incomplete tenki removal: file_tools.py strips tenki config while terminal_tool.py and code_execution_tool.py still expect it (bug)

The PR removes tenki backend support from file_tools.py only: it deletes the elif env_type == "tenki": image-resolution branch, changes line 987 from if env_type in _CONTAINER_BACKENDS to a hardcoded set {"docker", "singularity", "modal", "daytona"} that excludes tenki, and removes all tenki_* keys from container_config. However, two other files in the same codebase still carry full tenki support: terminal_tool.py (line 1218: _CONTAINER_BACKENDS includes "tenki"; lines 1522-1544: _create_environment() reads tenki_api_endpoint, tenki_workspace_id, tenki_project_id, etc. from container_config and passes them to TenkiEnvironment) and code_execution_tool.py (lines 655-681: has the env_type == "tenki" image branch, puts tenki in the container set, and includes all tenki_* keys in container_config). As a result, any user with env_type = "tenki" who triggers file operations (read_file, write_file, search_files — via any of the 6 call sites of _get_file_ops at lines 1080, 1198, 1517, 1538, 1671, 1809) will get image="" and container_config=None, causing TenkiEnvironment to be constructed with blank credentials that will fail at connection time. Terminal operations and code execution remain unaffected because their respective code paths are unchanged.

💡 Suggestion: The tenki removal must be applied consistently across all three files. Either (a) restore tenki support in file_tools.py to match terminal_tool.py and code_execution_tool.py, including the image branch, container_config keys, and the _CONTAINER_BACKENDS guard; or (b) complete the tenki removal by also removing tenki from terminal_tool.py _CONTAINER_BACKENDS (line 1218), the TenkiEnvironment creation block (lines 1522-1544), and code_execution_tool.py (lines 655-656, 663, 671-680). The current partial removal leaves the code in an inconsistent state.

📋 Prompt for AI Agents

To complete the tenki removal consistently: (1) In tools/terminal_tool.py line 1218, remove "tenki" from the CONTAINER_BACKENDS frozenset. (2) In tools/terminal_tool.py, remove lines 1522-1544 (the elif env_type == "tenki": block that creates TenkiEnvironment). (3) In tools/code_execution_tool.py line 655-656, remove the elif env_type == "tenki": image branch. (4) In tools/code_execution_tool.py line 663, remove "tenki" from the hardcoded set. (5) In tools/code_execution_tool.py lines 671-680, remove the tenki* config keys from container_config. (6) In tools/file_tools.py line 987, restore _CONTAINER_BACKENDS instead of the hardcoded set (so it stays consistent after tenki is removed from the frozenset).

container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
Expand All @@ -930,16 +994,6 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
"docker_forward_env": config.get("docker_forward_env", []),
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
"tenki_api_endpoint": config.get("tenki_api_endpoint", ""),
"tenki_workspace_id": config.get("tenki_workspace_id", ""),
"tenki_project_id": config.get("tenki_project_id", ""),
"tenki_name_prefix": config.get("tenki_name_prefix", "hermes"),
"tenki_allow_inbound": config.get("tenki_allow_inbound", False),
"tenki_allow_outbound": config.get("tenki_allow_outbound", True),
"tenki_max_duration": config.get("tenki_max_duration", 3600),
"tenki_idle_timeout": config.get("tenki_idle_timeout", 0),
"tenki_pause_retention": config.get("tenki_pause_retention", 0),
"tenki_sync_hermes_home": config.get("tenki_sync_hermes_home", False),
}

ssh_config = None
Expand Down Expand Up @@ -1744,17 +1798,32 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
"already_searched": count,
}, ensure_ascii=False)

try:
resolved_path = _resolve_path_for_task(path, task_id)
except (OSError, ValueError, RuntimeError):
resolved_path = None
block_error = get_read_block_error(str(resolved_path) if resolved_path else path)
if block_error:
return json.dumps({"error": block_error}, ensure_ascii=False)

file_ops = _get_file_ops(task_id)
result = file_ops.search(
pattern=pattern, path=path, target=target, file_glob=file_glob,
limit=limit, offset=offset, output_mode=output_mode, context=context
)
omitted = _filter_read_blocked_search_results(result, task_id)
if hasattr(result, 'matches'):
for m in result.matches:
if hasattr(m, 'content') and m.content:
m.content = redact_sensitive_text(m.content, file_read=True)
result_dict = result.to_dict(densify=True)

if omitted:
result_dict["_omitted"] = (
f"{omitted} result(s) omitted because they target credential, "
"token, cache, or secret-bearing environment files."
)

if count >= 3:
result_dict["_warning"] = (
f"You have run this exact search {count} times consecutively. "
Expand Down
Loading