Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 116 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pybreeze/
├── logging/ # pybreeze_logger
├── file_process/ # File/directory utilities
├── json_format/ # JSON processing
├── network/ # URL validation (SSRF prevention)
└── manager/package_manager/ # PackageManager class
```

Expand Down Expand Up @@ -95,25 +96,135 @@ All code must follow secure-by-default principles. Review every change against t
- Never use `subprocess.Popen(..., shell=True)` — always pass argument lists
- Never log or display secrets, tokens, passwords, or API keys
- Use `json.loads()` / `json.dumps()` for serialisation — never pickle
- Never use `yaml.load()` — always use `yaml.safe_load()`
- Validate all user input at system boundaries (file dialogs, URL inputs, network data)
- Handle exceptions without leaking stack traces, file paths, or internal state to the user

### Network requests (SSRF prevention)
- All outbound HTTP requests must go through `diagram_net_utils.safe_download_image()` or equivalent guards
- Only `http://` and `https://` schemes are allowed — block `file://`, `ftp://`, `data:`, `gopher://`
- Resolved IP addresses must be checked against private/loopback/link-local ranges (`ipaddress.is_private`, `is_loopback`, `is_link_local`, `is_reserved`)
- Enforce download size limits (default: 20 MB) and connection timeouts (default: 15s)
- Never pass user-supplied URLs directly to `urlopen()` without validation
- **All** outbound HTTP requests to user-specified URLs must validate the target before connecting:
1. Only `http://` and `https://` schemes — block `file://`, `ftp://`, `data:`, `gopher://`
2. Resolve the hostname and check IPs against private/loopback/link-local/reserved ranges (`ipaddress.is_private`, `is_loopback`, `is_link_local`, `is_reserved`)
3. Enforce connection timeouts (default: 15 s for downloads, 30 s for API calls)
4. Enforce response size limits where applicable (default: 20 MB for binary downloads)
- Reference implementation: `diagram_net_utils._validate_url()` and `safe_download_image()`
- For API-style requests (`requests.get/post`): create or reuse a URL validation helper that performs scheme + IP checks, then call it before every `requests.*` call
- Disable automatic redirect following (`allow_redirects=False`) or re-validate the redirect target to prevent redirect-based SSRF
- Never pass user-supplied URLs directly to `urlopen()` or `requests.*` without validation

### Network requests (TLS / SSH)
- All HTTPS requests must use default TLS verification — never set `verify=False`
- SSH connections: never use `paramiko.AutoAddPolicy()` or `paramiko.WarningPolicy()` — both silently accept unknown host keys and are vulnerable to MITM. Use `InteractiveHostKeyPolicy` from `pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy` (via `apply_host_key_policy(client, parent_widget)`), which prompts the user with the SHA256 fingerprint on first connection and persists confirmed keys to `~/.pybreeze/ssh_known_hosts`

### Subprocess execution
- Always pass argument lists to `subprocess.Popen` / `subprocess.run` — never `shell=True`
- Explicitly set `shell=False` for clarity in new code
- Never interpolate user input into command strings — pass as separate list elements
- Set `timeout` on all `subprocess.run()` calls to prevent hangs
- The IDE intentionally runs user-authored scripts; this is trusted local execution, not arbitrary remote code. Subprocess hardening protects against accidental shell injection, not against malicious local files

### JupyterLab integration
- The embedded JupyterLab server binds to `localhost` only and is intended for local development
- `--ServerApp.token=` and `--ServerApp.password=` are deliberately empty to enable seamless embedding — this is safe only because the server is localhost-only
- Do not change `--ServerApp.ip` to `0.0.0.0` or any externally-reachable address
- `--ServerApp.disable_check_xsrf=True` is required for the embedded QWebEngineView; do not expose the server externally with XSRF disabled

### File I/O
- File read/write paths from user dialogs (`QFileDialog`) are trusted (user-initiated)
- File paths loaded from saved data (`.diagram.json`) must be validated before access:
- Local paths: check `path.is_file()` and verify extension is in an allowlist
- URLs: pass through the same SSRF validation as user-entered URLs
- Never construct file paths by string concatenation with user input — use `pathlib.Path` with validation
- When writing to data directories (`.pybreeze/`), create the directory with `os.makedirs(exist_ok=True)` and always use `encoding="utf-8"`
- Never follow symlinks from untrusted sources — use `Path.resolve(strict=True)` and verify the resolved path is still within expected boundaries

