Skip to content
Open
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
9 changes: 6 additions & 3 deletions .github/workflows/snyk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
- name: Setup Snyk
if: ${{ env.SNYK_TOKEN != '' }}
uses: snyk/actions/setup@master

- name: Run Snyk (dependencies)
- name: Run Snyk (requirements.txt)
if: ${{ env.SNYK_TOKEN != '' }}
run: snyk test --severity-threshold=high
run: snyk test --file=requirements.txt --package-manager=pip --severity-threshold=high

- name: Run Snyk (pyproject.toml)
if: ${{ env.SNYK_TOKEN != '' }}
run: snyk test --file=pyproject.toml --package-manager=pip --severity-threshold=high
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
## 2024-05-26 - Avoid Redundant `aria-disabled` Attributes
**Learning:** Adding an `aria-disabled` attribute to a button that already uses the native HTML `disabled` attribute is redundant and considered an ARIA anti-pattern. Native `disabled` inherently communicates the state to assistive technologies and removes the element from the tab order.
**Action:** When improving loading states for buttons, rely on the native `disabled` attribute and use `aria-busy={loading}` to inform screen readers of the active process without duplicating the disabled state.
## 2024-05-30 - Empty States Call-to-Action
**Learning:** Including an 'or try an example' call-to-action button that populates the input field solves the 'blank canvas' UX problem by explicitly demonstrating the expected input format to users.
**Action:** When designing empty states for text areas and generative tools, include an 'or try an example' call-to-action button that populates the input field.
10 changes: 10 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,13 @@
**Vulnerability:** Information Exposure (CWE-200) in FastAPI error handling. An endpoint raised an `HTTPException` where the `detail` parameter was populated dynamically with the raw exception string (`str(exc)`).
**Learning:** Returning `str(exc)` directly to the client can inadvertently expose sensitive internal paths, downstream API details, or error contexts meant only for developers. The exception must be logged securely on the backend while a sanitized generic message is returned to the user.
**Prevention:** When raising `HTTPException` inside a `try...except` block, never pass the stringified exception `str(exc)` directly to the `detail` parameter. Instead, provide a generic, safe error message to the client, and rely on `logger.exception()` or explicit backend logging (e.g., `_log_repo_analyze_outcome`) to record the raw exception for internal troubleshooting.

## 2024-05-20 - Ensure Environment Configuration for CORS is Testable
**Vulnerability:** Default local CORS allowed origins (`localhost:3000`) enabled by default without explicit environment checks.
**Learning:** Default configuration logic evaluated at module import (`os.environ.get`) is harder to test using standard pytest monkeypatch without using `importlib.reload()`, and defaults can expose unverified environments.
**Prevention:** Always scope permissive security defaults (like localhost origins) behind strict environment checks (e.g., `ENV == "development"`), and ensure proper mock-reloading when testing top-level variables.

## 2025-05-30 - Prevent Command Injection via Custom Editor Wrappers
**Vulnerability:** The quick edit functionality in the CLI application manually parsed the `$EDITOR` environment variable and passed it to `subprocess.run()`, attempting to prevent malicious execution via a brittle blocklist of shells (`bash`, `python`, etc.). This was highly vulnerable to command injection and logic bypasses.
**Learning:** Custom command parsing and blocklisting for system interactions (like invoking an editor) is error-prone and often introduces "security theater". The standard library or established CLI frameworks usually have robust, platform-agnostic implementations.
**Prevention:** Use established security libraries/functions. For text editing in CLI applications, `click.edit()` should be used to safely handle temporary files, environment variables, and process execution without exposing the application to injection attacks.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ install:
cd web && npm ci

dev-backend:
uvicorn api.main:app --reload --host 0.0.0.0 --port 8080
ENV=development uvicorn api.main:app --reload --host 0.0.0.0 --port 8080

dev-web:
cd web && npm run dev
Expand All @@ -14,7 +14,7 @@ dev:
@echo "Starting backend (8080) + web (3000) in parallel..."
@if command -v npx >/dev/null 2>&1; then \
npx -y concurrently -n backend,web -c blue,green \
"uvicorn api.main:app --reload --host 0.0.0.0 --port 8080" \
"ENV=development uvicorn api.main:app --reload --host 0.0.0.0 --port 8080" \
"cd web && npm run dev"; \
else \
echo "npx not found — run 'make dev-backend' and 'make dev-web' in separate terminals"; \
Expand Down
13 changes: 7 additions & 6 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ async def lifespan(app: FastAPI):
allowed_origins_env = os.environ.get("ALLOWED_ORIGINS", "")
allow_origins = [origin.strip() for origin in allowed_origins_env.split(",") if origin.strip()]
if not allow_origins:
allow_origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
]
if os.environ.get("ENV", "production").strip().lower() == "development":
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CORS behavior change: With empty ALLOWED_ORIGINS, localhost origins are only added when ENV=development. Production deployments must set ALLOWED_ORIGINS explicitly or browsers will fail CORS preflight. Confirm this matches deployment docs and that make dev-backend / make dev setting ENV=development is sufficient for local dev.

