Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ cortex install "tools for video compression"
| **Dry-Run Default** | Preview all commands before execution |
| **Sandboxed Execution** | Commands run in Firejail isolation |
| **Full Rollback** | Undo any installation with `cortex rollback` |
| **Docker Permission Fixer** | Fix root-owned bind mount issues automatically |
| **Audit Trail** | Complete history in `~/.cortex/history.db` |
| **Hardware-Aware** | Detects GPU, CPU, memory for optimized packages |
| **Multi-LLM Support** | Works with Claude, GPT-4, or local Ollama models |
Expand Down Expand Up @@ -147,6 +148,7 @@ cortex rollback <installation-id>
| `cortex install <query>` | Install packages matching natural language query |
| `cortex install <query> --dry-run` | Preview installation plan (default) |
| `cortex install <query> --execute` | Execute the installation |
| `cortex docker permissions` | Fix file ownership for Docker bind mounts |
| `cortex sandbox <cmd>` | Test packages in Docker sandbox |
| `cortex history` | View all past installations |
| `cortex rollback <id>` | Undo a specific installation |
Expand Down Expand Up @@ -336,6 +338,7 @@ pip install -e .
- [x] Hardware detection (GPU/CPU/Memory)
- [x] Firejail sandboxing
- [x] Dry-run preview mode
- [x] Docker bind-mount permission fixer

### In Progress
- [ ] Conflict resolution UI
Expand Down
114 changes: 112 additions & 2 deletions cortex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,84 @@ def __init__(self, verbose: bool = False):
self.spinner_idx = 0
self.verbose = verbose

# Define a method to handle Docker-specific permission repairs
def docker_permissions(self, args: argparse.Namespace) -> int:
"""Handle the diagnosis and repair of Docker file permissions.

This method coordinates the environment-aware scanning of the project
directory and applies ownership reclamation logic. It ensures that
administrative actions (sudo) are never performed without user
acknowledgment unless the non-interactive flag is present.

Args:
args: The parsed command-line arguments containing the execution
context and safety flags.

Returns:
int: 0 if successful or the operation was gracefully cancelled,
1 if a system or logic error occurred.
"""
from cortex.permission_manager import PermissionManager

try:
manager = PermissionManager(os.getcwd())
cx_print("🔍 Scanning for Docker-related permission issues...", "info")

# Validate Docker Compose configurations for missing user mappings
# to help prevent future permission drift.
manager.check_compose_config()

# Retrieve execution context from argparse.
execute_flag = getattr(args, "execute", False)
yes_flag = getattr(args, "yes", False)

# SAFETY GUARD: If executing repairs, prompt for confirmation unless
# the --yes flag was provided. This follows the project safety
# standard: 'No silent sudo execution'.
if execute_flag and not yes_flag:
mismatches = manager.diagnose()
if mismatches:
cx_print(
f"⚠️ Found {len(mismatches)} paths requiring ownership reclamation.",
"warning",
)
try:
# Interactive confirmation prompt for administrative repair.
response = console.input(
"[bold cyan]Reclaim ownership using sudo? (y/n): [/bold cyan]"
)
if response.lower() not in ("y", "yes"):
cx_print("Operation cancelled", "info")
return 0
except (EOFError, KeyboardInterrupt):
# Graceful handling of terminal exit or manual interruption.
console.print()
cx_print("Operation cancelled", "info")
return 0

# Delegate repair logic to PermissionManager. If execute is False,
# a dry-run report is generated. If True, repairs are batched to
# avoid system ARG_MAX shell limits.
if manager.fix_permissions(execute=execute_flag):
if execute_flag:
cx_print("✨ Permissions fixed successfully!", "success")
return 0

return 1

except (PermissionError, FileNotFoundError, OSError) as e:
# Handle system-level access issues or missing project files.
cx_print(f"❌ Permission check failed: {e}", "error")
return 1
except NotImplementedError as e:
# Report environment incompatibility (e.g., native Windows).
cx_print(f"❌ {e}", "error")
return 1
except Exception as e:
# Safety net for unexpected runtime exceptions to prevent CLI crashes.
cx_print(f"❌ Unexpected error: {e}", "error")
return 1

