Skip to content

feat(workspace-tree): allow UI-only navigation through authorized escape-target symlinks#4656

Open
rodboev wants to merge 9 commits into
nesquena:masterfrom
rodboev:pr/4582-browser-only-escape-nav
Open

feat(workspace-tree): allow UI-only navigation through authorized escape-target symlinks#4656
rodboev wants to merge 9 commits into
nesquena:masterfrom
rodboev:pr/4582-browser-only-escape-nav

Conversation

@rodboev

@rodboev rodboev commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • Phase 1 already surfaced escape-target symlinks as display-only rows, but the browser still cannot do anything beyond showing an informational dialog when the user clicks them.
  • The shared workspace file helpers deliberately serve both browser browsing and agent/tool reads, so widening the existing shared routes with a generic escape flag would break the containment boundary the maintainer explicitly called out.
  • The narrow fix is a browser-only authorization flow for one surfaced symlink at a time, with descendant reads re-anchored to the verified external root and the shared agent/tool path left unchanged.

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 authorized escape route family for browser-only list/read/raw access, require a real browser Origin for 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:

pytest tests/test_symlink_cycle_detection.py -v --timeout=60
pytest tests/test_workspace_symlink_containment.py -v --timeout=60
pytest tests/test_routes_file_api_toctou.py -v --timeout=60
pytest tests/test_issue4582_workspace_escape_navigation.py -v --timeout=60
npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js"

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

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR converts the Phase 1 display-only escape-symlink affordance into a functional browser-only read flow. A short-lived token is minted via a new POST endpoint (Origin + CSRF gated), and three dedicated GET endpoints (/api/escape/list, /api/escape/file/read, /api/escape/file/raw) serve descendants re-anchored to the authorized external root without relaxing the shared workspace helpers used by agents and tools.

  • Backend (api/workspace.py, api/routes.py): Adds an in-memory token store with per-token re-verification (live symlink target compared to the stored target at every use), typed EscapeAuthorizationExpiredError for clean 403 dispatch, and nested-escape containment via the existing safe_resolve_ws barrier.
  • Frontend (static/workspace.js, static/ui.js): Adds a client-side grant store with best-match path lookup, a unified _workspaceRouteForPath dispatcher that transparently routes escape subtree requests through the new endpoints, and read-only affordance suppression (no delete, rename, upload, drop, or create inside authorized external subtrees).
  • Tests: Four test files cover live server round-trips, unit-level containment, path virtualization, stale-grant refresh, and frontend helper behavior via Node.js.

Confidence Score: 5/5

Safe to merge. The escape authorization is well-isolated from the shared agent/tool workspace helpers, the token re-verification on every use is robust, and the frontend read-only suppression covers all mutating entry points.

The containment boundary is enforced at both grant time (_escape_surface_target + safe_resolve_ws) and at every subsequent use (_escape_authorization_record live-target comparison). Nested-escape traversal is blocked because safe_resolve_ws refuses to follow an escape symlink when resolving the parent path. The two findings are both style-level: redundant always-true typeof guards in ui.js and a confirmation dialog that hides its cancel button. Neither affects the security model or produces incorrect behavior.

No files require special attention.

Important Files Changed

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
Loading
%%{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
Loading

Reviews (4): Last reviewed commit: "Require real browser origins for escape ..." | Re-trigger Greptile

Comment thread static/workspace.js
Comment thread api/routes.py
Comment thread api/routes.py
Comment thread api/workspace.py Outdated
Comment thread static/workspace.js
@rodboev rodboev force-pushed the pr/4582-browser-only-escape-nav branch from cb9bde9 to 60fb725 Compare June 22, 2026 04:02
@cutter-sh

cutter-sh Bot commented Jun 22, 2026

Copy link
Copy Markdown

🎬 Cutter preview — PR #4656

Authorize and browse external symlink
Authorize and browse external symlink — Authorized escape symlinks expand into a read-only file tree for browsing without edit actions.
clicking the external symlink opens the read-only authorization dialog
clicking the external symlink opens the read-only authorization dialog — External symlinks prompt a read-only confirmation before opening outside the workspace.

@rodboev rodboev force-pushed the pr/4582-browser-only-escape-nav branch from 4f434ef to 40fc69b Compare June 23, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L Large PR (>10 files or >250 LOC)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(workspace-tree): opt-in navigation through escape-target symlinks (Phase 2 backend authorization)

2 participants