diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index fefb944..7703c7a 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -40,8 +40,9 @@ DEFAULT_TOOL_DIRECTIVES = """\ ### Workspace - Explore with `tree`/`listdir`/`glob_paths`/`regex_files`/`read_file` when unsure. - Stay under the workspace (or a path from `new_temporary_workspace`). Respect path denylists. -- Need a path outside the workspace? Call `request_directory_access` \ -(human approves; denylists apply) instead of retrying an escaped path. +- Need a path outside the workspace? Call `request_directory_access` with the \ +`mode` the escaped-path error names (human approves; denylists apply) instead of \ +retrying that path. ### Files - Prefer file tools over shell. `edit_replace` fails usually means a bad match — \ diff --git a/src/plyngent/tools/access.py b/src/plyngent/tools/access.py index e67fd05..7ea3269 100644 --- a/src/plyngent/tools/access.py +++ b/src/plyngent/tools/access.py @@ -254,11 +254,11 @@ async def request_directory_access(path: str, reason: str = "", mode: str = "rea """Request human approval to use a directory (or exact file) outside the workspace. Call this instead of retrying a path that failed with ``path escapes - workspace root``. ``mode`` is ``read`` (inspect files), ``write`` (also - modify files) or ``exec`` (also use it as ``run_argv`` / PTY cwd); the human - may approve a different level. Approval lasts for the session (or for this - process when ``--yes`` auto-approves). Paths matching the configured - denylist can never be granted. + workspace root``, passing the ``mode`` that error names. ``mode`` is ``read`` + (inspect files), ``write`` (also modify files) or ``exec`` (also use it as + ``run_argv`` / PTY cwd); the human may approve a different level. Approval + lasts for the session (or for this process when ``--yes`` auto-approves). + Paths matching the configured denylist can never be granted. """ requested = parse_access_mode(mode) if requested is None: diff --git a/src/plyngent/tools/workspace.py b/src/plyngent/tools/workspace.py index a650cfc..e90e69d 100644 --- a/src/plyngent/tools/workspace.py +++ b/src/plyngent/tools/workspace.py @@ -393,7 +393,7 @@ def resolve_path(path: str | Path, *, required: AccessMode = AccessMode.WRITE) - if not _under_any_root(resolved, instance, policy) and not _path_grant_covers(resolved, required): msg = ( f"path escapes workspace root ({root}): {path}; " - "call request_directory_access to request access to a directory outside the workspace" + f"call request_directory_access with mode={required.name.lower()!r} to request access outside the workspace" ) raise WorkspaceError(msg) # Normalize separators so denylist entries like ``/secrets/`` match on Windows. diff --git a/tests/test_tools/test_workspace.py b/tests/test_tools/test_workspace.py index cc3d1f9..18bc9f1 100644 --- a/tests/test_tools/test_workspace.py +++ b/tests/test_tools/test_workspace.py @@ -38,6 +38,14 @@ def test_escape_error_hints_request_tool(workspace: object) -> None: _ = resolve_path("../outside") +@pytest.mark.parametrize("required", [AccessMode.READ, AccessMode.WRITE, AccessMode.EXEC]) +def test_escape_error_names_the_mode_to_request(workspace: object, required: AccessMode) -> None: + """The refusal carries the lowest access mode that would cover the call.""" + del workspace + with pytest.raises(WorkspaceError, match=f"mode='{required.name.lower()}'"): + _ = resolve_path("../outside", required=required) + + def test_access_mode_parse_and_covers() -> None: assert parse_access_mode(" Read ") is AccessMode.READ assert parse_access_mode("EXEC") is AccessMode.EXEC