def _debug(self, message: str):
"""Print debug info only in verbose mode"""
if self.verbose:
Expand Down Expand Up @@ -1538,7 +1616,12 @@ def progress_callback(current: int, total: int, step: InstallationStep) -> None:


def show_rich_help():
"""Display beautifully formatted help using Rich"""
"""Display a beautifully formatted help table using the Rich library.

This function outputs the primary command menu, providing descriptions
for all core Cortex utilities including installation, environment
management, and container tools.
"""
from rich.table import Table

show_banner(show_version=True)
Expand All @@ -1548,11 +1631,12 @@ def show_rich_help():
console.print("[dim]Just tell Cortex what you want to install.[/dim]")
console.print()

# Commands table
# Initialize a table to display commands with specific column styling
table = Table(show_header=True, header_style="bold cyan", box=None)
table.add_column("Command", style="green")
table.add_column("Description")

# Command Rows
table.add_row("ask <question>", "Ask about your system")
table.add_row("demo", "See Cortex in action")
table.add_row("wizard", "Configure API key")
Expand All @@ -1565,6 +1649,7 @@ def show_rich_help():
table.add_row("env", "Manage environment variables")
table.add_row("cache stats", "Show LLM cache statistics")
table.add_row("stack <name>", "Install the stack")
table.add_row("docker permissions", "Fix Docker bind-mount permissions")
table.add_row("sandbox <cmd>", "Test packages in Docker sandbox")
table.add_row("doctor", "System health check")

Expand Down Expand Up @@ -1631,6 +1716,22 @@ def main():

subparsers = parser.add_subparsers(dest="command", help="Available commands")

# Define the docker command and its associated sub-actions
docker_parser = subparsers.add_parser("docker", help="Docker and container utilities")
docker_subs = docker_parser.add_subparsers(dest="docker_action", help="Docker actions")

# Add the permissions action to allow fixing file ownership issues
perm_parser = docker_subs.add_parser(
"permissions", help="Fix file permissions from bind mounts"
)

# Provide an option to skip the manual confirmation prompt
perm_parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")

perm_parser.add_argument(
"--execute", "-e", action="store_true", help="Apply ownership changes (default: dry-run)"
)

# Demo command
demo_parser = subparsers.add_parser("demo", help="See Cortex in action")

Expand Down Expand Up @@ -1874,13 +1975,22 @@ def main():

args = parser.parse_args()

# The Guard: Check for empty commands before starting the CLI
if not args.command:
show_rich_help()
return 0

# Initialize the CLI handler
cli = CortexCLI(verbose=args.verbose)

try:
# Route the command to the appropriate method inside the cli object
if args.command == "docker":
if args.docker_action == "permissions":
return cli.docker_permissions(args)
parser.print_help()
return 1

if args.command == "demo":
return cli.demo()
elif args.command == "wizard":
Expand Down
173 changes: 173 additions & 0 deletions cortex/permission_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""
Docker Permission Management Module.

This module provides tools to diagnose and repair file ownership issues
that occur when Docker containers create files in host-mounted directories.
"""

import os
import platform
import subprocess

from cortex.branding import console

# Standard project directories to ignore during scans.
# Grouped into a constant to allow easy extension without modifying logic.
EXCLUDED_DIRS = {
"venv",
".venv",
".git",
"__pycache__",
"node_modules",
".pytest_cache",
"dist",
"build",
".tox",
".mypy_cache",
}


class PermissionManager:
"""Manages and fixes Docker-related file permission issues for bind mounts.

Attributes:
base_path (str): The root directory of the project to scan.
host_uid (int): The UID of the current host user.
host_gid (int): The GID of the current host user.
"""

def __init__(self, base_path: str):
"""Initialize the manager with the project base path and host identity.

Args:
base_path: The root directory of the project to scan.

