diff --git a/.env.example b/.env.example index 3a79607a..cf8d2fd5 100644 --- a/.env.example +++ b/.env.example @@ -43,7 +43,7 @@ OPENAI_API_KEY=sk-... # Host to bind the Status Server to # Default: 0.0.0.0 (all interfaces) -# API_HOST=0.0.0.0 +# API_HOST=127.0.0.1 # loopback by default (#935); 0.0.0.0 exposes the API on your network # Port for the Status Server # Default: 8080 diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index d03aa6a1..d78efb3f 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -23,6 +23,7 @@ import click import typer from rich.console import Console +from rich.markup import escape # Import auth subapp for credential management from codeframe.cli.auth_commands import auth_app @@ -217,7 +218,7 @@ def init( if hook_result.success: console.print(f" Hook after_init: [green]OK[/green] ({hook_result.duration_ms}ms)") else: - console.print(f" Hook after_init: [yellow]failed[/yellow] ({hook_result.stderr[:100]})") + console.print(f" Hook after_init: [yellow]failed[/yellow] ({escape(hook_result.stderr[:100])})") # Generate CODEFRAME.md if requested (never clobber an existing one, #778) if generate_config: @@ -481,7 +482,7 @@ def status( console.print("\n[bold]PRD[/bold]") latest_prd = prd.get_latest(workspace) if latest_prd: - console.print(f" Title: [green]{latest_prd.title}[/green]") + console.print(f" Title: [green]{escape(latest_prd.title)}[/green]") console.print(f" Added: {latest_prd.created_at.strftime('%Y-%m-%d %H:%M')}") else: console.print(" [dim]No PRD loaded. Run 'codeframe prd add '[/dim]") @@ -582,7 +583,7 @@ def summary( # PRD latest_prd = prd.get_latest(workspace) if latest_prd: - console.print(f"[bold]PRD:[/bold] {latest_prd.title}") + console.print(f"[bold]PRD:[/bold] {escape(latest_prd.title)}") else: console.print("[bold]PRD:[/bold] [dim]None[/dim]") @@ -681,11 +682,11 @@ def review( status_str = "[yellow]ERROR[/yellow]" duration_str = f" ({check.duration_ms}ms)" if check.duration_ms else "" - console.print(f" {check.name}: {status_str}{duration_str}") + console.print(f" {escape(check.name)}: {status_str}{duration_str}") # Show output for failures or verbose mode if check.output and (check.status == GateStatus.FAILED or verbose): - console.print(f" [dim]{check.output}[/dim]") + console.print(f" [dim]{escape(check.output)}[/dim]") # Diagnostics that are not gate verdicts (#909) — e.g. a failed # dependency install. Always shown: the gates may all have passed, and @@ -696,9 +697,9 @@ def review( # Summary console.print() if result.passed: - console.print(f"[bold green]All gates passed[/bold green] ({result.summary})") + console.print(f"[bold green]All gates passed[/bold green] ({escape(result.summary)})") else: - console.print(f"[bold red]Gates failed[/bold red] ({result.summary})") + console.print(f"[bold red]Gates failed[/bold red] ({escape(result.summary)})") raise typer.Exit(1) except FileNotFoundError: @@ -707,10 +708,46 @@ def review( raise typer.Exit(1) +#: Bind addresses that accept connections from outside this machine. +_NON_LOOPBACK_BINDS = {"0.0.0.0", "::", "*", ""} + + +def _warn_if_exposed(host: str) -> None: + """Warn when binding beyond loopback, loudly if auth is off (#935). + + The server exposes SQLite state, workspace file access and + agent-execution endpoints. Exposing that on a LAN is a deliberate choice; + doing it with CODEFRAME_AUTH_REQUIRED=false — which the dev docs suggest — + is an unauthenticated remote shell, so that combination gets a stronger + warning rather than a quiet default. + """ + if host.strip() not in _NON_LOOPBACK_BINDS: + return + + auth_off = os.environ.get("CODEFRAME_AUTH_REQUIRED", "").strip().lower() in { + "0", "false", "no", "off", + } + console.print( + f"[yellow]WARNING:[/yellow] binding {escape(host)} accepts connections " + "from other machines on your network." + ) + if auth_off: + console.print( + "[bold red]CODEFRAME_AUTH_REQUIRED is disabled.[/bold red] The API — " + "including workspace file access and agent execution — is reachable " + "with no credentials. Do not use this combination outside a trusted " + "network." + ) + + @app.command() def serve( port: int = typer.Option(8080, "--port", "-p", help="Port to run server on"), - host: str = typer.Option("0.0.0.0", "--host", help="Host to bind to"), + host: str = typer.Option( + "127.0.0.1", + "--host", + help="Host to bind to. Use 0.0.0.0 to expose on the network (see warning).", + ), reload: bool = typer.Option(False, "--reload", help="Enable auto-reload (development)"), ) -> None: """Start the optional FastAPI server (wraps core). @@ -718,13 +755,19 @@ def serve( The server is NOT required for Golden Path commands. It provides the v2 REST API that wraps the same core functions. + Binds 127.0.0.1 by default (#935). This process exposes SQLite state, + workspace file access and agent-execution endpoints, so it is loopback-only + until you explicitly ask for otherwise. + Examples: cf serve cf serve --port 3000 - cf serve --reload + cf serve --host 0.0.0.0 # expose on the network — see the warning """ import uvicorn + _warn_if_exposed(host) + console.print(f"Starting CodeFRAME API server on {host}:{port}") console.print(f" Swagger UI: http://localhost:{port}/docs") console.print(f" ReDoc: http://localhost:{port}/redoc") @@ -775,8 +818,8 @@ def prd_templates_list() -> None: console.print("\n[bold]Available PRD Templates:[/bold]\n") for template in templates: section_count = len(template.sections) - console.print(f" [green]{template.id}[/green] - {template.name}") - console.print(f" {template.description}") + console.print(f" [green]{template.id}[/green] - {escape(template.name)}") + console.print(f" {escape(template.description)}") console.print(f" Sections: {section_count} | Version: {template.version}") console.print() @@ -806,14 +849,14 @@ def prd_templates_show( console.print(f" {t.id}") raise typer.Exit(1) - console.print(f"\n[bold]{template.name}[/bold] ({template.id})\n") - console.print(f"{template.description}\n") + console.print(f"\n[bold]{escape(template.name)}[/bold] ({template.id})\n") + console.print(f"{escape(template.description)}\n") console.print(f"Version: {template.version}") console.print("\n[bold]Sections:[/bold]\n") for i, section in enumerate(template.sections, 1): required = "[green]required[/green]" if section.required else "[dim]optional[/dim]" - console.print(f" {i}. {section.title} ({section.id})") + console.print(f" {i}. {escape(section.title)} ({section.id})") console.print(f" Source: {section.source} | {required}") console.print() @@ -873,7 +916,7 @@ def prd_templates_import( try: # Import and persist to project directory template = manager.import_template(source_path, persist=True) - console.print(f"[green]✓[/green] Imported template '{template.id}' ({template.name})") + console.print(f"[green]✓[/green] Imported template '{template.id}' ({escape(template.name)})") console.print(f"[dim]Sections: {len(template.sections)}[/dim]") console.print(f"[dim]Saved to: .codeframe/templates/prd/{template.id}.yaml[/dim]") except Exception as e: @@ -928,7 +971,7 @@ def prd_add( ) console.print("[green]PRD added[/green]") - console.print(f" Title: {record.title}") + console.print(f" Title: {escape(record.title)}") console.print(f" ID: {record.id}") console.print(f" Source: {file_path}") console.print() @@ -984,7 +1027,7 @@ def prd_show( console.print("Add one with: codeframe prd add ") return - console.print(f"\n[bold]PRD:[/bold] {record.title}") + console.print(f"\n[bold]PRD:[/bold] {escape(record.title)}") console.print(f"[dim]ID: {record.id}[/dim]") console.print(f"[dim]Added: {record.created_at}[/dim]") @@ -1038,7 +1081,7 @@ def prd_list( console.print(f"\n[bold]PRDs ({len(records)}):[/bold]\n") for record in records: - console.print(f" {record.id[:8]}... [bold]{record.title}[/bold]") + console.print(f" {record.id[:8]}... [bold]{escape(record.title)}[/bold]") console.print(f" [dim]Added: {record.created_at.strftime('%Y-%m-%d %H:%M')}[/dim]") console.print() @@ -1109,7 +1152,7 @@ def prd_delete( print_event=False, ) - console.print(f"[green]PRD deleted:[/green] {record.title}") + console.print(f"[green]PRD deleted:[/green] {escape(record.title)}") except FileNotFoundError as e: console.print(f"[red]Error:[/red] {e}") @@ -1461,10 +1504,10 @@ def prd_generate( console.print(f"[red]Error:[/red] Template '{template}' not found.") console.print("\nAvailable templates:") for t in template_manager.list_templates(): - console.print(f" {t.id} - {t.name}") + console.print(f" {t.id} - {escape(t.name)}") raise typer.Exit(1) - console.print(f"[dim]Using template: {template_obj.name}[/dim]") + console.print(f"[dim]Using template: {escape(template_obj.name)}[/dim]") try: workspace = get_workspace(workspace_path) @@ -1619,7 +1662,7 @@ def prd_generate( ) # Show result - console.print(f"\n[green]✓[/green] PRD generated: [bold]{prd_record.title}[/bold]") + console.print(f"\n[green]✓[/green] PRD generated: [bold]{escape(prd_record.title)}[/bold]") console.print(f"[dim]ID: {prd_record.id}[/dim]") # Show preview @@ -1738,7 +1781,7 @@ def prd_stress_test( console.print("[red]Error:[/red] No PRD found. Run 'codeframe prd generate' first.") raise typer.Exit(1) - console.print(f"[dim]Stress-testing PRD: {record.title} (v{record.version})[/dim]") + console.print(f"[dim]Stress-testing PRD: {escape(record.title)} (v{record.version})[/dim]") # Build provider: flag → env → config → anthropic, validating the # matching API key (#768) @@ -1762,11 +1805,11 @@ def prd_stress_test( if result.ambiguities: console.print(f"\n[bold yellow]⚠ {len(result.ambiguities)} ambiguities found[/bold yellow]\n") for i, amb in enumerate(result.ambiguities, 1): - console.print(f"[bold yellow]{i}. {amb.label}[/bold yellow] (from \"{amb.source_node_title}\")") + console.print(f"[bold yellow]{i}. {escape(amb.label)}[/bold yellow] (from \"{escape(amb.source_node_title)}\")") console.print(" The PRD doesn't specify:") for q in amb.questions: console.print(f" [cyan]- {q}[/cyan]") - console.print(f" → {amb.recommendation}") + console.print(f" → {escape(amb.recommendation)}") console.print() else: console.print("\n[green]✓ No ambiguities found — PRD is well-specified.[/green]\n") @@ -1775,7 +1818,7 @@ def prd_stress_test( if interactive and result.ambiguities: console.print("[bold]Interactive mode — resolve ambiguities:[/bold]\n") for amb in result.ambiguities: - console.print(f"[yellow]{amb.label}[/yellow]: {', '.join(amb.questions)}") + console.print(f"[yellow]{escape(amb.label)}[/yellow]: {escape(', '.join(amb.questions))}") answer = typer.prompt("Your answer") amb.resolved_answer = answer console.print("[green]✓[/green] Recorded.\n") @@ -1917,7 +1960,7 @@ def tasks_generate( {t.id for t in tasks.list_tasks(workspace)} if overwrite else set() ) - console.print(f"Generating tasks from PRD: [bold]{prd_record.title}[/bold]") + console.print(f"Generating tasks from PRD: [bold]{escape(prd_record.title)}[/bold]") if recursive and no_llm: console.print( @@ -1999,7 +2042,7 @@ def tasks_generate( console.print(f"\n[green]Generated {len(created)} tasks[/green]\n") for i, task in enumerate(created, 1): - console.print(f" {i}. {task.title}") + console.print(f" {i}. {escape(task.title)}") if task.description: # Show first line of description desc_preview = task.description.split("\n")[0][:60] @@ -2117,7 +2160,7 @@ def tasks_list( f"[{status_style}]{task.status.value}[/{status_style}]", str(task.priority), deps_str, - task.title[:55] + ("..." if len(task.title) > 55 else ""), + escape(task.title[:55]) + ("..." if len(task.title) > 55 else ""), ) console.print(table) @@ -2138,6 +2181,84 @@ def tasks_list( raise typer.Exit(1) +@tasks_app.command("show") +def tasks_show( + task_id: str = typer.Argument(..., help="Task ID (may be a unique prefix)"), + repo_path: Optional[Path] = typer.Option( + None, + "--workspace", "-w", + help="Workspace path (defaults to current directory)", + ), +) -> None: + """Show a task's details and dependencies. + + README.md and CLAUDE.md have advertised this command for some time while it + did not exist (#935). + + Examples: + cf tasks show 3f2a1b + cf tasks show 3f2a1b8c-... --workspace ../other-repo + """ + from codeframe.core.workspace import get_workspace + from codeframe.core import tasks + + workspace_path = repo_path or Path.cwd() + + try: + workspace = get_workspace(workspace_path) + except FileNotFoundError: + console.print(f"[red]Error:[/red] No workspace found at {workspace_path}") + console.print("Run 'codeframe init' to initialize a workspace first.") + raise typer.Exit(1) + + task = tasks.get(workspace, task_id) + if task is None: + matches = tasks.find_by_prefix(workspace, task_id) + if not matches: + console.print(f"[red]Error:[/red] No task matching '{escape(task_id)}'") + raise typer.Exit(1) + if len(matches) > 1: + console.print( + f"[red]Error:[/red] '{escape(task_id)}' matches {len(matches)} tasks:" + ) + for match in matches[:10]: + console.print(f" {match.id[:8]} {escape(match.title[:60])}") + raise typer.Exit(1) + task = matches[0] + + # escape(): a title or description may contain Rich markup like '[/b]', + # which would otherwise raise MarkupError instead of printing (#935). + console.print(f"\n[bold]{escape(task.title)}[/bold]") + console.print(f"[dim]{task.id}[/dim]\n") + console.print(f" Status: {task.status.value}") + console.print(f" Priority: {task.priority}") + if task.estimated_hours is not None: + console.print(f" Estimated: {task.estimated_hours}h") + if task.complexity_score is not None: + console.print(f" Complexity: {task.complexity_score}") + console.print(f" Created: {task.created_at:%Y-%m-%d %H:%M}") + console.print(f" Updated: {task.updated_at:%Y-%m-%d %H:%M}") + + if task.github_issue_number: + console.print(f" GitHub: #{task.github_issue_number} {escape(task.external_url or '')}") + + deps = task.depends_on or [] + console.print(f"\n[bold]Dependencies[/bold] ({len(deps)})") + if not deps: + console.print(" [dim]none[/dim]") + else: + titles = tasks.get_titles(workspace, deps) + for dep_id in deps: + title = titles.get(dep_id) + label = escape(title) if title else "[dim](missing)[/dim]" + console.print(f" {dep_id[:8]} {label}") + + if task.description: + console.print("\n[bold]Description[/bold]") + console.print(escape(task.description)) + console.print() + + @tasks_app.command("set") def tasks_set( attribute: str = typer.Argument(..., help="Attribute to set (e.g., 'status')"), @@ -2234,7 +2355,7 @@ def tasks_set( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{actual_task_id}':") for t in matching: - console.print(f" {t.id}: {t.title[:40]}") + console.print(f" {t.id}: {escape(t.title[:40])}") console.print("Please provide a more specific ID.") raise typer.Exit(1) else: @@ -2277,7 +2398,7 @@ def tasks_set( task = matching[0] if updated_count: console.print("[green]Task updated[/green]") - console.print(f" {task.title[:50]}") + console.print(f" {escape(task.title[:50])}") console.print(f" Status: {task.status.value} -> {new_status.value}") else: console.print(f"[yellow]Task already {new_status.value}[/yellow]") @@ -2372,14 +2493,14 @@ def tasks_delete( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching: - console.print(f" {t.id}: {t.title[:40]}") + console.print(f" {t.id}: {escape(t.title[:40])}") console.print("Please provide a more specific ID.") raise typer.Exit(1) task = matching[0] if not force: - confirm = typer.confirm(f"Delete task '{task.title[:50]}'?") + confirm = typer.confirm(f"Delete task '{task.title[:50]}'?") # typer.confirm is not Rich-rendered if not confirm: console.print("[dim]Cancelled[/dim]") raise typer.Exit(0) @@ -2391,13 +2512,13 @@ def tasks_delete( f"[yellow]Warning:[/yellow] {len(dependents)} task(s) depend on this task" ) for dep in dependents[:3]: - console.print(f" - {dep.id[:8]}: {dep.title[:40]}") + console.print(f" - {dep.id[:8]}: {escape(dep.title[:40])}") if len(dependents) > 3: console.print(f" ... and {len(dependents) - 3} more") deleted = tasks.delete(workspace, task.id) if deleted: - console.print(f"[green]Deleted task:[/green] {task.title[:50]}") + console.print(f"[green]Deleted task:[/green] {escape(task.title[:50])}") else: console.print("[red]Error:[/red] Failed to delete task") raise typer.Exit(1) @@ -2541,7 +2662,7 @@ def work_start( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching[:5]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) task = matching[0] @@ -2580,7 +2701,7 @@ def work_start( run = runtime.start_task_run(workspace, task.id) console.print("\n[bold green]Run started[/bold green]") - console.print(f" Task: {task.title}") + console.print(f" Task: {escape(task.title)}") console.print(f" Run ID: [dim]{run.id}[/dim]") console.print(" Status: [yellow]RUNNING[/yellow]") @@ -2608,14 +2729,14 @@ def work_start( elif state.status == AgentStatus.BLOCKED: console.print("[yellow]Task blocked - human input needed[/yellow]") if state.blocker: - console.print(f" Question: {state.blocker.question}") + console.print(f" Question: {escape(state.blocker.question)}") console.print(" Use 'codeframe blocker list' to see blockers") elif state.status == AgentStatus.FAILED: console.print("[red]Task execution failed[/red]") if state.step_results: last_result = state.step_results[-1] if last_result.error: - console.print(f" Error: {last_result.error[:200]}") + console.print(f" Error: {escape(last_result.error[:200])}") # Show debug log location if debugging was enabled if debug: @@ -2694,7 +2815,7 @@ def work_resume( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching[:5]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) task = matching[0] @@ -2703,7 +2824,7 @@ def work_resume( run = runtime.resume_run(workspace, task.id) console.print("\n[bold green]Run resumed[/bold green]") - console.print(f" Task: {task.title}") + console.print(f" Task: {escape(task.title)}") console.print(f" Run ID: [dim]{run.id}[/dim]") console.print(" Status: [yellow]RUNNING[/yellow]") @@ -2738,7 +2859,7 @@ def work_resume( elif state.status == AgentStatus.BLOCKED: console.print("[yellow]Task blocked - human input needed[/yellow]") if state.blocker: - console.print(f" Question: {state.blocker.question}") + console.print(f" Question: {escape(state.blocker.question)}") console.print(" Use 'codeframe blocker list' to see blockers") elif state.status == AgentStatus.FAILED: console.print("[red]Task execution failed[/red]") @@ -2799,7 +2920,7 @@ def work_stop( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching[:5]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) task = matching[0] @@ -2808,7 +2929,7 @@ def work_stop( run = runtime.stop_run(workspace, task.id) console.print("\n[bold yellow]Run stopped[/bold yellow]") - console.print(f" Task: {task.title}") + console.print(f" Task: {escape(task.title)}") console.print(f" Run ID: [dim]{run.id}[/dim]") console.print(" Task returned to: [blue]READY[/blue]") @@ -3007,7 +3128,7 @@ def work_diagnose( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching[:5]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) task = matching[0] @@ -3017,7 +3138,7 @@ def work_diagnose( failed_runs = [r for r in runs if r.status == runtime.RunStatus.FAILED] if not failed_runs: - console.print(f"[yellow]No failed run found for task '{task.title}'[/yellow]") + console.print(f"[yellow]No failed run found for task '{escape(task.title)}'[/yellow]") console.print("[dim]Diagnosis is only available for failed tasks.[/dim]") raise typer.Exit(1) @@ -3083,7 +3204,7 @@ def _display_diagnostic_report( console.print("\n[bold]Recommendations:[/bold]\n") for i, rec in enumerate(report.recommendations, 1): console.print(f" {i}. [cyan]{rec.action.value}[/cyan]") - console.print(f" {rec.reason}") + console.print(f" {escape(rec.reason)}") console.print(f" [dim]Command:[/dim] [green]{rec.command}[/green]") console.print() @@ -3099,7 +3220,7 @@ def _display_diagnostic_report( if logs: console.print(f"\n[bold]Recent Errors ({len(logs)}):[/bold]") for log in logs[:5]: - console.print(f" [red]ERROR[/red] {log.category.value}: {log.message[:100]}") + console.print(f" [red]ERROR[/red] {log.category.value}: {escape(log.message[:100])}") console.print("━" * 60) console.print(f"[dim]Report ID: {report.id}[/dim]") @@ -3155,7 +3276,7 @@ def work_retry( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching[:5]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) task = matching[0] @@ -3188,7 +3309,7 @@ def work_retry( raise typer.Exit(0) # Start new run - console.print(f"\n[bold]Retrying task:[/bold] {task.title}") + console.print(f"\n[bold]Retrying task:[/bold] {escape(task.title)}") run = runtime.start_task_run(workspace, task.id) console.print(f" Run ID: [dim]{run.id}[/dim]") @@ -3208,7 +3329,7 @@ def work_retry( elif state.status == AgentStatus.BLOCKED: console.print("[yellow]Task blocked - human input needed[/yellow]") if state.blocker: - console.print(f" Question: {state.blocker.question}") + console.print(f" Question: {escape(state.blocker.question)}") console.print(" Use 'codeframe blocker list' to see blockers") elif state.status == AgentStatus.FAILED: console.print("[red]Task execution failed[/red]") @@ -3265,7 +3386,7 @@ def work_update_description( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching[:5]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) task = matching[0] @@ -3274,8 +3395,13 @@ def work_update_description( tasks_module.update(workspace, task.id, description=description) console.print("[green]Task description updated[/green]") - console.print(f" Task: {task.title}") - console.print(f" New description: {description[:100]}{'...' if len(description) > 100 else ''}") + console.print(f" Task: {escape(task.title)}") + # escape(): the description is user input and Rich renders markup — + # "Array indexing a[0] and b[1]" would raise MarkupError (#935). + console.print( + f" New description: {escape(description[:100])}" + f"{'...' if len(description) > 100 else ''}" + ) console.print("\nNext steps:") console.print(f" codeframe work retry {task.id[:8]} # Retry with updated description") @@ -3343,7 +3469,7 @@ def work_follow( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{task_id}':") for t in matching[:5]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) task = matching[0] @@ -3367,7 +3493,7 @@ def work_follow( console.print( f"[{status_color}]Run {last_run.status.value}[/{status_color}] " - f"for task: {task.title}" + f"for task: {escape(task.title)}" ) # Show final output if available @@ -3385,13 +3511,13 @@ def work_follow( raise typer.Exit(0) else: - console.print(f"[yellow]No active run found for task:[/yellow] {task.title}") + console.print(f"[yellow]No active run found for task:[/yellow] {escape(task.title)}") console.print("[dim]Start a run with:[/dim]") console.print(f" cf work start {task.id[:8]} --execute") raise typer.Exit(1) # We have an active run - stream it - console.print(f"[blue]Following task:[/blue] {task.title}") + console.print(f"[blue]Following task:[/blue] {escape(task.title)}") console.print(f"[dim]Run: {active_run.id[:8]} | Status: {active_run.status.value}[/dim]") # Show buffered output if requested @@ -3553,7 +3679,7 @@ def work_replay( for s in steps_to_show: status_color = {"completed": "green", "failed": "red"}.get(s.status, "yellow") console.print( - f"\n[bold]Step {s.step_number}:[/bold] {s.description} " + f"\n[bold]Step {s.step_number}:[/bold] {escape(s.description)} " f"[{status_color}][{s.status}][/{status_color}]" ) @@ -3971,7 +4097,7 @@ def batch_run( if len(matching) > 1: console.print(f"[red]Error:[/red] Multiple tasks match '{partial_id}':") for t in matching[:3]: - console.print(f" {t.id[:8]} - {t.title}") + console.print(f" {t.id[:8]} - {escape(t.title)}") raise typer.Exit(1) ids_to_execute.append(matching[0].id) else: @@ -4083,7 +4209,7 @@ def batch_run( console.print("\n[bold]Code Review Results[/bold]") for check in gate_result.checks: status_color = "green" if check.status.value == "PASSED" else "red" - console.print(f" [{status_color}]{check.name}[/{status_color}]: {check.status.value}") + console.print(f" [{status_color}]{escape(check.name)}[/{status_color}]: {check.status.value}") if check.output and check.status.value != "PASSED": # Show truncated output for failures output_lines = check.output.strip().split("\n")[:5] @@ -4874,11 +5000,11 @@ def blocker_show( console.print(f" Created: {blocker.created_at.strftime('%Y-%m-%d %H:%M')}") console.print("\n[bold]Question:[/bold]") - console.print(f" {blocker.question}") + console.print(f" {escape(blocker.question)}") if blocker.answer: console.print("\n[bold]Answer:[/bold]") - console.print(f" {blocker.answer}") + console.print(f" {escape(blocker.answer)}") if blocker.answered_at: console.print(f" [dim]Answered: {blocker.answered_at.strftime('%Y-%m-%d %H:%M')}[/dim]") @@ -5215,7 +5341,7 @@ def commit_create( console.print("\n[bold green]Commit created[/bold green]") console.print(f" Hash: {commit_info.hash}") - console.print(f" Message: {commit_info.message}") + console.print(f" Message: {escape(commit_info.message)}") console.print( f" Changes: {commit_info.files_changed} files, " f"+{commit_info.insertions}/-{commit_info.deletions}" @@ -5267,7 +5393,7 @@ def checkpoint_create( summary = checkpoint.snapshot.get("summary", {}) console.print("\n[bold green]Checkpoint created[/bold green]") - console.print(f" Name: {checkpoint.name}") + console.print(f" Name: {escape(checkpoint.name)}") console.print(f" ID: [dim]{checkpoint.id[:8]}[/dim]") console.print(f" Tasks: {summary.get('total_tasks', 0)}") @@ -5365,7 +5491,7 @@ def checkpoint_show( summary = checkpoint.snapshot.get("summary", {}) tasks_by_status = summary.get("tasks_by_status", {}) - console.print(f"\n[bold]Checkpoint:[/bold] {checkpoint.name}") + console.print(f"\n[bold]Checkpoint:[/bold] {escape(checkpoint.name)}") console.print(f" ID: {checkpoint.id}") console.print(f" Created: {checkpoint.created_at.strftime('%Y-%m-%d %H:%M:%S')}") @@ -5418,7 +5544,7 @@ def checkpoint_restore( summary = checkpoint.snapshot.get("summary", {}) console.print("\n[bold green]Checkpoint restored[/bold green]") - console.print(f" Name: {checkpoint.name}") + console.print(f" Name: {escape(checkpoint.name)}") console.print(f" Tasks restored: {summary.get('total_tasks', 0)}") except FileNotFoundError: @@ -5758,7 +5884,7 @@ def schedule_bottlenecks( console.print(f"[yellow]Task {bn.task_id}:[/yellow] {title}") console.print(f" Type: {bn.bottleneck_type}") console.print(f" Impact: {bn.impact_hours:.1f} hours") - console.print(f" Recommendation: {bn.recommendation}") + console.print(f" Recommendation: {escape(bn.recommendation)}") console.print() except FileNotFoundError as e: @@ -5816,8 +5942,8 @@ def templates_list( console.print("\n[bold]Available Templates:[/bold]\n") for template in templates: hours = template.total_estimated_hours - console.print(f" [green]{template.id}[/green] - {template.name}") - console.print(f" {template.description}") + console.print(f" [green]{template.id}[/green] - {escape(template.name)}") + console.print(f" {escape(template.description)}") console.print(f" Category: {template.category} | Tasks: {len(template.tasks)} | Est: {hours:.1f}h") console.print() @@ -5842,8 +5968,8 @@ def templates_show( console.print(f" {t.id}") raise typer.Exit(1) - console.print(f"\n[bold]{template.name}[/bold] ({template.id})\n") - console.print(f"{template.description}\n") + console.print(f"\n[bold]{escape(template.name)}[/bold] ({template.id})\n") + console.print(f"{escape(template.description)}\n") console.print(f"Category: {template.category}") console.print(f"Total Estimated Hours: {template.total_estimated_hours:.1f}h") if template.tags: @@ -5854,8 +5980,8 @@ def templates_show( deps = "" if task.depends_on_indices: deps = f" (depends on: {', '.join(str(d+1) for d in task.depends_on_indices)})" - console.print(f" {i}. {task.title}{deps}") - console.print(f" {task.description}") + console.print(f" {i}. {escape(task.title)}{deps}") + console.print(f" {escape(task.description)}") console.print(f" Est: {task.estimated_hours}h | Complexity: {task.complexity_score}/5 | Uncertainty: {task.uncertainty_level}") console.print() @@ -5942,7 +6068,7 @@ def templates_apply( console.print(f"\n[green]Created {len(created_task_list)} tasks from template '{template_id}'[/green]\n") for i, task in enumerate(created_task_list, 1): - console.print(f" {i}. {task.title}") + console.print(f" {i}. {escape(task.title)}") console.print("\nNext steps:") console.print(" codeframe tasks list View all tasks") diff --git a/codeframe/cli/auth_commands.py b/codeframe/cli/auth_commands.py index bb4cd63c..a77584bb 100644 --- a/codeframe/cli/auth_commands.py +++ b/codeframe/cli/auth_commands.py @@ -35,6 +35,8 @@ import logging import os +import sys +from pathlib import Path from typing import Optional, Tuple import requests @@ -537,7 +539,24 @@ def setup_credential( None, "--provider", "-p", help="Provider name (e.g., anthropic, github, openai)" ), value: Optional[str] = typer.Option( - None, "--value", "-v", help="Credential value (API key or token)", hide_input=True + None, + "--value", + "-v", + help=( + "DEPRECATED — the value appears in /proc//cmdline and your " + "shell history. Pipe it on stdin or omit this to be prompted." + ), + hidden=True, + ), + value_stdin: bool = typer.Option( + False, + "--value-stdin", + help="Read the credential value from stdin (for scripts and CI).", + ), + value_file: Optional[Path] = typer.Option( + None, + "--value-file", + help="Read the credential value from a file (the first line).", ), ): """Configure a credential for a provider. @@ -546,16 +565,55 @@ def setup_credential( and other integrations. Credentials are stored in the system keyring or an encrypted file. + The value is never taken from the command line by default (#935): anything + in argv is world-readable via /proc//cmdline while the process runs and + is written to your shell history. + Examples: - codeframe auth setup # Interactive mode + codeframe auth setup # interactive — prompts, input hidden + + codeframe auth setup --provider anthropic # prompts for the value - codeframe auth setup --provider anthropic --value sk-ant-... + echo "$MY_KEY" | codeframe auth setup -p github --value-stdin - codeframe auth setup -p github -v ghp_... + codeframe auth setup -p github --value-file ~/.secrets/gh-token """ manager = CredentialManager() + # A non-interactive value source is incompatible with the interactive + # provider prompt: typer.prompt() reads stdin, so with `--value-stdin` and + # no --provider it would consume the piped SECRET as the provider choice and + # then echo it back in "Unknown provider: sk-ant-..." — leaking the exact + # thing this flag exists to protect (PR review on #935). + if (value_stdin or value_file is not None) and not provider: + console.print( + "[red]Error:[/red] --provider is required with --value-stdin/--value-file." + ) + console.print(" e.g. echo \"$KEY\" | codeframe auth setup -p github --value-stdin") + raise typer.Exit(1) + + if value is not None: + console.print( + "[yellow]WARNING:[/yellow] --value puts the credential in this " + "process's command line, readable by any local process via " + "/proc//cmdline, and in your shell history. Use --value-stdin " + "or --value-file instead." + ) + + # Read the non-interactive value FIRST: stdin can only be consumed once, and + # the provider prompt below also reads it. + if value_stdin: + # .readline() not .read(): a trailing newline from `echo` is not part of + # the secret, and a here-doc may carry more than one line. + value = sys.stdin.readline().strip() + elif value_file is not None: + try: + value = value_file.read_text(encoding="utf-8").splitlines()[0].strip() + except (OSError, IndexError, UnicodeDecodeError) as exc: + console.print(f"[red]Error:[/red] could not read {value_file}: {exc}") + raise typer.Exit(1) + # Interactive provider selection if not provided if not provider: console.print("\n[bold]Select a provider to configure:[/bold]\n") @@ -577,8 +635,13 @@ def setup_credential( # Resolve provider name try: provider_enum = resolve_provider_name(provider) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") + except ValueError: + # Deliberately does NOT echo the rejected value: if stdin were ever + # mis-consumed the value would be the credential itself. + console.print( + "[red]Error:[/red] Unknown provider. Expected one of: " + "anthropic, openai, github, gitlab." + ) raise typer.Exit(1) # Prompt for value if not provided @@ -675,8 +738,13 @@ def validate_credential( # Resolve provider try: provider_enum = resolve_provider_name(provider) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") + except ValueError: + # Deliberately does NOT echo the rejected value: if stdin were ever + # mis-consumed the value would be the credential itself. + console.print( + "[red]Error:[/red] Unknown provider. Expected one of: " + "anthropic, openai, github, gitlab." + ) raise typer.Exit(1) # Get credential @@ -733,8 +801,13 @@ def rotate_credential( # Resolve provider try: provider_enum = resolve_provider_name(provider) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") + except ValueError: + # Deliberately does NOT echo the rejected value: if stdin were ever + # mis-consumed the value would be the credential itself. + console.print( + "[red]Error:[/red] Unknown provider. Expected one of: " + "anthropic, openai, github, gitlab." + ) raise typer.Exit(1) # Check if credential exists @@ -800,8 +873,13 @@ def remove_credential( # Resolve provider try: provider_enum = resolve_provider_name(provider) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") + except ValueError: + # Deliberately does NOT echo the rejected value: if stdin were ever + # mis-consumed the value would be the credential itself. + console.print( + "[red]Error:[/red] Unknown provider. Expected one of: " + "anthropic, openai, github, gitlab." + ) raise typer.Exit(1) # Check if credential exists in storage (not just environment) diff --git a/codeframe/core/config.py b/codeframe/core/config.py index f2b2ace7..c8d9a61a 100644 --- a/codeframe/core/config.py +++ b/codeframe/core/config.py @@ -683,7 +683,9 @@ class GlobalConfig(BaseSettings): database_path: str = Field(".codeframe/state.db", alias="DATABASE_PATH") # Status Server configuration - api_host: str = Field("0.0.0.0", alias="API_HOST") + # Loopback by default (#935): the API exposes SQLite state, workspace + # file access and agent execution. Set API_HOST=0.0.0.0 to expose it. + api_host: str = Field("127.0.0.1", alias="API_HOST") api_port: int = Field(8080, alias="API_PORT") cors_origins: str = Field("http://localhost:3000,http://localhost:5173", alias="CORS_ORIGINS") diff --git a/codeframe/tui/app.py b/codeframe/tui/app.py index e96db09b..e31a5aba 100644 --- a/codeframe/tui/app.py +++ b/codeframe/tui/app.py @@ -6,6 +6,8 @@ from typing import Optional +from rich.markup import escape + from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical @@ -219,7 +221,7 @@ def _update_task_table(self, data: DashboardData) -> None: color = _STATUS_COLORS.get(status_val, "white") table.add_row( task.id[:8], - task.title[:50], + escape(task.title[:50]), f"[{color}]{status_val}[/{color}]", str(task.priority), ) @@ -241,7 +243,7 @@ def _update_blocker_panel(self, data: DashboardData) -> None: return for blocker in data.blockers: - log.write(f"[red bold]{blocker.id[:8]}[/red bold]: {blocker.question[:60]}") + log.write(f"[red bold]{blocker.id[:8]}[/red bold]: {escape(blocker.question[:60])}") _SEVERITY_COLORS: dict[str, str] = { "critical": "red bold", @@ -269,7 +271,7 @@ def _update_proof_panel(self, data: DashboardData) -> None: from datetime import date days = (req.waiver.expires - date.today()).days log.write( - f"[yellow bold]⚠ {req.id}[/yellow bold]: waiver expires in {days}d — {req.title[:50]}" + f"[yellow bold]⚠ {req.id}[/yellow bold]: waiver expires in {days}d — {escape(req.title[:50])}" ) for req in data.open_requirements: @@ -281,7 +283,7 @@ def _update_proof_panel(self, data: DashboardData) -> None: sev_color = self._SEVERITY_COLORS.get(req.severity.value, "white") log.write( f"[red]{req.id}[/red] [{sev_color}]{req.severity.value}[/{sev_color}]" - f" {req.title[:45]} | {gate_summary}" + f" {escape(req.title[:45])} | {gate_summary}" ) def action_refresh(self) -> None: diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index 90a513f5..db21f1e1 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -893,7 +893,7 @@ async def test_broadcast(message: dict, project_id: int = None): # ============================================================================ -def run_server(host: str = "0.0.0.0", port: int = 8080): +def run_server(host: str = "127.0.0.1", port: int = 8080): """Run the Status Server.""" import uvicorn @@ -908,7 +908,9 @@ def run_server(host: str = "0.0.0.0", port: int = 8080): parser.add_argument( "--host", type=str, - default=os.environ.get("HOST", "0.0.0.0"), + # Loopback by default (#935): this process exposes SQLite state, + # workspace file access and agent execution. Set HOST=0.0.0.0 to expose it. + default=os.environ.get("HOST", "127.0.0.1"), help="Host to bind to (default: 0.0.0.0 or HOST env var)", ) parser.add_argument( diff --git a/tests/cli/test_cli_hardening_935.py b/tests/cli/test_cli_hardening_935.py new file mode 100644 index 00000000..c45f4fef --- /dev/null +++ b/tests/cli/test_cli_hardening_935.py @@ -0,0 +1,340 @@ +"""CLI defaults and output hardening (#935). + +Four separate problems: + +1. `cf serve` and the server defaulted `--host` to 0.0.0.0 while the printed + hints said localhost — so a beta server exposing SQLite state, workspace file + access and agent-execution endpoints sat on the LAN by default. Combined with + the documented `CODEFRAME_AUTH_REQUIRED=false` dev mode that is an + unauthenticated remote shell. +2. `auth setup` accepted the credential via `--value/-v` and the docstring + taught it, exposing keys through /proc//cmdline and shell history. +3. Task titles and blocker text were interpolated into Rich-rendered output with + markup enabled, so a title containing '[/b]' raised MarkupError and crashed + `cf tasks list` and the TUI. +4. README.md and CLAUDE.md advertised `cf tasks show `, which did not exist. +""" + +import inspect +import re +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from codeframe.cli.app import app +from codeframe.core import tasks +from codeframe.core.state_machine import TaskStatus +from codeframe.core.workspace import create_or_load_workspace + +pytestmark = pytest.mark.v2 + +REPO_ROOT = Path(__file__).resolve().parents[2] + +#: Titles that are valid user input and also valid-looking Rich markup. +HOSTILE_TITLES = [ + "Fix the [/b] parser", + "Support [bold] tags", + "Handle [/] gracefully", + "Array indexing a[0] and b[1]", + "[red]not actually markup[/red]", +] + + +@pytest.fixture +def workspace(tmp_path): + return create_or_load_workspace(tmp_path) + + +class TestServeBindsLoopback: + def test_serve_host_default_is_loopback(self): + from codeframe.cli.app import serve + + default = inspect.signature(serve).parameters["host"].default + assert default.default == "127.0.0.1", ( + f"cf serve binds {default.default} by default" + ) + + def test_run_server_default_is_loopback(self): + from codeframe.ui.server import run_server + + assert inspect.signature(run_server).parameters["host"].default == "127.0.0.1" + + def test_env_example_does_not_suggest_a_public_bind(self): + content = (REPO_ROOT / ".env.example").read_text() + + assert "# API_HOST=0.0.0.0" not in content + + def test_exposing_the_bind_warns(self, capsys): + from codeframe.cli.app import _warn_if_exposed + + _warn_if_exposed("0.0.0.0") + + assert "WARNING" in capsys.readouterr().out + + def test_loopback_does_not_warn(self, capsys): + from codeframe.cli.app import _warn_if_exposed + + _warn_if_exposed("127.0.0.1") + + assert capsys.readouterr().out == "" + + def test_exposed_bind_with_auth_disabled_warns_harder(self, capsys, monkeypatch): + """The dangerous combination the issue calls out.""" + from codeframe.cli.app import _warn_if_exposed + + monkeypatch.setenv("CODEFRAME_AUTH_REQUIRED", "false") + _warn_if_exposed("0.0.0.0") + + out = capsys.readouterr().out + assert "CODEFRAME_AUTH_REQUIRED" in out + assert "no credentials" in out + + @pytest.mark.parametrize("bind", ["0.0.0.0", "::", "*"]) + def test_every_wildcard_bind_warns(self, capsys, bind): + from codeframe.cli.app import _warn_if_exposed + + _warn_if_exposed(bind) + + assert "WARNING" in capsys.readouterr().out + + +class TestCredentialsStayOffArgv: + def test_stdin_and_file_options_exist(self): + from codeframe.cli.auth_commands import setup_credential + + params = inspect.signature(setup_credential).parameters + assert "value_stdin" in params + assert "value_file" in params + + def test_value_option_is_hidden_from_help(self): + from codeframe.cli.auth_commands import setup_credential + + value_param = inspect.signature(setup_credential).parameters["value"].default + assert value_param.hidden is True, "--value must not be advertised" + assert "DEPRECATED" in value_param.help + + def test_docstring_no_longer_teaches_passing_the_secret_inline(self): + from codeframe.cli.auth_commands import setup_credential + + doc = setup_credential.__doc__ or "" + assert "--value sk-ant-" not in doc + assert "-v ghp_" not in doc + assert "--value-stdin" in doc, "the safe alternative must be shown" + + +class TestRichMarkupIsEscaped: + """AC3 — a title containing '[/b]' must render, not raise.""" + + @pytest.mark.parametrize("title", HOSTILE_TITLES) + def test_tasks_list_renders_a_hostile_title(self, workspace, title, tmp_path): + tasks.create(workspace, title=title, description="d", status=TaskStatus.READY) + + result = CliRunner().invoke(app, ["tasks", "list", "--workspace", str(tmp_path)]) + + assert result.exception is None, f"{title!r} crashed: {result.exception!r}" + assert result.exit_code == 0 + + @pytest.mark.parametrize("title", HOSTILE_TITLES) + def test_tasks_show_renders_a_hostile_title(self, workspace, title, tmp_path): + task = tasks.create( + workspace, title=title, description=f"see {title}", status=TaskStatus.READY + ) + + result = CliRunner().invoke( + app, ["tasks", "show", task.id[:8], "--workspace", str(tmp_path)] + ) + + assert result.exception is None, f"{title!r} crashed: {result.exception!r}" + assert result.exit_code == 0 + + @pytest.mark.parametrize( + "module", + [ + "codeframe/cli/app.py", + # The TUI renders the same user text through RichLog/DataTable and + # was invisible to the first version of this scanner, which read only + # cli/app.py — the PR bot found req.title unescaped there. + "codeframe/tui/app.py", + ], + ) + def test_no_unescaped_user_text_reaches_rich_output(self, module): + """Scanner, not a point check: a new render of user data should fail the + build rather than wait to be found in review. + + Operates on whole *statements*, not lines. The line-based version missed + `log.write(\n f"... {req.title} ...")` in the TUI because the call and + the f-string sit on different lines — which is how the reviewer found two + sites the scanner had just declared clean. + """ + source = (REPO_ROOT / module).read_text() + + # Join physical lines into logical ones by tracking paren depth. The + # line-based version missed `log.write(\n f"... {req.title} ...")` in + # the TUI because the call and the f-string sit on different lines — + # which is how the reviewer found two sites the scanner had just + # declared clean. (Simple depth counting, not tokenize: 3.12 splits + # f-strings into separate tokens and the brace never survives.) + statements, buf, depth = [], "", 0 + for line in source.splitlines(): + stripped = line.strip() + buf = f"{buf} {stripped}" if buf else stripped + depth += line.count("(") - line.count(")") + if depth <= 0: + statements.append(buf) + buf, depth = "", 0 + + # A denylist of free-text FIELD names — deliberately, after trying the + # alternatives. Enumerating variable names failed (missed 17 sites); + # deny-by-default on every attribute over-fires on 89 statements that + # interpolate timestamps, counts and enum accessors, which would be + # churn rather than safety. This list covers the free-prose fields that + # actually exist in this codebase; a real lint rule is the durable + # answer and is filed as a follow-up. + FREE_TEXT = ( + "title|description|question|answer|label|recommendation|message|" + "output|error|name|summary|content|text|reason|stderr|stdout|" + "source_node_title|feedback|detail|hint|notes" + ) + # The field may appear ANYWHERE inside the interpolation, not only as a + # bare `{obj.field}` — `{', '.join(amb.questions)}` was the fifth gap + # found in this review cycle. Match `{...}` spans, then look inside. + interpolation = re.compile(r"\{[^{}]*\}") + # `s?`: the attribute is often plural (`amb.questions`), and \b after a + # singular name refuses to match it — which is why the reviewer found + # `', '.join(amb.questions)` still unescaped. + free_field = re.compile(r"\.(?:" + FREE_TEXT + r")s?\b") + + def _has_unescaped_field(statement: str) -> bool: + return any( + free_field.search(span) and "escape(" not in span + for span in interpolation.findall(statement) + ) + renders = ("console.print", "log.write", "add_row") + + offenders = [ + st for st in statements + if any(r in st for r in renders) + and _has_unescaped_field(st) + ] + + assert not offenders, ( + f"unescaped user text rendered through Rich in {module}:\n" + + "\n".join(o[:160] for o in offenders) + ) + + def test_markup_is_shown_literally_not_interpreted(self, workspace, tmp_path): + tasks.create( + workspace, title="Fix [/b] now", description="d", status=TaskStatus.READY + ) + + result = CliRunner().invoke(app, ["tasks", "list", "--workspace", str(tmp_path)]) + + assert "[/b]" in result.output, "the literal text should survive escaping" + + def test_rich_would_have_raised_without_escaping(self): + """Guard the guard: prove the hostile titles really are hostile.""" + from rich.console import Console + + console = Console(file=open("/dev/null", "w")) + with pytest.raises(Exception): + console.print(f"[cyan]Title:[/cyan] {HOSTILE_TITLES[0]}") + + +class TestStdinValueNeverLeaks: + """Raised by the PR bot: `--value-stdin` without `--provider` let the + interactive provider prompt consume the piped SECRET as the provider choice, + and the error path then echoed it back — leaking the exact thing the flag + exists to protect.""" + + def test_stdin_without_provider_is_rejected_before_any_prompt(self): + result = CliRunner().invoke( + app, ["auth", "setup", "--value-stdin"], input="sk-ant-SUPERSECRET\n" + ) + + assert result.exit_code == 1 + assert "--provider is required" in result.output + assert "SUPERSECRET" not in result.output, "the piped secret was echoed" + + def test_value_file_without_provider_is_rejected(self, tmp_path): + secret = tmp_path / "k" + secret.write_text("ghp_SUPERSECRET\n") + + result = CliRunner().invoke( + app, ["auth", "setup", "--value-file", str(secret)] + ) + + assert result.exit_code == 1 + assert "SUPERSECRET" not in result.output + + def test_an_unknown_provider_is_not_echoed_back(self): + """The rejected value could be a mis-consumed credential.""" + result = CliRunner().invoke( + app, ["auth", "setup", "--provider", "sk-ant-SUPERSECRET"] + ) + + assert result.exit_code == 1 + assert "SUPERSECRET" not in result.output + assert "Unknown provider" in result.output + + +class TestTasksShowExists: + """AC4 — README.md and CLAUDE.md advertise it.""" + + def test_command_is_registered(self): + result = CliRunner().invoke(app, ["tasks", "--help"]) + + assert "show" in result.output + + def test_shows_details_and_dependencies(self, workspace, tmp_path): + dep = tasks.create(workspace, title="First", description="", status=TaskStatus.READY) + task = tasks.create( + workspace, + title="Second", + description="Do the thing", + status=TaskStatus.READY, + depends_on=[dep.id], + ) + + result = CliRunner().invoke( + app, ["tasks", "show", task.id, "--workspace", str(tmp_path)] + ) + + assert result.exit_code == 0, result.output + assert "Second" in result.output + assert "Do the thing" in result.output + assert "Dependencies" in result.output + assert dep.id[:8] in result.output + assert "First" in result.output, "the dependency's title should resolve" + + def test_accepts_a_unique_prefix(self, workspace, tmp_path): + task = tasks.create(workspace, title="Prefixed", description="", status=TaskStatus.READY) + + result = CliRunner().invoke( + app, ["tasks", "show", task.id[:8], "--workspace", str(tmp_path)] + ) + + assert result.exit_code == 0, result.output + assert "Prefixed" in result.output + + def test_unknown_id_exits_nonzero(self, workspace, tmp_path): + result = CliRunner().invoke( + app, ["tasks", "show", "nosuchtask", "--workspace", str(tmp_path)] + ) + + assert result.exit_code == 1 + assert "No task matching" in result.output + + def test_docs_reference_a_command_that_exists(self): + for doc in ("README.md", "CLAUDE.md"): + content = (REPO_ROOT / doc).read_text() + if "tasks show" in content: + # Imported here, not at module scope: a module-level import + # would turn "the command is missing" into a collection error + # that hides every other test in this file. + from codeframe.cli.app import tasks_show + + assert tasks_show is not None + break + else: + pytest.fail("neither doc mentions `tasks show` — did the reference move?") diff --git a/tests/cli/test_serve_command.py b/tests/cli/test_serve_command.py index 1e17a4c7..5ad77375 100644 --- a/tests/cli/test_serve_command.py +++ b/tests/cli/test_serve_command.py @@ -27,7 +27,13 @@ class TestServeCommand: """`cf serve` wires uvicorn with the requested host/port/reload.""" def test_default_host_port_and_app(self): - """No flags → uvicorn.run on the v2 app at 0.0.0.0:8080, reload off.""" + """No flags → uvicorn.run on the v2 app at 127.0.0.1:8080, reload off. + + The default was 0.0.0.0 until #935 — this test asserted it, which meant + the suite was pinning the vulnerability in place: the server exposes + SQLite state, workspace file access and agent execution, and binding + every interface by default put all of it on the LAN. + """ with patch("uvicorn.run") as mock_run: result = runner.invoke(app, ["serve"]) @@ -36,7 +42,7 @@ def test_default_host_port_and_app(self): args, kwargs = mock_run.call_args # The app import string is passed positionally to uvicorn.run. assert args[0] == "codeframe.ui.server:app" - assert kwargs["host"] == "0.0.0.0" + assert kwargs["host"] == "127.0.0.1" assert kwargs["port"] == 8080 assert kwargs["reload"] is False diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 57b35871..1f36aa17 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -22,7 +22,9 @@ def test_default_values(self, monkeypatch): config = GlobalConfig(_env_file=None) assert config.database_path == ".codeframe/state.db" - assert config.api_host == "0.0.0.0" + # 127.0.0.1 since #935 — binding every interface by default put + # workspace file access and agent execution on the LAN. + assert config.api_host == "127.0.0.1" assert config.api_port == 8080 assert config.log_level == "INFO" assert config.debug is False