fix(agent): give file tools the agent workspace as root when no workspace is set (#6722)

* fix(agent): give file tools the agent workspace as root when no workspace is set (#6679)

With no workspace bound, bash/python run in data/agent_workspace, but
RequestAuthority sealed no filesystem root, so every native file tool
failed with "requires a sealed resource root", and an approved
write_file failed with "no sealed resource identity". Seal the agent
workspace instead, and resolve relative paths in _resolve_tool_path
against it rather than the server's cwd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: re-seal resource roots when a test swaps the authority owner (#6679)

A no-workspace authority now seals the agent workspace for its owner, so
replace(authority, owner="bob") copied alice's root and was rejected
before the approval-binding check the tests are about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agent): only seal a vetted agent workspace as the file-tool root (#6679)

Apply the agent_cwd() check before sealing data/agent_workspace: it must
be a real directory in the readable data subdirs, otherwise no root is
sealed. A symlinked workspace no longer redirects the file tools or the
bash/python launch scope. Tighten the dispatch test assertions to the
exact 'outside the sealed roots' message.

* refactor(agent): vet the no-workspace file root with default_agent_workspace (#6679)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grzegorz
2026-10-26 09:46:52 -06:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9d0d3720f4
commit 1000b6582c
7 changed files with 109 additions and 15 deletions
+80 -4
View File
@@ -123,12 +123,88 @@ def test_blocks_listing_the_data_directory_itself():
_resolve_tool_path(DATA_DIR)
def test_blocks_app_state_reached_by_relative_path(monkeypatch):
"""The data directory is a relative hop from the checkout root, so
def test_blocks_app_state_reached_by_relative_path(tmp_path, monkeypatch):
"""The data directory is a relative hop from the agent workspace, so
confinement cannot depend on the model supplying an absolute path."""
monkeypatch.chdir(os.path.dirname(DATA_DIR))
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
with pytest.raises(ValueError, match="application state"):
_resolve_tool_path(os.path.join(os.path.basename(DATA_DIR), "sessions.json"))
current_execution._resolve_tool_path(os.path.join("..", "sessions.json"))
def test_relative_path_resolves_in_agent_workspace_not_server_cwd(tmp_path, monkeypatch):
"""With no workspace bound, bash runs in the agent workspace, so a
relative write_file/read_file path must land there too (#6679), not in
the server's cwd, which is outside the allowed roots."""
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
server_cwd = tmp_path / "checkout"
server_cwd.mkdir()
monkeypatch.chdir(server_cwd)
token = current_execution._active_workspace.set(None)
try:
assert current_execution._resolve_tool_path("test.txt") == os.path.join(
os.path.realpath(workspace), "test.txt"
)
assert current_execution._resolve_search_root("sub") == os.path.join(
os.path.realpath(workspace), "sub"
)
finally:
current_execution._active_workspace.reset(token)
def test_request_without_workspace_seals_the_agent_workspace(tmp_path, monkeypatch):
"""With no workspace, the native file tools get the folder bash/python
already run in (#6679) instead of no root at all, and a relative path
lands there while app state next to it stays out of reach."""
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resource_binding import resolve_filesystem_operation
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
(data_dir / "sessions.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("write_file"),))
assert [r.path for r in authority.resource_roots] == [os.path.realpath(workspace)]
bound = resolve_filesystem_operation(
ExactOperation.normalize("write_file", "test.txt\nhello"), roots=authority.resource_roots)
assert bound.bindings[0].resource.path == os.path.join(os.path.realpath(workspace), "test.txt")
with pytest.raises(ValueError, match="outside the sealed roots"):
resolve_filesystem_operation(
ExactOperation.normalize("read_file", "../sessions.json"), roots=authority.resource_roots)
def test_symlinked_agent_workspace_is_not_sealed(tmp_path, monkeypatch):
"""A symlinked agent workspace must not become the file-tool root:
agent_cwd() and create_directories() refuse the same layout."""
from src.agent_runtime.authority import OperationGrant, RequestAuthority
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
(elsewhere / "notes.txt").write_text("private", encoding="utf-8")
try:
workspace.symlink_to(elsewhere, target_is_directory=True)
except OSError:
pytest.skip("cannot create symlink")
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("read_file"),))
assert authority.resource_roots == ()
def test_blocks_app_state_reached_through_a_symlink(tmp_path):
+2 -1
View File
@@ -281,7 +281,8 @@ async def test_browser_approval_application_binding_is_exact(producer, field, va
original = authority()
content = '{"action":"session_info"}'
approval = approval_for(original, "private_browser", content)
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
# resource_roots=None re-seals the root for the changed owner.
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=(), resource_roots=None)
_, result = await dispatch(changed, "private_browser", content, approval)
assert result["exit_code"] == 1 and not approval._claimed
@@ -93,7 +93,6 @@ def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agen
# Agent mode with no folder picked is the default. bash already runs in the
# agent workspace there (agent_cwd), so that is where its approval seals.
authority = _authority("", "get_workspace")
assert authority.resource_roots == ()
pending, _ = _approve(authority, "bash", "pwd")
+2 -1
View File
@@ -117,7 +117,8 @@ async def test_remote_approval_is_bound_to_operation_and_request(manager, change
content = '{"record":"two"}'
else:
authority = replace(authority, **{"request": {"request_id": "other"}, "owner": {"owner": "bob"},
"session": {"session_id": "other"}}[change])
"session": {"session_id": "other"}}[change],
resource_roots=None) # re-seal the root for the changed owner
_, result = await dispatch(authority, tool, content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["exit_code"] == 1
+6 -3
View File
@@ -255,7 +255,8 @@ async def test_read_file_dispatch_blocks_etc_shadow(monkeypatch):
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert "outside the sealed roots" in (result.get("error") or "")
assert result.get("blocked") is True
assert result.get("exit_code") == 1
@@ -285,7 +286,8 @@ async def test_write_file_dispatch_blocks_authorized_keys(monkeypatch):
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert "outside the sealed roots" in (result.get("error") or "")
assert result.get("blocked") is True
assert result.get("exit_code") == 1
@@ -349,7 +351,8 @@ async def test_write_file_dispatch_blocks_cron(monkeypatch):
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert "outside the sealed roots" in (result.get("error") or "")
assert result.get("blocked") is True
assert result.get("exit_code") == 1
@pytest.mark.parametrize("filename", ["auth.json", "app.db", "settings.json"])
def test_application_secrets_are_sensitive_paths(filename):