Raises:
NotImplementedError: If the operating system is native Windows (outside WSL),
as Unix UID/GID logic is inapplicable.
"""
self.base_path = base_path

# Validate environment compatibility.
# Native Windows handles file ownership via Docker Desktop settings,
# making Unix-style permission repairs unnecessary and potentially misleading.
if platform.system() == "Windows":
if "microsoft" not in platform.uname().release.lower():
raise NotImplementedError(
"PermissionManager requires a Linux environment or WSL. "
"Native Windows handles file ownership differently via Docker Desktop settings."
)

# Cache system identities to avoid repeated syscalls during fix operations.
self.host_uid = os.getuid()
self.host_gid = os.getgid()

def diagnose(self) -> list[str]:
"""Scans for files where ownership does not match the active host user.

Returns:
List[str]: A list of full file paths requiring ownership reclamation.
"""
mismatched_files = []
for root, dirs, files in os.walk(self.base_path):
# Prune excluded directories in-place to optimize the recursive walk.
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]

for name in files:
full_path = os.path.join(root, name)
try:
# Capture files owned by root or mismatched container UIDs.
if os.stat(full_path).st_uid != self.host_uid:
mismatched_files.append(full_path)
except (PermissionError, FileNotFoundError):
# Skip files that are inaccessible or moved during the scan.
continue
return mismatched_files

def generate_compose_settings(self) -> str:
"""Generates a YAML snippet for correct user mapping in Docker Compose.

Returns:
str: A formatted YAML snippet utilizing the host user's UID/GID.
"""
return (
f' user: "{self.host_uid}:{self.host_gid}"\n'
" # Note: Indentation is 4 spaces. Adjust as needed for your file.\n"
' # user: "${UID}:${GID}"'
)

def check_compose_config(self) -> None:
"""Analyzes docker-compose.yml for missing 'user' directives using YAML parsing.

This method avoids false positives (like occurrences in comments) by
performing a structural analysis of the service definitions.
"""
compose_path = os.path.join(self.base_path, "docker-compose.yml")
if not os.path.exists(compose_path):
return

try:
import yaml

with open(compose_path, encoding="utf-8") as f:
data = yaml.safe_load(f)

services = data.get("services", {})
# If at least one service has a user mapping, we consider the file valid.
has_user_mapping = any("user" in svc for svc in services.values())

if not has_user_mapping:
console.print(
"\n[bold yellow]💡 Recommended Docker-Compose settings:[/bold yellow]"
)
console.print(self.generate_compose_settings())
except (ImportError, Exception):
# Silently fallback if PyYAML is missing or the file is malformed.
pass

def fix_permissions(self, execute: bool = False) -> bool:
"""Repairs file ownership via sudo or provides a dry-run summary.

Args:
execute: If True, applies ownership changes. Defaults to False (dry-run).

Returns:
bool: True if operations succeeded or no mismatches were detected.
"""
mismatches = self.diagnose()

if not mismatches:
console.print("[bold green]✅ No permission mismatches detected.[/bold green]")
return True

if not execute:
console.print(
f"\n[bold cyan]📋 [Dry-run][/bold cyan] Found {len(mismatches)} files mismatched."
)
for path in mismatches[:5]:
console.print(f" • {path}")
if len(mismatches) > 5:
console.print(f" ... and {len(mismatches) - 5} more.")
console.print("\n[bold yellow]👉 Run with --execute to apply repairs.[/bold yellow]")
return True

console.print(
f"[bold core_blue]🔧 Applying repairs to {len(mismatches)} paths...[/bold core_blue]"
)

# Process in batches to avoid shell ARG_MAX limits on large projects.
BATCH_SIZE = 100
try:
for i in range(0, len(mismatches), BATCH_SIZE):
batch = mismatches[i : i + BATCH_SIZE]
subprocess.run(
["sudo", "chown", f"{self.host_uid}:{self.host_gid}"] + batch,
check=True,
capture_output=True,
timeout=60,
)
console.print("[bold green]✅ Ownership reclaimed successfully![/bold green]")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, PermissionError) as e:
console.print(f"[bold red]❌ Failed to fix permissions: {str(e)}[/bold red]")
return False
Loading
Loading