allow_origins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
]

app.add_middleware(
CORSMiddleware,
Expand Down
99 changes: 8 additions & 91 deletions app/quick_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
including text editing, metadata updates, and re-compilation.
"""

import tempfile
import subprocess
import os
import shlex
import click
from typing import Optional, Dict, Any, Literal

from rich.console import Console
Expand Down Expand Up @@ -96,100 +94,19 @@ def edit_text_in_editor(self, text: str) -> Optional[str]:
Returns:
Edited text or None if cancelled
"""
# Create temporary file
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
) as f:
f.write(text)
temp_path = f.name

try:
editor = self.get_editor()
edited_text = click.edit(text=text, editor=editor)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review focus: click.edit(..., editor=editor) delegates to Click’s Editor.edit_files, which runs subprocess.Popen(f"{editor} {filename}", shell=True) (Click 8.3.1). The previous implementation used shlex.split and subprocess.run(editor_parts) without shell=True, plus an executable denylist.

Please confirm during review that:

  1. Passing the raw $EDITOR string here does not widen injection surface (e.g. nano; id, bash -c ...).
  2. Relying on Click alone is an acceptable tradeoff vs. restoring argument-splitting without shell=True.

If not, consider click.edit without a custom editor (env-only), or a fixed allowlist of editor binaries with argv splitting.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review focus: click.edit(..., editor=editor) delegates to Click’s Editor.edit_files, which runs subprocess.Popen(f"{editor} {filename}", shell=True) (Click 8.4.0). The previous code used shlex.split and subprocess.run(editor_parts) without shell=True, plus an executable denylist.

Please confirm:

  1. Passing the raw $EDITOR string does not widen injection (e.g. nano; id, bash -c ...).
  2. Relying on Click alone is acceptable vs. argv splitting without shell=True.

If not, consider click.edit without a custom editor (env-only), or a fixed allowlist with argv splitting.


# Open editor
# Safely parse the editor string into arguments to handle cases like "nano -w"
# and prevent command injection if the EDITOR env var is maliciously crafted
try:
editor_parts = shlex.split(editor, posix=os.name != "nt")
except ValueError as exc:
console.print(
f"[red]⚠️ Failed to parse editor command from EDITOR/VISUAL: {exc}[/red]"
)
return None

if os.name == "nt":
editor_parts = [
part[1:-1] if len(part) >= 2 and part[0] == part[-1] == '"' else part
for part in editor_parts
]

editor_parts = [part for part in editor_parts if part]
if not editor_parts:
console.print(
"[red]⚠️ EDITOR/VISUAL environment variable is empty or invalid.[/red]"
)
# click.edit returns None if not saved/changed
if edited_text is None:
console.print("[yellow]No changes made or editor aborted.[/yellow]")
return None

# Validate editor components against a denylist of shells/interpreters
# to prevent command injection via execution wrappers
denylist_prefixes = (
"bash",
"sh",
"zsh",
"csh",
"ksh",
"tcsh",
"dash",
"fish",
"python",
"pypy",
"env",
"cmd",
"powershell",
"pwsh",
"node",
"ruby",
"perl",
"php",
)
for part in editor_parts:
normalized_part = part.replace("\\", "/")
basename = os.path.basename(normalized_part).lower()

# Check for extensions and exact matches/prefixes
base_without_ext = basename
if basename.endswith(".exe"):
base_without_ext = basename[:-4]

for prefix in denylist_prefixes:
if base_without_ext.startswith(prefix):
remainder = base_without_ext[len(prefix) :]
if not remainder or remainder.lstrip(".0123456789") == "":
console.print(
f"[red]⚠️ Editor command contains forbidden executable or shell: {part}[/red]"
)
return None

editor_parts.append(temp_path)

result = subprocess.run(editor_parts)

if result.returncode != 0:
console.print(f"[yellow]⚠️ Editor exited with code {result.returncode}[/yellow]")
return None

# Read edited content
with open(temp_path, "r", encoding="utf-8") as f:
edited_text = f.read()

return edited_text

finally:
# Clean up temp file
try:
os.unlink(temp_path)
except Exception:
pass
except Exception as exc:
console.print(f"[red]⚠️ Editor failed: {exc}[/red]")
return None

