mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
fix(agent): skip deny-listed sensitive files in glob (#5094)
This commit is contained in:
@@ -281,7 +281,13 @@ class LsTool:
|
|||||||
|
|
||||||
class GlobTool:
|
class GlobTool:
|
||||||
async def execute(self, content: str, ctx: dict) -> dict:
|
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 = {}
|
args = {}
|
||||||
_s = (content or "").strip()
|
_s = (content or "").strip()
|
||||||
if _s.startswith("{"):
|
if _s.startswith("{"):
|
||||||
@@ -322,7 +328,11 @@ class GlobTool:
|
|||||||
) == nbase
|
) == nbase
|
||||||
except ValueError:
|
except ValueError:
|
||||||
inside = False
|
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
|
return [cand], None
|
||||||
# Literal not at exact path — fall through to walk so
|
# Literal not at exact path — fall through to walk so
|
||||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
# 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):
|
for dp, dns, fns in os.walk(base):
|
||||||
# Prune skipped dirs before descending (unlike rglob which
|
# Prune skipped dirs before descending (unlike rglob which
|
||||||
# descends first then filters — fatal on large node_modules).
|
# 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:
|
for name in fns + dns:
|
||||||
full = os.path.join(dp, name)
|
full = os.path.join(dp, name)
|
||||||
rel = os.path.relpath(full, base).replace(os.sep, "/")
|
rel = os.path.relpath(full, base).replace(os.sep, "/")
|
||||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
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:
|
try:
|
||||||
mtime = os.stat(full).st_mtime
|
mtime = os.stat(full).st_mtime
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -167,6 +167,38 @@ async def test_glob_confined_e2e(ws, admin):
|
|||||||
assert r["exit_code"] == 0 and "No files" in r["output"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_subprocess_cwd_is_workspace_e2e(ws, admin):
|
async def test_subprocess_cwd_is_workspace_e2e(ws, admin):
|
||||||
"""python tool runs with cwd = workspace (OS-agnostic probe)."""
|
"""python tool runs with cwd = workspace (OS-agnostic probe)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user