### Qt / UI
- `QGraphicsTextItem` with `TextEditorInteraction` must not be enabled by default — use double-click-to-edit pattern to prevent unintended text selection issues in themed environments
- Plugin loading (`jeditor_plugins/`) uses auto-discovery — only load `.py` files, skip files starting with `_` or `.`
- `QWebEngineView.setUrl()` must only load trusted URLs (localhost or user-confirmed external URLs) — never load untrusted HTML or URLs without user consent
- Never call `QWebEngineView.setHtml()` with unsanitised content — this enables XSS within the embedded browser

### Secrets and credentials
- SSH passwords and private key passphrases are held in memory only during the session — never persist to disk or logs
- Password fields must use `QLineEdit.EchoMode.Password`
- API endpoint URLs may contain embedded tokens — treat URL strings with the same care as credentials (do not log full URLs)
- Environment variables (`PYBREEZE_LOG_MAX_BYTES`, etc.) must never contain secrets; use dedicated secure stores for credentials

### Dependency security
- Pin dependencies to exact versions in `requirements.txt` / `dev_requirements.txt`
- Do not add new dependencies without reviewing their security posture (maintained? known CVEs?)
- Avoid transitive dependency bloat — prefer stdlib solutions when the alternative is a single-function dependency

## Code quality (SonarQube / Codacy compliance)

All code must satisfy common static-analysis rules enforced by SonarQube and Codacy. Review each change against the checklist below.

### Complexity & size
- Cyclomatic complexity per function: ≤ 15 (hard cap 20). Break large branches into helpers
- Cognitive complexity per function: ≤ 15. Flatten nested `if`/`for`/`try` chains with early returns or guard clauses
- Function length: ≤ 75 lines of code (excluding docstring / blank lines). Extract helpers past that
- Parameter count: ≤ 7 per function/method. Use a dataclass or typed dict when more are needed
- Nesting depth: ≤ 4 levels of `if`/`for`/`while`/`try`. Refactor with early returns instead of pyramids
- File length: ≤ 1000 lines — split modules past that
- Class `__init__`: keep attribute count reasonable; if a class has > 15 instance attributes, split responsibilities

### Exception handling
- Never use bare `except:` — always specify exception types
- Avoid catching `Exception` or `BaseException` unless immediately re-raising or logging and re-raising with context
- Never `pass` silently inside `except` — log the error via `pybreeze_logger` (at minimum `.debug()`) with context
- Do not `return` / `break` / `continue` inside a `finally` block — it swallows exceptions
- Custom exceptions must inherit from `ITEException`; never `raise Exception(...)` directly
- Use `raise ... from err` (or `raise ... from None`) when re-raising to preserve / suppress the chain explicitly

### Pythonic correctness
- Compare with `None` using `is` / `is not`, never `==` / `!=`
- Type checks use `isinstance(obj, T)`, never `type(obj) == T`
- Never use mutable default arguments (`def f(x=[])`) — use `None` and initialise inside
- Prefer f-strings over `%` formatting or `str.format()`
- Use context managers (`with open(...) as f:`) for every file / socket / lock — never leave resources to GC
- Use `enumerate()` instead of `range(len(...))` when the index is needed alongside the item
- Use `dict.get(key, default)` instead of `key in dict and dict[key]` patterns
- Use set / dict comprehensions when clearer than manual loops; avoid comprehensions with side effects

### Naming & style (PEP 8)
- `snake_case` for functions, methods, variables, module names
- `PascalCase` for classes
- `UPPER_SNAKE_CASE` for module-level constants
- `_leading_underscore` for protected / internal members; never use `__dunder__` for custom attributes
- No single-letter names except loop indices (`i`, `j`) or conventional math (`x`, `y`)
- Do not shadow built-ins (`id`, `type`, `list`, `dict`, `input`, `file`, `open`, etc.) — rename the local variable

### Duplication & dead code
- String literal used 3+ times in the same module → extract a module-level constant
- Identical 6+ line blocks in 2+ places → extract a helper function
- Remove unused imports, unused parameters, unused local variables, unreachable code after `return` / `raise`
- No commented-out code blocks — delete them (git history is the archive)
- No `TODO` / `FIXME` / `XXX` without an accompanying issue reference (`# TODO(#123): ...`)

### Logging, printing, assertions
- Never use `print()` for diagnostics in library / runtime code — use `pybreeze_logger`
- Use lazy logging (`logger.debug("x=%s", x)`) — avoid eager f-string formatting inside log calls on hot paths
- Never use `assert` for runtime validation (Python strips assertions with `-O`). Use explicit `if … raise …` instead; `assert` is only for test code

