Skip to content

Commit c63d9cd

Browse files
author
Agent-Planner
committed
security and validation fixes: error handling, path validation, type checking, and package constraints
1 parent be55176 commit c63d9cd

10 files changed

Lines changed: 153 additions & 41 deletions

auto_documentation.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,19 @@ class DocumentationGenerator:
8686
"""
8787

8888
def __init__(self, project_dir: Path, output_dir: str = "docs"):
89-
self.project_dir = Path(project_dir)
90-
self.output_dir = self.project_dir / output_dir
89+
self.project_dir = Path(project_dir).resolve()
90+
91+
# Validate that output directory is contained within project_dir
92+
resolved_output = (self.project_dir / output_dir).resolve()
93+
try:
94+
resolved_output.relative_to(self.project_dir)
95+
except ValueError:
96+
raise ValueError(
97+
f"Output directory '{output_dir}' escapes project directory boundary. "
98+
f"Resolved to '{resolved_output}' but project_dir is '{self.project_dir}'"
99+
)
100+
101+
self.output_dir = resolved_output
91102
self.app_spec: Optional[dict] = None
92103

93104
def generate(self) -> ProjectDocs:

design_tokens.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,19 @@ def validate_contrast(self, tokens: DesignTokens) -> list[dict]:
524524
# Check primary colors against white/black backgrounds
525525
for name, value in tokens.colors.items():
526526
color_token = ColorToken(name=name, value=value)
527-
_hue, _sat, lightness = color_token.to_hsl()
527+
try:
528+
_hue, _sat, lightness = color_token.to_hsl()
529+
except Exception as e:
530+
# Log invalid color and continue to next token
531+
issues.append(
532+
{
533+
"color": name,
534+
"value": value,
535+
"issue": "Invalid color value",
536+
"error": str(e),
537+
}
538+
)
539+
continue
528540

529541
# Simple contrast check based on lightness
530542
if lightness > 50:
@@ -565,18 +577,26 @@ def generate_all(self, output_dir: Optional[Path] = None) -> dict:
565577
tokens = self.load()
566578
output = output_dir or self.project_dir / "src" / "styles"
567579

580+
# Create Path objects for output files
581+
css_path = output / "tokens.css"
582+
scss_path = output / "_tokens.scss"
583+
584+
# Generate files and pass Path objects
585+
self.generate_css(tokens, css_path)
586+
self.generate_scss(tokens, scss_path)
587+
568588
results = {
569-
"css": str(self.generate_css(tokens, output / "tokens.css")),
570-
"scss": str(self.generate_scss(tokens, output / "_tokens.scss")),
589+
"css": str(css_path),
590+
"scss": str(scss_path),
571591
}
572592

573593
# Check for Tailwind
574594
if (self.project_dir / "tailwind.config.js").exists() or (
575595
self.project_dir / "tailwind.config.ts"
576596
).exists():
577-
results["tailwind"] = str(
578-
self.generate_tailwind_config(tokens, output / "tailwind.tokens.js")
579-
)
597+
tailwind_path = output / "tailwind.tokens.js"
598+
self.generate_tailwind_config(tokens, tailwind_path)
599+
results["tailwind"] = str(tailwind_path)
580600

581601
# Validate and report
582602
issues = self.validate_contrast(tokens)

quality_gates.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,23 +132,22 @@ def _detect_python_linter(project_dir: Path) -> tuple[str, list[str]] | None:
132132
if flake8_path:
133133
return ("flake8", [flake8_path, "."])
134134

135-
# Check in virtual environment for both Unix and Windows paths
135+
# Check in virtual environment for ruff (both Unix and Windows paths)
136136
venv_ruff_paths = [
137137
project_dir / "venv/bin/ruff",
138138
project_dir / "venv/Scripts/ruff.exe"
139139
]
140140

141+
for venv_ruff in venv_ruff_paths:
142+
if venv_ruff.exists():
143+
return ("ruff", [str(venv_ruff), "check", "."])
144+
145+
# Check in virtual environment for flake8 (both Unix and Windows paths)
141146
venv_flake8_paths = [
142147
project_dir / "venv/bin/flake8",
143148
project_dir / "venv/Scripts/flake8.exe"
144149
]
145150

146-
# Check for ruff in venv
147-
for venv_ruff in venv_ruff_paths:
148-
if venv_ruff.exists():
149-
return ("ruff", [str(venv_ruff), "check", "."])
150-
151-
# Check for flake8 in venv
152151
for venv_flake8 in venv_flake8_paths:
153152
if venv_flake8.exists():
154153
return ("flake8", [str(venv_flake8), "."])

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
claude-agent-sdk>=0.1.0,<0.2.0
33
python-dotenv~=1.0.0
44
sqlalchemy~=2.0
5-
fastapi>=0.128.0
5+
fastapi~=0.128.0
66
uvicorn[standard]~=0.32
77
websockets~=13.0
88
# CVE-2026-24486: >=0.0.22 strips directory components from uploaded filenames
@@ -15,7 +15,7 @@ pywinpty~=2.0; sys_platform == "win32"
1515
pyyaml~=6.0
1616
slowapi~=0.1.9
1717
pydantic-settings~=2.0
18-
openai>=1.52.0
18+
openai~=2.15.0
1919

2020
# Dev dependencies
2121
ruff~=0.8.0

security.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def redact_string(s: str, max_preview: int = 20) -> str:
109109
{
110110
"timestamp": cmd.timestamp,
111111
"command": redact_string(cmd.command),
112-
"reason": cmd.reason,
112+
"reason": redact_string(cmd.reason),
113113
"project_dir": cmd.project_dir,
114114
}
115115
for cmd in commands

server/routers/design_tokens.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,18 @@ async def update_design_tokens(project_name: str, request: DesignTokensRequest):
205205
manager = DesignTokensManager(project_dir)
206206
current = manager.load()
207207

208-
# Update only provided fields
209-
if request.colors:
208+
# Update only provided fields (explicit None checks allow empty dicts/lists for clearing)
209+
if request.colors is not None:
210210
current.colors.update(request.colors)
211-
if request.spacing:
211+
if request.spacing is not None:
212212
current.spacing = request.spacing
213-
if request.typography:
213+
if request.typography is not None:
214214
current.typography.update(request.typography)
215-
if request.borders:
215+
if request.borders is not None:
216216
current.borders.update(request.borders)
217-
if request.shadows:
217+
if request.shadows is not None:
218218
current.shadows.update(request.shadows)
219-
if request.animations:
219+
if request.animations is not None:
220220
current.animations.update(request.animations)
221221

222222
manager.save(current)
@@ -339,7 +339,16 @@ async def validate_tokens(project_name: str):
339339

340340
hex_pattern = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
341341
for name, value in tokens.colors.items():
342-
if not hex_pattern.match(value):
342+
if not isinstance(value, str):
343+
issues.append(
344+
{
345+
"type": "color_format",
346+
"field": f"colors.{name}",
347+
"value": value,
348+
"message": "Non-string color value",
349+
}
350+
)
351+
elif not hex_pattern.match(value):
343352
issues.append(
344353
{
345354
"type": "color_format",

server/routers/documentation.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pydantic import BaseModel, Field
2020

2121
from auto_documentation import DocumentationGenerator
22-
from registry import get_project_path
22+
from registry import get_project_path, list_registered_projects
2323

2424
logger = logging.getLogger(__name__)
2525

@@ -121,18 +121,30 @@ def _validate_project_path(path: Path) -> None:
121121
allowed_root = Path.cwd().resolve()
122122

123123
try:
124-
# Check if the path is within the allowed root directory
125-
if not path.is_relative_to(allowed_root):
126-
raise HTTPException(
127-
status_code=403,
128-
detail=f"Access denied: Project path '{path}' is outside allowed directory boundary"
129-
)
124+
# First check if the path is within the allowed root directory (cwd)
125+
if path.is_relative_to(allowed_root):
126+
return
130127
except ValueError:
131-
# Handle case where path comparison fails
132-
raise HTTPException(
133-
status_code=403,
134-
detail=f"Access denied: Project path '{path}' is outside allowed directory boundary"
135-
)
128+
pass
129+
130+
# Check if the path matches or is within any registered project path
131+
try:
132+
registered_projects = list_registered_projects()
133+
for proj_name, proj_info in registered_projects.items():
134+
registered_path = Path(proj_info["path"]).resolve()
135+
try:
136+
if path == registered_path or path.is_relative_to(registered_path):
137+
return
138+
except ValueError:
139+
continue
140+
except Exception as e:
141+
logger.warning(f"Failed to check registry: {e}")
142+
143+
# Path is not within allowed boundaries
144+
raise HTTPException(
145+
status_code=403,
146+
detail=f"Access denied: Project path '{path}' is outside allowed directory boundary"
147+
)
136148

137149

138150
# ============================================================================
@@ -177,6 +189,9 @@ async def generate_docs(request: GenerateDocsRequest):
177189
message=f"Generated {len(generated)} documentation files",
178190
)
179191

192+
except ValueError as e:
193+
logger.error(f"Invalid output directory: {e}")
194+
raise HTTPException(status_code=400, detail=str(e))
180195
except Exception as e:
181196
logger.error(f"Documentation generation failed: {e}")
182197
raise HTTPException(status_code=500, detail=str(e))

server/routers/review.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,67 @@ def get_project_dir(project_name: str) -> Path:
119119
# Try to get from registry
120120
project_path = get_project_path(project_name)
121121
if project_path:
122-
return Path(project_path)
122+
# Resolve and validate the registered path
123+
resolved_path = project_path.resolve()
124+
_validate_project_dir(resolved_path)
125+
return resolved_path
123126

124127
# Check if it's a direct path
125128
path = Path(project_name)
126129
if path.exists() and path.is_dir():
127-
return path
130+
# Resolve and validate the provided path
131+
resolved_path = path.resolve()
132+
_validate_project_dir(resolved_path)
133+
return resolved_path
128134

129135
raise HTTPException(status_code=404, detail=f"Project not found: {project_name}")
130136

131137

138+
def _validate_project_dir(resolved_path: Path) -> None:
139+
"""
140+
Validate that a project directory is within allowed boundaries.
141+
142+
Args:
143+
resolved_path: The resolved project path to validate
144+
145+
Raises:
146+
HTTPException: If the path is outside allowed boundaries or is dangerous
147+
"""
148+
# Blocklist for dangerous locations
149+
dangerous_roots = [
150+
Path("/").resolve(), # Root
151+
Path("/etc").resolve(), # System config
152+
Path("/var").resolve(), # System variables
153+
Path.home().resolve(), # User home (allow subpaths, block direct home)
154+
]
155+
156+
# Check if path is in dangerous locations
157+
for dangerous in dangerous_roots:
158+
try:
159+
if dangerous in resolved_path.parents or dangerous == resolved_path:
160+
raise HTTPException(
161+
status_code=404,
162+
detail=f"Project not found: {resolved_path}"
163+
)
164+
except (ValueError, OSError):
165+
pass
166+
167+
# Ensure path is contained within an allowed root
168+
allowed_root = Path.cwd().resolve()
169+
170+
try:
171+
if resolved_path.is_relative_to(allowed_root):
172+
return
173+
except ValueError:
174+
pass
175+
176+
# Path is not within allowed boundaries
177+
raise HTTPException(
178+
status_code=404,
179+
detail=f"Project not found: {resolved_path}"
180+
)
181+
182+
132183
# ============================================================================
133184
# Endpoints
134185
# ============================================================================

server/routers/templates.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from templates.library import generate_app_spec, generate_features
2929
from templates.library import get_template as get_template_data
30+
from templates.library import list_templates as load_templates_list
3031

3132
logger = logging.getLogger(__name__)
3233

@@ -140,7 +141,7 @@ async def list_templates():
140141
Returns basic information about each template.
141142
"""
142143
try:
143-
templates = list_templates()
144+
templates = load_templates_list()
144145

145146
return TemplateListResponse(
146147
templates=[

server/routers/visual_regression.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,12 @@ async def update_baseline(request: UpdateBaselineRequest):
325325
"""
326326
project_dir = get_project_dir(request.project_name)
327327

328+
# Validate inputs (same checks as delete_baseline)
329+
if ".." in request.name or "/" in request.name or "\\" in request.name:
330+
raise HTTPException(status_code=400, detail="Invalid name")
331+
if ".." in request.viewport or "/" in request.viewport or "\\" in request.viewport:
332+
raise HTTPException(status_code=400, detail="Invalid viewport")
333+
328334
try:
329335
tester = VisualRegressionTester(project_dir)
330336
success = tester.update_baseline(request.name, request.viewport)

0 commit comments

Comments
 (0)