diff --git a/src/tool_approvals.py b/src/tool_approvals.py index d68adb82a..5f25cdb26 100644 --- a/src/tool_approvals.py +++ b/src/tool_approvals.py @@ -425,9 +425,13 @@ class ToolApprovalStore: # approval exists to admit an ungranted one. Seal its launch # boundary now, in the request's workspace, the same way an # ungranted filesystem action gets its root below (#6635). + # With no workspace bound, bash/python run in the agent + # workspace (agent_cwd), so the launch is sealed there. roots = request_authority.resource_roots - if not roots and workspace: - roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),) + if not roots: + from src.tool_execution import default_agent_workspace + roots = (FilesystemRoot.seal(workspace or default_agent_workspace(), + owner=_normalized_owner(owner)),) if len(roots) == 1: from dataclasses import replace process_authority = replace( @@ -455,8 +459,10 @@ class ToolApprovalStore: if tool_name in NATIVE_FILESYSTEM_TOOLS and backend_operation is not None and isinstance(backend_operation.resource, NativeBackendResource): try: roots = request_authority.resource_roots if request_authority is not None else () - if not roots and workspace and (request_authority is None or not request_authority.inherited): - roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),) + if not roots and (request_authority is None or not request_authority.inherited): + from src.tool_execution import default_agent_workspace + roots = (FilesystemRoot.seal(workspace or default_agent_workspace(), + owner=_normalized_owner(owner)),) resource_operation = resolve_filesystem_operation( ExactOperation.normalize(tool_name, content), roots=roots, workspace=workspace or "", request_id=request_authority.request_id if request_authority is not None else "") diff --git a/src/tool_execution.py b/src/tool_execution.py index 599eade99..bde68df30 100644 --- a/src/tool_execution.py +++ b/src/tool_execution.py @@ -1221,6 +1221,12 @@ def agent_cwd() -> str: if vetted is None: raise RuntimeError("active workspace is not a safe real directory") return vetted + return default_agent_workspace() + + +def default_agent_workspace() -> str: + """The agent workspace under the data dir, where subprocesses run when no + workspace is bound.""" resolved = os.path.realpath(_AGENT_WORKDIR) if resolved not in _agent_readable_data_subdirs() or vet_workspace(resolved) != resolved: raise RuntimeError("agent workspace is not a safe real directory") diff --git a/tests/test_exact_approval_ungranted_process.py b/tests/test_exact_approval_ungranted_process.py index fffb5c011..15064178f 100644 --- a/tests/test_exact_approval_ungranted_process.py +++ b/tests/test_exact_approval_ungranted_process.py @@ -79,3 +79,69 @@ async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path assert pending.process_operation is None _, result = await _dispatch(child, "bash", "printf approved-run", exact) assert result.get("blocked") is True + + +@pytest.fixture +def agent_workspace(): + import os + from src.constants import AGENT_WORKSPACE_DIR + os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True) + return os.path.realpath(AGENT_WORKSPACE_DIR) + + +def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agent_workspace): + # 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") + + bound = pending.process_operation + assert bound is not None + assert bound.launch is not None + assert bound.launch.scope.root.path == agent_workspace + + +async def test_approved_bash_without_a_workspace_runs_in_the_agent_workspace(agent_workspace): + authority = _authority("", "get_workspace") + _, exact = _approve(authority, "bash", "pwd") + + description, result = await _dispatch(authority, "bash", "pwd", exact) + + assert result.get("failure_kind") != "resource_identity_denied", result + assert result.get("exit_code") == 0, result + containment = result.get("containment") or {} + assert containment.get("workspace", agent_workspace) == agent_workspace, containment + + +async def test_inherited_authority_without_a_workspace_still_cannot_widen(agent_workspace): + from dataclasses import replace + child = replace(_authority("", "get_workspace"), inherited=True) + pending, exact = _approve(child, "bash", "pwd") + + assert pending.process_operation is None + _, result = await _dispatch(child, "bash", "pwd", exact) + assert result.get("blocked") is True + + +async def test_approved_write_without_a_workspace_lands_in_the_agent_workspace(agent_workspace): + # write_file had the same gap: with no workspace the approval sealed no + # root, so "approve" ended in "no sealed resource identity". + import os + target = "approved-write-6635.txt" + content = f"{target}\nhello" + authority = _authority("", "get_workspace") + pending, exact = _approve(authority, "write_file", content) + assert pending.resource_operation is not None + + _, result = await _dispatch(authority, "write_file", content, exact) + + assert result.get("failure_kind") != "resource_identity_denied", result + written = os.path.join(agent_workspace, target) + try: + with open(written) as fh: + assert fh.read().strip() == "hello" + finally: + if os.path.exists(written): + os.remove(written)