### Hardcoded values & secrets
- No hardcoded passwords, tokens, API keys, or secrets — use env vars or a config file excluded from VCS
- No hardcoded IP addresses or hostnames outside of `localhost` / documented loopback — use config
- Magic numbers (except 0, 1, -1) should be named constants when repeated or non-obvious

### Boolean & return hygiene
- Replace `if cond: return True else: return False` with `return bool(cond)` or `return cond`
- Replace `if x == True` / `if x == False` with `if x` / `if not x`
- A function should have a consistent return type — never mix `return value` and bare `return` (returns `None`) on meaningful paths unless explicitly documented
- Do not return inside a generator function (`yield` + `return value` is a syntax pitfall)

### Imports
- One import per line for `import` statements; grouped `from x import a, b` is fine
- Order: stdlib → third-party → first-party (`pybreeze.*`) — separated by blank lines
- No wildcard imports (`from x import *`) outside of `__init__.py` re-exports
- No relative imports beyond one level (`from ..pkg import x` OK, `from ...pkg import x` avoid)

### Running the linters
- Before committing any non-trivial change, run `ruff check pybreeze/` locally to catch these rules — `ruff` covers the majority of SonarQube/Codacy Python rules
- When adding a new rule exception, justify it in a `# noqa: RULE` comment with a short reason — never blanket-disable

## Commit & PR rules

Expand Down
2 changes: 2 additions & 0 deletions pybreeze/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from pybreeze import start_editor

start_editor()
10 changes: 5 additions & 5 deletions pybreeze/extend/process_executor/python_task_process_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import subprocess
import sys
import threading
import typing
from typing import Callable
from pathlib import Path
from queue import Queue
from threading import Thread
Expand Down Expand Up @@ -42,8 +42,8 @@ class TaskProcessManager:
def __init__(
self,
main_window: CodeWindow,
task_done_trigger_function: typing.Callable | None = None,
error_trigger_function: typing.Callable | None = None,
task_done_trigger_function: Callable | None = None,
error_trigger_function: Callable | None = None,
program_buffer_size: int = 1024,
program_encoding: str = "utf-8"
):
Expand All @@ -60,8 +60,8 @@ def __init__(
self.run_error_queue: Queue = Queue()
self.process: subprocess.Popen | None = None

self.task_done_trigger_function: typing.Callable = task_done_trigger_function
self.error_trigger_function: typing.Callable = error_trigger_function
self.task_done_trigger_function: Callable = task_done_trigger_function
self.error_trigger_function: Callable = error_trigger_function
self.program_buffer_size = program_buffer_size

def renew_path(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from PySide6.QtCore import QTimer
from PySide6.QtGui import QTextCharFormat
from PySide6.QtWidgets import QWidget
from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict
from je_editor.utils.venv_check.check_venv import check_and_choose_venv

Expand All @@ -31,7 +30,6 @@ def __init__(
encoding: str = "utf-8",
):
self._main_window: PyBreezeMainWindow = main_window
self._widget: QWidget = main_window.tab_widget.currentWidget()
# Code window init
self._code_window = CodeWindow()
self._main_window.current_run_code_window.append(self._code_window)
Expand Down
9 changes: 9 additions & 0 deletions pybreeze/extend_multi_language/extend_english.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from je_editor import english_word_dict

# PyBreeze-specific English translations
Expand Down Expand Up @@ -99,6 +101,13 @@
"test_pioneer_create_template_label": "Create TestPioneer Yaml template",
"test_pioneer_run_yaml": "Execute Test Pioneer Yaml",
"test_pioneer_not_choose_yaml": "Please choose a Yaml file",
# SSH host key verification
"ssh_host_key_policy_dialog_title_verify_host": "Verify SSH host key",
"ssh_host_key_policy_dialog_message_verify_host": (
"The authenticity of host '{host}' cannot be established.\n"
"{key_type} key fingerprint is {fingerprint}.\n\n"
"Do you want to trust this host and continue connecting?"
),
# SSH command widget
"ssh_command_widget_window_title_ssh_command_widget": "SSH Command Widget",
"ssh_command_widget_button_label_send_command": "Send",
Expand Down
9 changes: 9 additions & 0 deletions pybreeze/extend_multi_language/extend_traditional_chinese.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from je_editor import traditional_chinese_word_dict

# PyBreeze-specific Traditional Chinese translations
Expand Down Expand Up @@ -99,6 +101,13 @@
"test_pioneer_create_template_label": "建立 TestPioneer Yaml 模板",
"test_pioneer_run_yaml": "執行 Test Pioneer Yaml",
"test_pioneer_not_choose_yaml": "請選擇 Yaml 檔案",
# SSH host key verification
"ssh_host_key_policy_dialog_title_verify_host": "驗證 SSH 主機金鑰",
"ssh_host_key_policy_dialog_message_verify_host": (
"無法驗證主機 '{host}' 的真實性。\n"
"{key_type} 金鑰指紋為 {fingerprint}。\n\n"
"是否信任此主機並繼續連線?"
),
# SSH command widget
"ssh_command_widget_window_title_ssh_command_widget": "SSH 指令介面",
"ssh_command_widget_button_label_send_command": "送出",
Expand Down
2 changes: 2 additions & 0 deletions pybreeze/extend_multi_language/update_language_dict.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from pybreeze.extend_multi_language.extend_english import update_english_word_dict
from pybreeze.extend_multi_language.extend_traditional_chinese import \
update_traditional_chinese_word_dict
Expand Down
6 changes: 5 additions & 1 deletion pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import os
import re

Expand All @@ -10,6 +12,7 @@
)
from je_editor import language_wrapper

from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy
from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget
from pybreeze.utils.logging.logger import pybreeze_logger

Expand Down Expand Up @@ -137,7 +140,8 @@

try:
self.ssh_client = paramiko.SSHClient()
self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
apply_host_key_policy(self.ssh_client, self)

Check warning on line 143 in pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py#L143

Paramiko call with policy set to automatically trust the unknown host key.
pybreeze_logger.info("SSH connecting to %s:%s", host, port)

if use_key:
if not os.path.exists(key_path):
Expand Down
19 changes: 11 additions & 8 deletions pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from __future__ import annotations

import os
import stat
from pathlib import Path

import paramiko
Expand All @@ -9,7 +12,9 @@
)
from je_editor import language_wrapper

