fix(spawn): handle special characters in project names

- Reject null bytes and control characters (U+0000-U+001F, U+007F) in
  _validate_spawn_params with explicit INVALID_PROJECT error
- Reject whitespace-only project names as MISSING_PROJECT
- Reject non-string project names (int, list, etc.)
- Add _sanitize_pane_name() to clean Zellij pane names: replaces quotes,
  backticks, and control chars with underscores; collapses whitespace;
  truncates to 64 chars
- Add 44 new tests: safe chars (hyphens, spaces, dots, @, +, #),
  dangerous chars (null byte, newline, tab, ESC, DEL), shell
  metacharacters ($, ;, backtick, |), pane name sanitization, and
  spawn command construction with special char names

Closes bd-14p
This commit is contained in:
teernisse
2026-02-26 17:09:49 -05:00
parent 99a55472a5
commit e3e42e53f2
2 changed files with 311 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
import json
import os
import re
import subprocess
import time
import uuid
@@ -20,6 +21,20 @@ AGENT_COMMANDS = {
# Module-level cache for projects list (AC-33)
_projects_cache: list[str] = []
# Characters unsafe for Zellij pane/tab names: control chars, quotes, backticks
_UNSAFE_PANE_CHARS = re.compile(r'[\x00-\x1f\x7f"\'`]')
def _sanitize_pane_name(name):
"""Sanitize a string for use as a Zellij pane name.
Replaces control characters and quotes with underscores, collapses runs
of whitespace into a single space, and truncates to 64 chars.
"""
name = _UNSAFE_PANE_CHARS.sub('_', name)
name = re.sub(r'\s+', ' ', name).strip()
return name[:64] if name else 'unnamed'
def load_projects_cache():
"""Scan ~/projects/ and cache the list. Called on server start."""
@@ -112,9 +127,17 @@ class SpawnMixin:
def _validate_spawn_params(self, project, agent_type):
"""Validate spawn parameters. Returns resolved_path or error dict."""
if not project:
if not project or not isinstance(project, str):
return {'error': 'Project name is required', 'code': 'MISSING_PROJECT'}
# Reject whitespace-only names
if not project.strip():
return {'error': 'Project name is required', 'code': 'MISSING_PROJECT'}
# Reject null bytes and control characters (U+0000-U+001F, U+007F)
if '\x00' in project or re.search(r'[\x00-\x1f\x7f]', project):
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
# Reject path traversal characters (/, \, ..)
if '/' in project or '\\' in project or '..' in project:
return {'error': 'Invalid project name', 'code': 'INVALID_PROJECT'}
@@ -221,7 +244,7 @@ class SpawnMixin:
# Build agent command
agent_cmd = AGENT_COMMANDS[agent_type]
pane_name = f'{agent_type}-{project}'
pane_name = _sanitize_pane_name(f'{agent_type}-{project}')
# Spawn pane with agent command
env = os.environ.copy()