Skip to content

Commit 8284b58

Browse files
bradjin8cursoragent
andcommitted
Align /api/validate-path with validate_workspace_path (PR #22)
- POST /api/validate-path now uses the same realpath + marker checks as set-workspace; returns canonical path and structured errors on failure. - README documents WORKSPACE_PATH as trusted-operator tilde expansion only. - Config page shows server error text when validation fails. - Docstrings + symlink-test CI note; TOCTOU comment after realpath. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a3b89bf commit 8284b58

6 files changed

Lines changed: 53 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ The application automatically detects your Cursor workspace storage location:
122122

123123
To override, set the `WORKSPACE_PATH` environment variable or use the Configuration page in the web UI.
124124

125+
Paths submitted through **`POST /api/set-workspace`** (and **`POST /api/validate-path`**) are validated the same way: canonical resolution (`realpath`), directory checks, and Cursor workspace markers (`state.vscdb` under immediate subdirectories). The **`WORKSPACE_PATH`** environment variable is only tilde-expanded — it is a **trusted-operator** escape hatch for automation and known-good paths, not a substitute for those API checks when untrusted input matters.
126+
125127
Cursor CLI agent sessions are read from `~/.cursor/chats/` (the default path used by the `cursor agent` CLI). Override with the `CLI_CHATS_PATH` environment variable.
126128

127129
## Project Structure

api/config_api.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
from flask import Blueprint, jsonify, request
1414

15-
from utils.path_helpers import expand_tilde_path
1615
from utils.path_validation import WorkspacePathError, validate_workspace_path
1716
from utils.workspace_path import set_workspace_path_override
1817

@@ -51,23 +50,30 @@ def detect_environment():
5150

5251
@bp.route("/api/validate-path", methods=["POST"])
5352
def validate_path():
53+
"""Same path rules as POST /api/set-workspace: realpath, markers (issue #15)."""
5454
try:
5555
body = request.get_json(silent=True) or {}
56-
workspace_path = body.get("path", "")
57-
expanded = expand_tilde_path(workspace_path)
58-
59-
if not os.path.isdir(expanded):
60-
return jsonify({"valid": False, "error": "Path does not exist"})
56+
raw = body.get("path", "")
57+
try:
58+
canonical = validate_workspace_path(raw)
59+
except WorkspacePathError as e:
60+
return jsonify({"valid": False, "error": str(e), "workspaceCount": 0})
6161

6262
workspace_count = 0
63-
for name in os.listdir(expanded):
64-
full = os.path.join(expanded, name)
63+
for name in os.listdir(canonical):
64+
full = os.path.join(canonical, name)
6565
if os.path.isdir(full):
6666
db = os.path.join(full, "state.vscdb")
6767
if os.path.isfile(db):
6868
workspace_count += 1
6969

70-
return jsonify({"valid": workspace_count > 0, "workspaceCount": workspace_count})
70+
return jsonify(
71+
{
72+
"valid": workspace_count > 0,
73+
"workspaceCount": workspace_count,
74+
"path": canonical,
75+
}
76+
)
7177

7278
except Exception as e:
7379
print(f"Validation error: {e}")

templates/config.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ <h1>Configuration</h1>
9696
setTimeout(() => { window.location.href = '/'; }, 1000);
9797
} else {
9898
statusEl.className = 'alert alert-danger';
99-
statusEl.textContent = 'No workspaces found in the specified location';
99+
statusEl.textContent = data.error || 'No workspaces found in the specified location';
100100
statusEl.style.display = 'block';
101101
}
102102
} catch (e) {

tests/test_workspace_path_validation.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def test_traversal_into_non_workspace_is_rejected(self):
114114
validate_workspace_path(escape)
115115

116116
# ─── Symlink-escape class ──────────────────────────────────────
117+
# POSIX-only; CI runs tests on ubuntu-latest so these still run in CI.
117118

118119
@unittest.skipIf(sys.platform == "win32", "POSIX symlinks only")
119120
def test_symlink_to_non_workspace_is_rejected(self):
@@ -196,6 +197,24 @@ def test_dict_with_valid_path_returns_200_with_canonical(self):
196197
self.assertTrue(body["success"])
197198
self.assertEqual(body["path"], os.path.realpath(storage))
198199

200+
def test_validate_path_returns_canonical_and_count(self):
201+
storage = _make_cursor_workspace_dir(self.tmp)
202+
resp = self.client.post("/api/validate-path", json={"path": storage})
203+
self.assertEqual(resp.status_code, 200)
204+
data = resp.get_json()
205+
self.assertTrue(data["valid"])
206+
self.assertGreaterEqual(data["workspaceCount"], 1)
207+
self.assertEqual(data["path"], os.path.realpath(storage))
208+
209+
def test_validate_path_invalid_returns_error(self):
210+
plain = os.path.join(self.tmp, "no-markers")
211+
os.makedirs(plain)
212+
resp = self.client.post("/api/validate-path", json={"path": plain})
213+
self.assertEqual(resp.status_code, 200)
214+
data = resp.get_json()
215+
self.assertFalse(data["valid"])
216+
self.assertIn("error", data)
217+
199218

200219
if __name__ == "__main__":
201220
unittest.main()

utils/path_validation.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Validation for workspace paths submitted via /api/set-workspace.
1+
"""Validation for workspace paths submitted via /api/set-workspace and /api/validate-path.
22
33
Lives outside ``api/`` so the unit tests can import it without pulling
44
Flask into scope (the existing test suite intentionally avoids Flask —
@@ -30,9 +30,10 @@ class WorkspacePathError(ValueError):
3030
def _has_cursor_workspace_markers(directory: str) -> bool:
3131
"""Return True iff at least one immediate subdirectory contains state.vscdb.
3232
33-
Same heuristic /api/validate-path already uses to recognise a Cursor
34-
workspaceStorage directory. Used here as the final accept gate so that
35-
a symlink whose realpath happens to leave the user's own data area
33+
Same heuristic as POST /api/validate-path (counts workspaces). Nested
34+
layouts beyond one level are out of scope per issue #15. Used here as the
35+
final accept gate so that a symlink whose realpath happens to leave the
36+
user's own data area
3637
(e.g. /tmp, /etc) is rejected — those locations have no state.vscdb.
3738
"""
3839
try:
@@ -50,7 +51,9 @@ def _has_cursor_workspace_markers(directory: str) -> bool:
5051

5152

5253
def validate_workspace_path(raw_path: str) -> str:
53-
"""Validate a /api/set-workspace input and return the canonical real path.
54+
"""Validate a workspace path input and return the canonical real path.
55+
56+
Used by POST /api/set-workspace and POST /api/validate-path (issue #15).
5457
5558
Raises :class:`WorkspacePathError` if the path:
5659
- is empty / not a string,
@@ -69,6 +72,8 @@ def validate_workspace_path(raw_path: str) -> str:
6972
# realpath() collapses `..` AND resolves symlinks. Both classes of escape
7073
# become equivalent to whatever is actually on disk.
7174
real = os.path.realpath(expanded)
75+
# Classic TOCTOU: the tree could change before listdir below; low practical
76+
# risk for this single-user local tool (issue #15 review).
7277

7378
if not os.path.exists(real):
7479
raise WorkspacePathError("path does not exist")

utils/workspace_path.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ def get_default_workspace_path() -> str:
5858

5959

6060
def resolve_workspace_path() -> str:
61-
"""Return the effective workspace path (override > env var > default)."""
61+
"""Return the effective workspace path (override > env var > default).
62+
63+
Override comes from POST /api/set-workspace (validated). ``WORKSPACE_PATH``
64+
is only tilde-expanded — trusted-operator escape hatch, not the same checks
65+
as the API (issue #15).
66+
"""
6267
if _workspace_path_override:
6368
return expand_tilde_path(_workspace_path_override)
6469
env_path = os.environ.get("WORKSPACE_PATH", "").strip()

0 commit comments

Comments
 (0)