Security and multi-instance improvements

captain_api.py:
- Generate random JWT_SECRET if not configured (with warning)
- Restrict CORS to specific origins
- Add POST /sessions endpoint to create screen sessions
- Sanitize session names (prevent command injection)
- Validate file paths to upload directory only
- Improve JWT error handling
- Set TERM environment for screen sessions

flow-ui: Add multi-instance support with server validation
mindlink: Add multi-instance support with category filtering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ARCHITECT
2026-01-17 11:13:40 +00:00
parent 60ca0640a3
commit f91485f866
3 changed files with 79 additions and 10 deletions

View File

@@ -24,13 +24,17 @@ from pydantic import BaseModel
from contextlib import asynccontextmanager
# Configuration
JWT_SECRET = os.getenv("JWT_SECRET", "captain-claude-secret-key-change-in-production")
import secrets
_default_secret = secrets.token_hex(32) # Generate random secret if not configured
JWT_SECRET = os.getenv("JWT_SECRET", _default_secret)
if JWT_SECRET == _default_secret:
print("WARNING: Using random JWT_SECRET. Set JWT_SECRET env var for production.")
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_DAYS = 7
API_USER = os.getenv("API_USER", "captain")
API_PASSWORD = os.getenv("API_PASSWORD", "tzzr2025")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://captain:captain@localhost/captain_mobile")
CLAUDE_CMD = os.getenv("CLAUDE_CMD", "/home/architect/.claude/local/claude")
CLAUDE_CMD = os.getenv("CLAUDE_CMD", "/home/architect/.npm-global/bin/claude")
# Database pool
db_pool: Optional[asyncpg.Pool] = None
@@ -57,7 +61,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=["https://captain.tzzrarchitect.me", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -90,6 +94,9 @@ class ScreenSession(BaseModel):
pid: str
attached: bool
class CreateSessionRequest(BaseModel):
name: str
# Database initialization
async def init_database():
if not db_pool:
@@ -177,6 +184,48 @@ async def list_sessions(user: str = Depends(verify_token)) -> list[ScreenSession
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/sessions", response_model=ScreenSession)
async def create_session(request: CreateSessionRequest, user: str = Depends(verify_token)):
"""Create a new screen session with Claude Code"""
import re
try:
# Sanitize name - only allow alphanumeric and hyphens
name = request.name.replace(" ", "-").lower()
name = re.sub(r'[^a-z0-9-]', '', name)
if not name or len(name) > 50 or len(name) < 2:
raise HTTPException(status_code=400, detail="Invalid session name (2-50 chars, alphanumeric and hyphens only)")
# Check if session with same name already exists
ls_check = subprocess.run(["screen", "-ls"], capture_output=True, text=True)
if f".{name}" in ls_check.stdout:
raise HTTPException(status_code=409, detail=f"Session '{name}' already exists")
# Start screen session with claude in detached mode
result = subprocess.run(
["screen", "-dmS", name, CLAUDE_CMD],
capture_output=True,
text=True,
cwd="/home/architect/captain-claude"
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=f"Failed to create session: {result.stderr}")
# Get the PID of the new session
await asyncio.sleep(1.0) # Wait for screen to start
ls_result = subprocess.run(["screen", "-ls"], capture_output=True, text=True)
for line in ls_result.stdout.split("\n"):
if f".{name}" in line:
parts = line.strip().split("\t")[0].split(".")
if len(parts) >= 2:
return ScreenSession(pid=parts[0], name=name, attached=False)
raise HTTPException(status_code=500, detail="Session created but could not find PID")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/history")
async def get_history(user: str = Depends(verify_token), limit: int = 20) -> list[Conversation]:
"""Get conversation history"""
@@ -291,10 +340,13 @@ async def websocket_chat(websocket: WebSocket):
# Build claude command
cmd = [CLAUDE_CMD, "-p", user_message, "--output-format", "stream-json"]
# Add file context if provided
# Add file context if provided (only from uploads dir)
upload_dir = "/tmp/captain-uploads"
for file_path in context_files:
if os.path.exists(file_path):
cmd.extend(["--file", file_path])
# Security: Only allow files from upload directory
real_path = os.path.realpath(file_path)
if real_path.startswith(upload_dir) and os.path.exists(real_path):
cmd.extend(["--file", real_path])
# Stream response from claude
try:
@@ -394,21 +446,38 @@ async def websocket_terminal(websocket: WebSocket, session_name: str):
try:
jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except:
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
await websocket.close(code=4001, reason="Invalid token")
return
# Validate session_name format (prevent command injection)
import re
if not re.match(r'^[0-9]+\.[a-z0-9-]+$', session_name):
await websocket.close(code=4003, reason="Invalid session name format")
return
# Verify session exists before connecting
ls_result = subprocess.run(["screen", "-ls"], capture_output=True, text=True)
if session_name not in ls_result.stdout:
await websocket.close(code=4004, reason=f"Session '{session_name}' not found")
return
await websocket.send_json({"type": "connected", "session": session_name})
# Create PTY and attach to screen session
master_fd, slave_fd = pty.openpty()
# Set TERM environment for screen
env = os.environ.copy()
env["TERM"] = "xterm-256color"
process = subprocess.Popen(
["screen", "-x", session_name],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
preexec_fn=os.setsid
preexec_fn=os.setsid,
env=env
)
os.close(slave_fd)

Submodule apps/flow-ui updated: f0c09b10ad...0b49fc77e2

Submodule apps/mindlink updated: 40c0944cf7...1cf3453ad1