Skip to content

Commit 528d244

Browse files
committed
[bugf][autonomous-loop][confine the built-in file tools to the agent workspace]
1 parent 16afc75 commit 528d244

2 files changed

Lines changed: 127 additions & 45 deletions

File tree

swarms/structs/autonomous_loop_utils.py

Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,43 @@ def respond_to_user_tool(
640640
return f"Message sent to user: {message}"
641641

642642

643+
def _resolve_in_workspace(agent: Any, path: str) -> str:
644+
"""Resolve a model-supplied path, refusing anything outside the workspace.
645+
646+
The file tools take ``file_path`` straight from the model, so any prompt
647+
injection reaching the autonomous loop can otherwise read, overwrite or
648+
delete anywhere the host process can. ``realpath`` is what makes the check
649+
hold: it collapses ``..`` segments and follows symlinks before the
650+
containment test, so neither can be used to step outside.
651+
652+
Args:
653+
agent: The agent instance, used for the workspace directory
654+
path: Model-supplied path, relative to the workspace or absolute
655+
656+
Returns:
657+
str: The resolved absolute path, guaranteed inside the workspace
658+
659+
Raises:
660+
ValueError: If the path resolves outside the agent workspace
661+
"""
662+
workspace = os.path.realpath(agent._get_agent_workspace_dir())
663+
full_path = (
664+
path
665+
if path and os.path.isabs(path)
666+
else os.path.join(workspace, path or "")
667+
)
668+
resolved = os.path.realpath(full_path)
669+
670+
if resolved != workspace and not resolved.startswith(
671+
workspace + os.sep
672+
):
673+
raise ValueError(
674+
f"Path is outside the agent workspace and was refused: {path}"
675+
)
676+
677+
return resolved
678+
679+
643680
def create_file_tool(
644681
agent: Any, file_path: str, content: str, **kwargs
645682
) -> str:
@@ -656,12 +693,7 @@ def create_file_tool(
656693
str: Path to the created file or error message
657694
"""
658695
try:
659-
# Resolve path - if relative, use agent workspace
660-
if not os.path.isabs(file_path):
661-
workspace_dir = agent._get_agent_workspace_dir()
662-
full_path = os.path.join(workspace_dir, file_path)
663-
else:
664-
full_path = file_path
696+
full_path = _resolve_in_workspace(agent, file_path)
665697

666698
# Create parent directories if they don't exist
667699
os.makedirs(os.path.dirname(full_path), exist_ok=True)
@@ -715,12 +747,7 @@ def update_file_tool(
715747
str: Success message or error message
716748
"""
717749
try:
718-
# Resolve path - if relative, use agent workspace
719-
if not os.path.isabs(file_path):
720-
workspace_dir = agent._get_agent_workspace_dir()
721-
full_path = os.path.join(workspace_dir, file_path)
722-
else:
723-
full_path = file_path
750+
full_path = _resolve_in_workspace(agent, file_path)
724751

725752
# Check if file exists
726753
if not os.path.exists(full_path):
@@ -769,12 +796,7 @@ def read_file_tool(agent: Any, file_path: str, **kwargs) -> str:
769796
str: File contents or error message
770797
"""
771798
try:
772-
# Resolve path - if relative, use agent workspace
773-
if not os.path.isabs(file_path):
774-
workspace_dir = agent._get_agent_workspace_dir()
775-
full_path = os.path.join(workspace_dir, file_path)
776-
else:
777-
full_path = file_path
799+
full_path = _resolve_in_workspace(agent, file_path)
778800

779801
# Check if file exists
780802
if not os.path.exists(full_path):
@@ -819,17 +841,7 @@ def list_directory_tool(
819841
str: Formatted list of directory contents
820842
"""
821843
try:
822-
# Resolve path - if relative or empty, use agent workspace
823-
if not directory_path or not os.path.isabs(directory_path):
824-
workspace_dir = agent._get_agent_workspace_dir()
825-
if directory_path:
826-
full_path = os.path.join(
827-
workspace_dir, directory_path
828-
)
829-
else:
830-
full_path = workspace_dir
831-
else:
832-
full_path = directory_path
844+
full_path = _resolve_in_workspace(agent, directory_path)
833845

834846
# Check if directory exists
835847
if not os.path.exists(full_path):
@@ -888,12 +900,7 @@ def delete_file_tool(agent: Any, file_path: str, **kwargs) -> str:
888900
str: Success message or error message
889901
"""
890902
try:
891-
# Resolve path - if relative, use agent workspace
892-
if not os.path.isabs(file_path):
893-
workspace_dir = agent._get_agent_workspace_dir()
894-
full_path = os.path.join(workspace_dir, file_path)
895-
else:
896-
full_path = file_path
903+
full_path = _resolve_in_workspace(agent, file_path)
897904

898905
# Check if file exists
899906
if not os.path.exists(full_path):
@@ -1129,16 +1136,7 @@ def grep_tool(
11291136
str: Matching lines or error message
11301137
"""
11311138
try:
1132-
# Resolve path
1133-
if not path or not os.path.isabs(path):
1134-
workspace_dir = agent._get_agent_workspace_dir()
1135-
full_path = (
1136-
os.path.join(workspace_dir, path)
1137-
if path
1138-
else workspace_dir
1139-
)
1140-
else:
1141-
full_path = path
1139+
full_path = _resolve_in_workspace(agent, path)
11421140

11431141
if not os.path.exists(full_path):
11441142
return f"Error: Path does not exist: {full_path}"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""The autonomous-loop file tools must stay inside the agent workspace.
2+
3+
The model picks `file_path` itself, so any prompt injection reaching the loop
4+
(a fetched page, a file the agent was asked to summarise, a sub-agent's task
5+
string) otherwise becomes arbitrary local file read, overwrite or deletion
6+
under the host process's privileges.
7+
8+
No agent, no network: the workspace is a tmp_path and the agent is a mock that
9+
only answers _get_agent_workspace_dir.
10+
"""
11+
12+
import os
13+
from unittest.mock import MagicMock
14+
15+
import pytest
16+
17+
from swarms.structs.autonomous_loop_utils import (
18+
create_file_tool,
19+
delete_file_tool,
20+
grep_tool,
21+
list_directory_tool,
22+
read_file_tool,
23+
update_file_tool,
24+
)
25+
26+
27+
@pytest.fixture
28+
def workspace(tmp_path):
29+
"""A workspace with a secret sitting just outside it."""
30+
ws = tmp_path / "agent_workspace" / "agents" / "worker-1"
31+
ws.mkdir(parents=True)
32+
(ws / "inside.txt").write_text("INSIDE-OK\n")
33+
(tmp_path / "outside_secret.txt").write_text("SECRET-OUTSIDE\n")
34+
35+
agent = MagicMock()
36+
agent._get_agent_workspace_dir.return_value = str(ws)
37+
return agent, ws, tmp_path / "outside_secret.txt"
38+
39+
40+
def test_every_file_tool_refuses_to_escape(workspace):
41+
agent, ws, secret = workspace
42+
relative_escape = os.path.relpath(secret, ws)
43+
44+
# signatures differ (grep takes the pattern first), so call each one
45+
for label, call in (
46+
(
47+
"read_file relative",
48+
lambda: read_file_tool(agent, relative_escape),
49+
),
50+
(
51+
"read_file absolute",
52+
lambda: read_file_tool(agent, str(secret)),
53+
),
54+
("grep", lambda: grep_tool(agent, "SECRET", relative_escape)),
55+
(
56+
"list_directory",
57+
lambda: list_directory_tool(agent, str(secret.parent)),
58+
),
59+
("delete_file", lambda: delete_file_tool(agent, str(secret))),
60+
):
61+
result = str(call())
62+
assert (
63+
"outside the agent workspace" in result
64+
), f"{label} did not refuse the escape: {result[:120]}"
65+
assert "SECRET-OUTSIDE" not in result
66+
67+
# writes must not land outside either, by relative or absolute path
68+
create_file_tool(agent, relative_escape, "overwritten")
69+
update_file_tool(agent, str(secret), "overwritten")
70+
assert secret.read_text() == "SECRET-OUTSIDE\n"
71+
72+
73+
def test_legitimate_workspace_access_still_works(workspace):
74+
agent, ws, _ = workspace
75+
76+
assert "INSIDE-OK" in str(read_file_tool(agent, "inside.txt"))
77+
# an absolute path inside the workspace is still fine: the check is
78+
# containment, not "relative paths only"
79+
assert "INSIDE-OK" in str(
80+
read_file_tool(agent, str(ws / "inside.txt"))
81+
)
82+
assert "inside.txt" in str(list_directory_tool(agent, ""))
83+
assert "Error" not in str(create_file_tool(agent, "new.txt", "x"))
84+
assert (ws / "new.txt").exists()

0 commit comments

Comments
 (0)