def edit_prompt(self, prompt_id: str, recompile: bool = False) -> bool:
"""Edit a prompt by ID.
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ pyyaml>=6.0.3
ttkbootstrap>=1.20.3
pillow>=12.2.0
openai>=2.37.0
httpx<0.29
python-dotenv>=1.2.2
Jinja2>=3.1.6
jinja2>=3.1.6
tiktoken>=0.13.0
cachetools>=7.1.3
sqlalchemy>=2.0.49
Expand Down
14 changes: 10 additions & 4 deletions tests/test_api_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ def test_repo_context_endpoint_keeps_per_ip_buckets_isolated(monkeypatch):
def test_benchmark_requires_api_key():
client = TestClient(app)

with patch("app.routers.benchmark._generate_llm_output") as mock_llm, patch(
"app.routers.benchmark._judge_with_llm", return_value=None
with (
patch("app.routers.benchmark._generate_llm_output") as mock_llm,
patch("app.routers.benchmark._judge_with_llm", return_value=None),
):
mock_llm.side_effect = ["raw output", "compiled output"]
response = client.post(
Expand Down Expand Up @@ -289,8 +290,13 @@ def test_rag_ingest_rejects_path_outside_allowed_root(test_key, monkeypatch):
assert "invalid path specified" in response.json()["detail"].lower()


def test_cors_preflight_allows_prompt_mode_header():
client = TestClient(app)
def test_cors_preflight_allows_prompt_mode_header(monkeypatch):
monkeypatch.setenv("ALLOWED_ORIGINS", "http://localhost:3000")
import importlib
import api.main

importlib.reload(api.main)
client = TestClient(api.main.app)

response = client.options(
"/compile",
Expand Down
54 changes: 15 additions & 39 deletions tests/test_quick_edit_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,63 +7,39 @@
def test_editor_command_injection():
editor = QuickEditor()

# Mock subprocess.run
with patch("subprocess.run") as mock_run:
# Mock click.edit
with patch("click.edit") as mock_edit:
mock_edit.return_value = "edited content"
# Simulate an environment variable with arguments
with patch.dict(os.environ, {"EDITOR": "nano -w -K"}):
editor.edit_text_in_editor("test content")
result = editor.edit_text_in_editor("test content")

# subprocess.run should receive a list of arguments, correctly split
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert args[0] == "nano"
assert args[1] == "-w"
assert args[2] == "-K"
# The last argument should be the temp file path
assert args[3].endswith(".txt")
# click.edit should be called safely
mock_edit.assert_called_once_with(text="test content", editor="nano -w -K")
assert result == "edited content"


def test_editor_invalid_shell_syntax_returns_none():
editor = QuickEditor()

with patch("subprocess.run") as mock_run:
with patch("click.edit") as mock_edit:
# click.edit handles shell stuff internally, but we can mock it raising an exception
# if the editor string is severely malformed.
mock_edit.side_effect = Exception("Editing failed")
with patch.dict(os.environ, {"EDITOR": '"unterminated'}):
result = editor.edit_text_in_editor("test content")

assert result is None
mock_run.assert_not_called()


def test_editor_denylist_execution_wrappers_blocked():
editor = QuickEditor()

# Test that forbidden shells/interpreters are blocked
forbidden_editors = [
"bash -c 'malicious command'",
"env python -c 'import os; os.system(\"id\")'",
"python3 -c 'print(\"hello\")'",
"/bin/sh -c 'echo pwned'",
"cmd.exe /c calc.exe",
"python3.12 -c 'print(\"hello\")'",
'"C:\\Windows\\System32\\cmd.exe" /c calc',
"pypy3 -c 'test'",
]

for malicious_editor in forbidden_editors:
with patch("subprocess.run") as mock_run:
with patch.dict(os.environ, {"EDITOR": malicious_editor}):
result = editor.edit_text_in_editor("test content")

assert result is None, f"Expected {malicious_editor} to be blocked"
mock_run.assert_not_called()
mock_edit.assert_called_once()


def test_editor_empty_command_returns_none():
editor = QuickEditor()

with patch("subprocess.run") as mock_run:
with patch("click.edit") as mock_edit:
mock_edit.return_value = None # simulated no change
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed test_editor_denylist_execution_wrappers_blocked cases were the only tests asserting malicious EDITOR values never reach execution. With only click.edit mocks, regressions in real Click/subprocess behavior would not be caught. Consider keeping integration-style cases (or documenting why Click makes them redundant).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed denylist integration cases were the only tests asserting malicious EDITOR values never reach execution. With only click.edit mocks, regressions in real Click/subprocess behavior would not be caught. Consider restoring integration-style cases or documenting why Click makes them redundant.

with patch.dict(os.environ, {"EDITOR": " "}):
result = editor.edit_text_in_editor("test content")

assert result is None
mock_run.assert_not_called()
mock_edit.assert_called_once()
16 changes: 16 additions & 0 deletions tests/test_rag_simple_index_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from app.rag.simple_index import _query_cache, _clear_query_cache, _cache_put, _cache_get


def test_clear_query_cache():
# Setup: populate the cache
_cache_put("test_key", [{"mock": "data"}])
assert "test_key" in _query_cache
assert _cache_get("test_key") == [{"mock": "data"}]
assert len(_query_cache) > 0

# Action: clear the cache
_clear_query_cache()

# Verify: cache is empty
assert len(_query_cache) == 0
assert _cache_get("test_key") is None
Loading