from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy
from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget
from pybreeze.utils.logging.logger import pybreeze_logger


class SFTPClientWrapper:
Expand All @@ -25,14 +30,16 @@ def __init__(self):
self.root_path: str = "/"

def connect(self, host: str, port: int, username: str, password: str,
use_key: bool = False, key_path: str = ""):
use_key: bool = False, key_path: str = "",
parent_widget: QWidget | None = None):
"""
Establish SSH + SFTP connection.
建立 SSH + SFTP 連線。
"""
self.close()
self._ssh = paramiko.SSHClient()
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
apply_host_key_policy(self._ssh, parent_widget)
pybreeze_logger.info("SFTP connecting to %s:%s", host, port)
if use_key and key_path:
pkey = None
for KeyType in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey):
Expand Down Expand Up @@ -96,8 +103,6 @@ def is_dir(self, path: str) -> bool:
))
try:
st = self._sftp.stat(path)
# S_ISDIR check via stat.S_ISDIR
import stat
return stat.S_ISDIR(st.st_mode)
except OSError:
return False
Expand Down Expand Up @@ -210,7 +215,7 @@ def _connect(self):
self.word_dict.get("ssh_file_viewer_dialog_message_missing_input"))
return
try:
self.client.connect(host, port, user, pwd, use_key, key_path)
self.client.connect(host, port, user, pwd, use_key, key_path, parent_widget=self)
self.load_root("/")
except Exception as e:
QMessageBox.critical(
Expand Down Expand Up @@ -294,7 +299,6 @@ def populate_children(self, parent_item: QTreeWidgetItem):
try:
entries = self.client.list_dir(path)
# Sort: dirs first, then files
import stat
dirs = []
files = []
for e in entries:
Expand All @@ -307,10 +311,9 @@ def populate_children(self, parent_item: QTreeWidgetItem):
else:
files.append((name, e))
for name, e in dirs + files:
import stat as _stat
full_path = os.path.join(path if path != "/" else "", name)
full_path = full_path if full_path.startswith("/") else f"/{full_path}"
typ = "dir" if _stat.S_ISDIR(e.st_mode) else "file"
typ = "dir" if stat.S_ISDIR(e.st_mode) else "file"
size = e.st_size if typ == "file" else 0
child = self.make_item(name, typ, size, full_path)
parent_item.addChild(child)
Expand Down
Loading
Loading