diff --git a/src/agent_tools/filesystem_tools.py b/src/agent_tools/filesystem_tools.py index b0f5b6a89..573d30db3 100644 --- a/src/agent_tools/filesystem_tools.py +++ b/src/agent_tools/filesystem_tools.py @@ -281,7 +281,13 @@ class LsTool: class GlobTool: async def execute(self, content: str, ctx: dict) -> dict: - from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate + from src.tool_execution import ( + _SENSITIVE_BASENAMES, + _is_sensitive_path, + _resolve_tool_path, + _resolve_search_root, + _truncate, + ) args = {} _s = (content or "").strip() if _s.startswith("{"): @@ -322,7 +328,11 @@ class GlobTool: ) == nbase except ValueError: inside = False - if inside and os.path.exists(cand): + # A literal that names a deny-listed sensitive file (.env, + # .ssh/id_rsa, …) falls through to the walk, which skips it — + # otherwise glob would surface secret paths that read_file / + # grep already refuse to touch. + if inside and os.path.exists(cand) and not _is_sensitive_path(cand): return [cand], None # Literal not at exact path — fall through to walk so # e.g. "foo.py" still matches at any depth (like rglob). @@ -334,11 +344,20 @@ class GlobTool: for dp, dns, fns in os.walk(base): # Prune skipped dirs before descending (unlike rglob which # descends first then filters — fatal on large node_modules). - dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS] + # Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob + # never enumerates the keys/tokens inside them. + dns[:] = [ + d for d in dns + if d not in _CODENAV_SKIP_DIRS and d not in _SENSITIVE_BASENAMES + ] for name in fns + dns: full = os.path.join(dp, name) rel = os.path.relpath(full, base).replace(os.sep, "/") if regex.fullmatch(rel) or regex.fullmatch(name): + # Skip deny-listed sensitive files (.env, id_rsa, + # known_hosts, …) the same way grep does. + if _is_sensitive_path(os.path.realpath(full)): + continue try: mtime = os.stat(full).st_mtime except OSError: diff --git a/tests/test_workspace_confine.py b/tests/test_workspace_confine.py index ca0819cc7..6d90789c9 100644 --- a/tests/test_workspace_confine.py +++ b/tests/test_workspace_confine.py @@ -167,6 +167,38 @@ async def test_glob_confined_e2e(ws, admin): assert r["exit_code"] == 0 and "No files" in r["output"] +@pytest.mark.asyncio +async def test_glob_skips_sensitive_files_in_workspace(ws, admin): + """glob must not enumerate deny-listed sensitive files that live inside the + workspace. read_file/write_file/edit_file refuse them and grep skips them, + so glob surfacing their paths is an enumeration oracle for prompt-injection. + """ + with open(os.path.join(ws, "keep.py"), "w") as f: + f.write("x") + with open(os.path.join(ws, ".env"), "w") as f: + f.write("AWS_SECRET=xxx") + with open(os.path.join(ws, "id_rsa"), "w") as f: # non-dotfile key at root + f.write("KEY") + os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True) + with open(os.path.join(ws, ".ssh", "authorized_keys"), "w") as f: + f.write("ssh-rsa AAAA") + + # A recursive wildcard returns ordinary files but none of the sensitive + # ones. The pattern "**/*" contains no secret names, so a secret basename + # appearing in the output is a real leak (not the echoed not-found pattern). + _, r = await execute_tool_block(_block("glob", json.dumps({"pattern": "**/*"})), owner="a", workspace=ws) + assert r["exit_code"] == 0 + assert "keep.py" in r["output"] + for leak in (".env", "id_rsa", "authorized_keys"): + assert leak not in r["output"], f"glob leaked sensitive file: {leak}" + + # Directly targeting a sensitive file (literal fast-path and wildcard) must + # come back as the not-found message, never a match with the file's path. + for pat in (".env", "**/id_rsa", "**/authorized_keys"): + _, r = await execute_tool_block(_block("glob", json.dumps({"pattern": pat})), owner="a", workspace=ws) + assert r["exit_code"] == 0 and "No files" in r["output"] + + @pytest.mark.asyncio async def test_subprocess_cwd_is_workspace_e2e(ws, admin): """python tool runs with cwd = workspace (OS-agnostic probe)."""