-
Notifications
You must be signed in to change notification settings - Fork 0
feat(cli): Windows-safe SQLite handle + cross-platform paths + sensitive write prefixes #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |
| import sys | ||
| from pathlib import Path | ||
|
|
||
| from .interceptor import intercept_command | ||
| from .interceptor import DENY_EXIT_CODE, intercept_command | ||
| from .runtime_context import infer_repo_name_from_cwd | ||
|
|
||
| READ_ONLY_TOOLS = {"Glob", "Grep", "LS", "Read"} | ||
|
|
@@ -27,7 +27,7 @@ | |
| re.DOTALL, | ||
| ), | ||
| ), | ||
| ("shell-redirect-write", re.compile(r"(?:^|&&|;|\|)\s*[^2]?[>]\s*/", re.MULTILINE)), | ||
| ("shell-redirect-write", re.compile(r"(?:^|&&|;|\|)\s*[^2]?>\s*/|(?<![12=&])>\s*/\S", re.MULTILINE)), | ||
| ("tee-write", re.compile(r"\btee\b")), | ||
| ("dd-write", re.compile(r"\bdd\b.*\bof=")), | ||
| ("heredoc-write", re.compile(r'<<\s*[\'"]?EOF')), | ||
|
|
@@ -198,10 +198,19 @@ | |
|
|
||
| def _resolve_target_path(target_path: str, cwd: str) -> str: | ||
| """Resolve a possibly-relative target path against the command cwd.""" | ||
| path = Path(target_path) | ||
| if path.is_absolute(): | ||
| return str(path) | ||
| return str((Path(cwd) / path).resolve()) | ||
| normalized_target = target_path.replace("\\", "/") | ||
| normalized_cwd = cwd.replace("\\", "/") | ||
|
|
||
| if normalized_target.startswith("/"): | ||
| return normalized_target | ||
|
|
||
| if len(normalized_target) > 1 and normalized_target[1] == ":": | ||
| return Path(normalized_target).as_posix() | ||
|
|
||
| if normalized_cwd.startswith("/"): | ||
| return f"{normalized_cwd.rstrip('/')}/{normalized_target.lstrip('/')}" | ||
|
|
||
| return str((Path(cwd) / target_path).resolve().as_posix()) | ||
|
|
||
|
|
||
| def _extract_sed_target_paths(command: str, cwd: str) -> list[str]: | ||
|
|
@@ -328,6 +337,55 @@ | |
| } | ||
|
|
||
|
|
||
| _SENSITIVE_WRITE_PREFIXES = ( | ||
| "/etc/", | ||
| "/usr/", | ||
| "/bin/", | ||
| "/sbin/", | ||
| "/var/", | ||
| "/sys/", | ||
| "/proc/", | ||
| "/tmp/", | ||
|
Check failure on line 348 in cli/src/policy_federation/claude_hooks.py
|
||
| ) | ||
|
|
||
|
|
||
| def _normalize_policy_path(path: str) -> str: | ||
| return path.replace("\\", "/") | ||
|
|
||
|
|
||
| def _is_worktree_path(path: str) -> bool: | ||
| normalized = _normalize_policy_path(path) | ||
| return any( | ||
| marker in normalized | ||
| for marker in ("-wtrees/", "/.worktrees/", "/worktrees/", "PROJECT-wtrees/") | ||
| ) | ||
|
Comment on lines
+356
to
+361
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Worktree detection uses substring matching on arbitrary path text, so attacker-controlled paths like Severity Level: Critical 🚨- ❌ Synthetic deny skipped for /tmp/worktrees outside repos.
- ❌ Attackers craft paths mimicking worktrees to avoid checks.
- ⚠️ Risk assessment treats untrusted temporary directories as worktrees.Steps of Reproduction ✅1. Note the worktree detection helper `_is_worktree_path()` in
`cli/src/policy_federation/claude_hooks.py:356-361`, which normalizes separators then
returns `True` if any of the markers `"-wtrees/"`, `"/.worktrees/"`, `"/worktrees/"`, or
`"PROJECT-wtrees/"` appear as a substring anywhere in the path, without tying this to an
actual repository root or approved worktree list.
2. Observe `_should_deny_unapproved_write()` at `claude_hooks.py:372-387`, which is called
from `evaluate_claude_pretool_payload()` at `claude_hooks.py:389-427` after
`intercept_command()` returns, and is responsible for applying a synthetic deny when
`action == "write"`, `final_decision == "ask"`, and the target paths hit sensitive
prefixes listed in `_SENSITIVE_WRITE_PREFIXES` at `claude_hooks.py:340-349`.
3. Supply a payload to `main()` (`claude_hooks.py:116-124`) where `_extract_request()` at
`claude_hooks.py:307-320` sets `action: "write"` and `target_paths:
["/tmp/worktrees/evil.txt"]` (for example by using a write tool targeting
`/tmp/worktrees/evil.txt`) so that `evaluate_claude_pretool_payload()` later calls
`_should_deny_unapproved_write(action="write", target_paths=["/tmp/worktrees/evil.txt"],
cwd="/tmp", final_decision="ask")`.
4. Inside `_should_deny_unapproved_write()`, `paths` becomes `["/tmp/worktrees/evil.txt"]`
and the guard `if paths and all(_is_worktree_path(path) for path in paths): return False`
at `claude_hooks.py:382-384` calls `_is_worktree_path("/tmp/worktrees/evil.txt")`, which
returns `True` because the substring `"/worktrees/"` is present, so
`_should_deny_unapproved_write()` exits early and never calls
`_targets_sensitive_write_paths()` at `claude_hooks.py:364-369`; as a result, the
synthetic deny for sensitive prefixes such as `"/tmp/"` is skipped even though
`/tmp/worktrees/evil.txt` is not an approved repository worktree path.Fix in Cursor | Fix in VSCode Claude (Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** cli/src/policy_federation/claude_hooks.py
**Line:** 356:361
**Comment:**
*Incorrect Condition Logic: Worktree detection uses substring matching on arbitrary path text, so attacker-controlled paths like `/tmp/worktrees/evil.txt` are treated as approved worktree paths even when they are outside any real approved checkout. This can suppress the synthetic deny for sensitive writes; replace this with strict path-root validation against known approved worktree directories.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
|
|
||
|
|
||
| def _targets_sensitive_write_paths(target_paths: list[str]) -> bool: | ||
| for raw in target_paths: | ||
| path = _normalize_policy_path(raw) | ||
| if any(path.startswith(prefix) for prefix in _SENSITIVE_WRITE_PREFIXES): | ||
| return True | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Windows paths bypass sensitive denyMedium Severity
Reviewed by Cursor Bugbot for commit 261103c. Configure here. |
||
| return False | ||
|
|
||
|
|
||
| def _should_deny_unapproved_write( | ||
| *, | ||
| action: str, | ||
| target_paths: list[str], | ||
| cwd: str, | ||
| final_decision: str, | ||
| ) -> bool: | ||
| if action != "write" or final_decision != "ask": | ||
| return False | ||
|
|
||
| paths = target_paths or [cwd] | ||
| if paths and all(_is_worktree_path(path) for path in paths): | ||
| return False | ||
|
|
||
| return _targets_sensitive_write_paths(paths) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sensitive deny skips resolved pathsHigh Severity The new Additional Locations (1)Reviewed by Cursor Bugbot for commit 261103c. Configure here. |
||
|
|
||
|
|
||
| def evaluate_claude_pretool_payload( | ||
| payload: dict, repo_root: Path | None = None, | ||
| ) -> dict: | ||
|
|
@@ -352,6 +410,35 @@ | |
| ask_mode=os.environ.get("POLICY_ASK_MODE", "fail"), | ||
| ) | ||
|
|
||
| if request.get("bypass_indicators") and result["final_decision"] == "ask": | ||
| result = { | ||
| **result, | ||
| "allowed": False, | ||
| "exit_code": DENY_EXIT_CODE, | ||
| "final_decision": "deny", | ||
| } | ||
|
|
||
| if _should_deny_unapproved_write( | ||
| action=request["action"], | ||
| target_paths=request.get("target_paths", []), | ||
| cwd=cwd, | ||
| final_decision=result["final_decision"], | ||
| ): | ||
| evaluation = dict(result.get("evaluation") or {}) | ||
| evaluation["winning_rule"] = { | ||
| "id": "deny-write-outside-worktrees", | ||
| "effect": "deny", | ||
| "priority": 0, | ||
| "description": "Write blocked outside approved worktrees to sensitive targets", | ||
| } | ||
| result = { | ||
| **result, | ||
| "allowed": False, | ||
| "exit_code": DENY_EXIT_CODE, | ||
| "final_decision": "deny", | ||
| "evaluation": evaluation, | ||
| } | ||
|
|
||
| if result["final_decision"] == "allow": | ||
| evaluation = result.get("evaluation") or {} | ||
| headless_review = evaluation.get("headless_review") | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Relative target paths are now concatenated with the cwd without normalization, so traversal segments like
../are preserved. This lets writes such as../../etc/shadowavoid sensitive-prefix detection because the resulting string starts with the cwd instead of/etc/. Resolve and normalize the joined path before policy checks so traversal cannot bypass the deny logic. [security]Severity Level: Critical 🚨
Steps of Reproduction ✅
Fix in Cursor | Fix in VSCode Claude
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