cli/internal/cmd/setup/cmd_ci.go
Snider f47e8211fb feat(mcp): add workspace root validation to prevent path traversal (#100)
* feat(mcp): add workspace root validation to prevent path traversal

- Add workspaceRoot field to Service for restricting file operations
- Add WithWorkspaceRoot() option for configuring the workspace directory
- Add validatePath() helper to check paths are within workspace
- Apply validation to all file operation handlers
- Default to current working directory for security
- Add comprehensive tests for path validation

Closes #82

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: move CLI commands from pkg/ to internal/cmd/

- Move 18 CLI command packages to internal/cmd/ (not externally importable)
- Keep 16 library packages in pkg/ (externally importable)
- Update all import paths throughout codebase
- Cleaner separation between CLI logic and reusable libraries

CLI commands moved: ai, ci, dev, docs, doctor, gitcmd, go, monitor,
php, pkgcmd, qa, sdk, security, setup, test, updater, vm, workspace

Libraries remaining: agentic, build, cache, cli, container, devops,
errors, framework, git, i18n, io, log, mcp, process, release, repos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(mcp): use pkg/io Medium for sandboxed file operations

Replace manual path validation with pkg/io.Medium for all file operations.
This delegates security (path traversal, symlink bypass) to the sandboxed
local.Medium implementation.

Changes:
- Add io.NewSandboxed() for creating sandboxed Medium instances
- Refactor MCP Service to use io.Medium instead of direct os.* calls
- Remove validatePath and resolvePathWithSymlinks functions
- Update tests to verify Medium-based behaviour

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct import path and workflow references

- Fix pkg/io/io.go import from core-gui to core
- Update CI workflows to use internal/cmd/updater path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address CodeRabbit review issues for path validation

- pkg/io/local: add symlink resolution and boundary-aware containment
  - Reject absolute paths in sandboxed Medium
  - Use filepath.EvalSymlinks to prevent symlink bypass attacks
  - Fix prefix check to prevent /tmp/root matching /tmp/root2

- pkg/mcp: fix resolvePath to validate and return errors
  - Changed resolvePath from (string) to (string, error)
  - Update deleteFile, renameFile, listDirectory, fileExists to handle errors
  - Changed New() to return (*Service, error) instead of *Service
  - Properly propagate option errors instead of silently discarding

- pkg/io: wrap errors with E() helper for consistent context
  - Copy() and MockMedium.Read() now use coreerr.E()

- tests: rename to use _Good/_Bad/_Ugly suffixes per coding guidelines
  - Fix hardcoded /tmp in TestPath to use t.TempDir()
  - Add TestResolvePath_Bad_SymlinkTraversal test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix gofmt formatting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix gofmt formatting across all files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:59:34 +00:00

299 lines
7.8 KiB
Go

package setup
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/host-uk/core/pkg/cli"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
// CIConfig holds CI setup configuration from .core/ci.yaml
type CIConfig struct {
// Homebrew tap (e.g., "host-uk/tap")
Tap string `yaml:"tap"`
// Formula name (defaults to "core")
Formula string `yaml:"formula"`
// Scoop bucket URL
ScoopBucket string `yaml:"scoop_bucket"`
// Chocolatey package name
ChocolateyPkg string `yaml:"chocolatey_pkg"`
// GitHub repository for direct downloads
Repository string `yaml:"repository"`
// Default version to install
DefaultVersion string `yaml:"default_version"`
}
// DefaultCIConfig returns the default CI configuration.
func DefaultCIConfig() *CIConfig {
return &CIConfig{
Tap: "host-uk/tap",
Formula: "core",
ScoopBucket: "https://github.com/host-uk/scoop-bucket.git",
ChocolateyPkg: "core-cli",
Repository: "host-uk/core",
DefaultVersion: "dev",
}
}
// LoadCIConfig loads CI configuration from .core/ci.yaml
func LoadCIConfig() *CIConfig {
cfg := DefaultCIConfig()
// Try to find .core/ci.yaml in current directory or parents
dir, err := os.Getwd()
if err != nil {
return cfg
}
for {
configPath := filepath.Join(dir, ".core", "ci.yaml")
data, err := os.ReadFile(configPath)
if err == nil {
if err := yaml.Unmarshal(data, cfg); err == nil {
return cfg
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return cfg
}
// CI setup command flags
var (
ciShell string
ciVersion string
)
func init() {
ciCmd := &cobra.Command{
Use: "ci",
Short: "Output CI installation commands for core CLI",
Long: `Output installation commands for the core CLI in CI environments.
Generates shell commands to install the core CLI using the appropriate
package manager for each platform:
macOS/Linux: Homebrew (brew install host-uk/tap/core)
Windows: Scoop or Chocolatey, or direct download
Configuration can be customized via .core/ci.yaml:
tap: host-uk/tap # Homebrew tap
formula: core # Homebrew formula name
scoop_bucket: https://... # Scoop bucket URL
chocolatey_pkg: core-cli # Chocolatey package name
repository: host-uk/core # GitHub repo for direct downloads
default_version: dev # Default version to install
Examples:
# Output installation commands for current platform
core setup ci
# Output for specific shell (bash, powershell, yaml)
core setup ci --shell=bash
core setup ci --shell=powershell
core setup ci --shell=yaml
# Install specific version
core setup ci --version=v1.0.0
# Use in GitHub Actions (pipe to shell)
eval "$(core setup ci --shell=bash)"`,
RunE: runSetupCI,
}
ciCmd.Flags().StringVar(&ciShell, "shell", "", "Output format: bash, powershell, yaml (auto-detected if not specified)")
ciCmd.Flags().StringVar(&ciVersion, "version", "", "Version to install (tag name or 'dev' for latest dev build)")
setupCmd.AddCommand(ciCmd)
}
func runSetupCI(cmd *cobra.Command, args []string) error {
cfg := LoadCIConfig()
// Use flag version or config default
version := ciVersion
if version == "" {
version = cfg.DefaultVersion
}
// Auto-detect shell if not specified
shell := ciShell
if shell == "" {
if runtime.GOOS == "windows" {
shell = "powershell"
} else {
shell = "bash"
}
}
switch shell {
case "bash", "sh":
return outputBashInstall(cfg, version)
case "powershell", "pwsh", "ps1":
return outputPowershellInstall(cfg, version)
case "yaml", "yml", "gha", "github":
return outputGitHubActionsYAML(cfg, version)
default:
return cli.Err("unsupported shell: %s (use bash, powershell, or yaml)", shell)
}
}
func outputBashInstall(cfg *CIConfig, version string) error {
script := fmt.Sprintf(`#!/bin/bash
set -e
VERSION="%s"
REPO="%s"
TAP="%s"
FORMULA="%s"
# Detect OS and architecture
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
# Try Homebrew first on macOS/Linux
if command -v brew &>/dev/null; then
echo "Installing via Homebrew..."
brew tap "$TAP" 2>/dev/null || true
if [ "$VERSION" = "dev" ]; then
brew install "${TAP}/${FORMULA}" --HEAD 2>/dev/null || brew upgrade "${TAP}/${FORMULA}" --fetch-HEAD 2>/dev/null || brew install "${TAP}/${FORMULA}"
else
brew install "${TAP}/${FORMULA}"
fi
%s --version
exit 0
fi
# Fall back to direct download
echo "Installing %s CLI ${VERSION} for ${OS}/${ARCH}..."
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/%s-${OS}-${ARCH}"
# Download binary
curl -fsSL "$DOWNLOAD_URL" -o /tmp/%s
chmod +x /tmp/%s
# Install to /usr/local/bin (requires sudo on most systems)
if [ -w /usr/local/bin ]; then
mv /tmp/%s /usr/local/bin/%s
else
sudo mv /tmp/%s /usr/local/bin/%s
fi
echo "Installed:"
%s --version
`, version, cfg.Repository, cfg.Tap, cfg.Formula,
cfg.Formula, cfg.Formula, cfg.Formula,
cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula, cfg.Formula)
fmt.Print(script)
return nil
}
func outputPowershellInstall(cfg *CIConfig, version string) error {
script := fmt.Sprintf(`# PowerShell installation script for %s CLI
$ErrorActionPreference = "Stop"
$Version = "%s"
$Repo = "%s"
$ScoopBucket = "%s"
$ChocoPkg = "%s"
$BinaryName = "%s"
$Arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" }
# Try Scoop first
if (Get-Command scoop -ErrorAction SilentlyContinue) {
Write-Host "Installing via Scoop..."
scoop bucket add host-uk $ScoopBucket 2>$null
scoop install "host-uk/$BinaryName"
& $BinaryName --version
exit 0
}
# Try Chocolatey
if (Get-Command choco -ErrorAction SilentlyContinue) {
Write-Host "Installing via Chocolatey..."
choco install $ChocoPkg -y
& $BinaryName --version
exit 0
}
# Fall back to direct download
Write-Host "Installing $BinaryName CLI $Version for windows/$Arch..."
$DownloadUrl = "https://github.com/$Repo/releases/download/$Version/$BinaryName-windows-$Arch.exe"
$InstallDir = "$env:LOCALAPPDATA\Programs\$BinaryName"
$BinaryPath = "$InstallDir\$BinaryName.exe"
# Create install directory
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
# Download binary
Invoke-WebRequest -Uri $DownloadUrl -OutFile $BinaryPath
# Add to PATH if not already there
$CurrentPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($CurrentPath -notlike "*$InstallDir*") {
[Environment]::SetEnvironmentVariable("Path", "$CurrentPath;$InstallDir", "User")
$env:Path = "$env:Path;$InstallDir"
}
Write-Host "Installed:"
& $BinaryPath --version
`, cfg.Formula, version, cfg.Repository, cfg.ScoopBucket, cfg.ChocolateyPkg, cfg.Formula)
fmt.Print(script)
return nil
}
func outputGitHubActionsYAML(cfg *CIConfig, version string) error {
yaml := fmt.Sprintf(`# GitHub Actions steps to install %s CLI
# Add these to your workflow file
# Option 1: Direct download (fastest, no extra dependencies)
- name: Install %s CLI
shell: bash
run: |
VERSION="%s"
REPO="%s"
BINARY="%s"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
esac
curl -fsSL "https://github.com/${REPO}/releases/download/${VERSION}/${BINARY}-${OS}-${ARCH}" -o "${BINARY}"
chmod +x "${BINARY}"
sudo mv "${BINARY}" /usr/local/bin/
%s --version
# Option 2: Homebrew (better for caching, includes dependencies)
- name: Install %s CLI (Homebrew)
run: |
brew tap %s
brew install %s/%s
%s --version
`, cfg.Formula, cfg.Formula, version, cfg.Repository, cfg.Formula, cfg.Formula,
cfg.Formula, cfg.Tap, cfg.Tap, cfg.Formula, cfg.Formula)
fmt.Print(yaml)
return nil
}