feat(workspace-tree): allow UI-only navigation through authorized escape-target symlinks#4656
feat(workspace-tree): allow UI-only navigation through authorized escape-target symlinks#4656rodboev wants to merge 9 commits into
Conversation
|
| Filename | Overview |
|---|---|
| api/workspace.py | Adds the escape-authorization subsystem: token store, prune, mint, re-verify, and three descendant-access helpers (list/read/raw). Containment is correctly delegated to the existing safe_resolve_ws/list_dir/read_file_content helpers; the re-verification in _escape_authorization_record (comparing the live-resolved target to the stored target) is robust. |
| api/routes.py | Adds four new escape endpoints (authorize POST, list/read/raw GET), gated by Origin+CSRF for the POST and by the bearer token for the GETs. Exception discrimination now uses a typed EscapeAuthorizationExpiredError. No issues found. |
| static/workspace.js | Adds the client-side grant store, _workspaceRouteForPath dispatcher, and authorizeWorkspaceEscapeNavigation flow. Expiry cleanup during iteration is safe (Object.keys snapshot). Minor: the authorization confirmation dialog hides its cancel button despite framing the message as a yes/no question. |
| static/ui.js | Wires the escape-auth flow into click handlers and suppresses mutating affordances for read-only escape items. Contains redundant typeof isReadOnlyEscape !== 'undefined' guards that are always true because isReadOnlyEscape is always in scope. |
| static/i18n.js | Adds three new i18n keys across all 14 locales: external_link_open_confirm (updated), external_link_read_only, and external_link_grant_expired. All non-English locales currently carry the English strings; consistent with existing fallback pattern in the file. |
| tests/test_issue4582_workspace_escape_navigation.py | New integration test suite covering live server round-trips for file-symlink and dir-symlink grants, path virtualization, nested-escape containment, and frontend helper behavior via Node.js. |
| tests/test_workspace_symlink_containment.py | Extends the existing containment suite with four new unit tests: re-anchoring descendants, expiry on symlink retarget, independence of concurrent grants, and nested-escape display-only enforcement. |
| tests/test_symlink_cycle_detection.py | Adds an escape-authorized listing virtualization test and _DIR_FD_OK platform guards for three pre-existing tests. No issues. |
| tests/test_routes_file_api_toctou.py | Adds two source-inspection tests verifying that the escape raw/read routes use the authorized helpers and that raw_authorized_escape_target re-anchors through safe_resolve_ws. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Browser as Browser (UI)
participant API as Backend API
participant WS as workspace.py
participant FS as Filesystem
Browser->>API: POST /api/escape/authorize
API->>WS: authorize_escape_target(workspace, sid, rel)
WS->>FS: _escape_surface_target() verify symlink escapes
WS->>FS: _escape_authorized_root() determine external root
WS-->>API: token, path, is_dir, expires_at
API-->>Browser: grant stored client-side
Browser->>API: "GET /api/escape/list?token=&path=escape/subdir"
API->>WS: list_authorized_escape_dir()
WS->>WS: _escape_authorization_record() re-verify live target
WS->>FS: list_dir(external_root, external_rel)
WS-->>API: entries with virtualized paths
API-->>Browser: entries, read_only true
Browser->>API: "GET /api/escape/file/raw?token=&path=escape/file.txt"
API->>WS: raw_authorized_escape_target()
WS->>WS: _escape_authorization_record() re-verify
WS->>FS: safe_resolve_ws(external_root, external_rel)
WS-->>API: anchor_root, target
API-->>Browser: file bytes no-store CSP sandbox
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Browser as Browser (UI)
participant API as Backend API
participant WS as workspace.py
participant FS as Filesystem
Browser->>API: POST /api/escape/authorize
API->>WS: authorize_escape_target(workspace, sid, rel)
WS->>FS: _escape_surface_target() verify symlink escapes
WS->>FS: _escape_authorized_root() determine external root
WS-->>API: token, path, is_dir, expires_at
API-->>Browser: grant stored client-side
Browser->>API: "GET /api/escape/list?token=&path=escape/subdir"
API->>WS: list_authorized_escape_dir()
WS->>WS: _escape_authorization_record() re-verify live target
WS->>FS: list_dir(external_root, external_rel)
WS-->>API: entries with virtualized paths
API-->>Browser: entries, read_only true
Browser->>API: "GET /api/escape/file/raw?token=&path=escape/file.txt"
API->>WS: raw_authorized_escape_target()
WS->>WS: _escape_authorization_record() re-verify
WS->>FS: safe_resolve_ws(external_root, external_rel)
WS-->>API: anchor_root, target
API-->>Browser: file bytes no-store CSP sandbox
Reviews (4): Last reviewed commit: "Require real browser origins for escape ..." | Re-trigger Greptile
cb9bde9 to
60fb725
Compare
🎬 Cutter preview — PR #4656 |
…a#4582) # Conflicts: # static/workspace.js
# Conflicts: # static/workspace.js
4f434ef to
40fc69b
Compare


Thinking Path
What Changed
api/workspace.py: add browser-only escape authorization, nested-symlink containment, and parent-anchor file handling for external file targets without relaxing the shared workspace-root helpers.api/routes.py: add a dedicated authorizedescaperoute family for browser-only list/read/raw access, require a real browserOriginfor grant minting, and keep the shared routes fail-closed.static/ui.js: replace the current info-only external-link click path with authorize-then-browse behavior, while suppressing mutating affordances inside authorized external subtrees.static/workspace.js: route only the authorized subtree through the new browser-only endpoints, refresh stale exact grants on repeat root clicks without reprompting, and keep displayed paths virtualized to the surfaced symlink path.static/i18n.js: add confirmation, stale-grant, and read-only external-subtree copy.tests/test_symlink_cycle_detection.py,tests/test_workspace_symlink_containment.py,tests/test_routes_file_api_toctou.py,tests/test_issue4582_workspace_escape_navigation.py: pin browser-origin grant minting, descendant containment, nested-child fail-closed behavior, file-symlink parent anchoring, stale-grant refresh, and agent-path non-regression.Why It Matters
This turns the shipped Phase 1 external-link affordance into a usable browser workflow without weakening the shared workspace boundary for agent/tool callers. Users can browse a confirmed external subtree read-only, reauthorize the same surfaced row after backend invalidation, and open external file symlinks through the anchored-safe read path while the backend still keeps the resolved outside path private and preserves fail-closed behavior everywhere else.
Verification
Passed locally:
Full-suite CI context, not run locally:
pytest tests/ -v --timeout=60.Upstream
Closes #4582.
Builds on the display-only escape-target groundwork from #4573 / #4581, shipped in #4610.
Model Used
GPT 5.5 via Codex CLI