Skip to content
Open
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ Atmos is a sophisticated Go CLI tool for managing complex cloud infrastructure u
- **Vendoring system** for external components
- **Terminal UI** with rich interactive components

## Product Requirements Documents (PRDs)

**Important**: Check the `prd/` directory for design decisions and requirements for features. PRDs contain:
- Problem statements and goals
- User stories and acceptance criteria
- Design decisions with alternatives considered
- Technical specifications
- Implementation plans

When implementing features, consult relevant PRDs first to understand the full context and requirements.

## Essential Commands

### Development Workflow
Expand Down Expand Up @@ -339,6 +350,11 @@ Use fixtures in `tests/test-cases/` for integration tests. Each test case should

## Common Development Tasks

### Adding New Features or Major Changes
1. **Check for existing PRDs** in `prd/` directory for design decisions and requirements
2. **Create a PRD** for significant features following the template in `prd/`
3. Follow the implementation plan outlined in the relevant PRD

### Adding New CLI Command
1. Create `cmd/new_command.go` with Cobra command definition
2. **Create embedded markdown examples** in `cmd/markdown/atmos_command_subcommand_usage.md`
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
log "github.com/charmbracelet/log"
"github.com/elewis787/boa"
"github.com/spf13/cobra"

Expand Down
9 changes: 8 additions & 1 deletion internal/exec/copy_glob.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

log "github.com/charmbracelet/log"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/security"
u "github.com/cloudposse/atmos/pkg/utils"
cp "github.com/otiai10/copy" // Using the optimized copy library when no filtering is required.
)
Expand Down Expand Up @@ -409,6 +410,7 @@ func copyToTargetWithPatterns(
sourceDir, targetPath string,
s *schema.AtmosVendorSource,
sourceIsLocalFile bool,
atmosConfig *schema.AtmosConfiguration,
) error {
finalTarget, err := initFinalTarget(sourceDir, targetPath, sourceIsLocalFile)
if err != nil {
Expand All @@ -421,7 +423,12 @@ func copyToTargetWithPatterns(
// If no inclusion or exclusion patterns are defined, use the cp library.
if len(s.IncludedPaths) == 0 && len(s.ExcludedPaths) == 0 {
log.Debug("No inclusion or exclusion patterns defined; using cp.Copy for fast copy")
return cp.Copy(sourceDir, finalTarget)
// Get the symlink policy from config
policy := security.GetPolicyFromConfig(atmosConfig)
copyOptions := cp.Options{
OnSymlink: security.CreateSymlinkHandler(sourceDir, policy),
}
return cp.Copy(sourceDir, finalTarget, copyOptions)
}
// Process each inclusion pattern.
for _, pattern := range s.IncludedPaths {
Expand Down
8 changes: 4 additions & 4 deletions internal/exec/copy_glob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ func TestCopyToTargetWithPatterns(t *testing.T) {
IncludedPaths: []string{"**/*.test"},
ExcludedPaths: []string{"**/skip.test"},
}
if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false, nil); err != nil {
t.Fatalf("copyToTargetWithPatterns failed: %v", err)
}
if _, err := os.Stat(filepath.Join(dstDir, "sub", "keep.test")); os.IsNotExist(err) {
Expand Down Expand Up @@ -639,7 +639,7 @@ func TestCopyToTargetWithPatterns_NoPatterns(t *testing.T) {
IncludedPaths: []string{},
ExcludedPaths: []string{},
}
if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false, nil); err != nil {
t.Fatalf("copyToTargetWithPatterns failed: %v", err)
}
if _, err := os.Stat(filepath.Join(dstDir, "file.txt")); os.IsNotExist(err) {
Expand Down Expand Up @@ -671,7 +671,7 @@ func TestCopyToTargetWithPatterns_LocalFileBranch(t *testing.T) {
IncludedPaths: []string{"**/*.txt"},
ExcludedPaths: []string{},
}
if err := copyToTargetWithPatterns(srcDir, targetFile, dummy, true); err != nil {
if err := copyToTargetWithPatterns(srcDir, targetFile, dummy, true, nil); err != nil {
t.Fatalf("copyToTargetWithPatterns failed: %v", err)
}
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
Expand Down Expand Up @@ -913,7 +913,7 @@ func TestCopyToTargetWithPatterns_UseCpCopy(t *testing.T) {
IncludedPaths: []string{},
ExcludedPaths: []string{},
}
if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false, nil); err != nil {
t.Fatalf("copyToTargetWithPatterns failed: %v", err)
}
if !called {
Expand Down
5 changes: 3 additions & 2 deletions internal/exec/stack_processor_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,8 +632,9 @@ func ProcessYAMLConfigFileWithContext(
// Deep-merge the stack manifest and all the imports
stackConfigsDeepMerged, err := m.MergeWithContext(atmosConfig, stackConfigs, mergeContext)
if err != nil {
// The error already contains context information from MergeWithContext
return nil, nil, nil, nil, nil, nil, nil, err
// Wrap error with static error for consistency, but preserve context from MergeWithContext
err2 := fmt.Errorf("%w: ProcessYAMLConfigFile: Deep-merge the stack manifest and all the imports: %v", errUtils.ErrMerge, err)
return nil, nil, nil, nil, nil, nil, nil, err2
}

return stackConfigsDeepMerged,
Expand Down
70 changes: 45 additions & 25 deletions internal/exec/vendor_component_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/downloader"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/security"
u "github.com/cloudposse/atmos/pkg/utils"
)

Expand Down Expand Up @@ -123,11 +124,24 @@ func ExecuteStackVendorInternal(
return ErrStackPullNotSupported
}

func copyComponentToDestination(tempDir, componentPath string, vendorComponentSpec *schema.VendorComponentSpec, sourceIsLocalFile bool, uri string) error {
// copyComponentParams holds parameters for copyComponentToDestination function.
type copyComponentParams struct {
tempDir string
componentPath string
vendorComponentSpec *schema.VendorComponentSpec
sourceIsLocalFile bool
uri string
atmosConfig *schema.AtmosConfiguration
}

func copyComponentToDestination(params copyComponentParams) error {
// Get the symlink policy from config
policy := security.GetPolicyFromConfig(params.atmosConfig)

// Copy from the temp folder to the destination folder and skip the excluded files
copyOptions := cp.Options{
// Skip specifies which files should be skipped
Skip: createComponentSkipFunc(tempDir, vendorComponentSpec),
Skip: createComponentSkipFunc(params.tempDir, params.vendorComponentSpec),

// Preserve the atime and the mtime of the entries
// On linux we can preserve only up to 1 millisecond accuracy
Expand All @@ -136,21 +150,19 @@ func copyComponentToDestination(tempDir, componentPath string, vendorComponentSp
// Preserve the uid and the gid of all entries
PreserveOwner: false,

// OnSymlink specifies what to do on symlink
// Override the destination file if it already exists
OnSymlink: func(src string) cp.SymlinkAction {
return cp.Deep
},
// OnSymlink specifies what to do on symlink based on security policy
// Use the temp directory (source) as the boundary for symlink validation
OnSymlink: security.CreateSymlinkHandler(params.tempDir, policy),
}

componentPath2 := componentPath
if sourceIsLocalFile {
if filepath.Ext(componentPath) == "" {
componentPath2 = filepath.Join(componentPath, SanitizeFileName(uri))
componentPath2 := params.componentPath
if params.sourceIsLocalFile {
if filepath.Ext(params.componentPath) == "" {
componentPath2 = filepath.Join(params.componentPath, SanitizeFileName(params.uri))
}
}

if err := cp.Copy(tempDir, componentPath2, copyOptions); err != nil {
if err := cp.Copy(params.tempDir, componentPath2, copyOptions); err != nil {
return err
}
return nil
Expand Down Expand Up @@ -467,28 +479,36 @@ func installComponent(p *pkgComponentVendor, atmosConfig *schema.AtmosConfigurat
}

case pkgTypeLocal:
if err := handlePkgTypeLocalComponent(tempDir, p); err != nil {
if err := handlePkgTypeLocalComponent(tempDir, p, atmosConfig); err != nil {
return err
}
default:
return fmt.Errorf("%w %s for package %s", errUtils.ErrUnknownPackageType, p.pkgType.String(), p.name)
}
if err := copyComponentToDestination(tempDir, p.componentPath, p.vendorComponentSpec, p.sourceIsLocalFile, p.uri); err != nil {
if err := copyComponentToDestination(copyComponentParams{
tempDir: tempDir,
componentPath: p.componentPath,
vendorComponentSpec: p.vendorComponentSpec,
sourceIsLocalFile: p.sourceIsLocalFile,
uri: p.uri,
atmosConfig: atmosConfig,
}); err != nil {
return fmt.Errorf("failed to copy package %s error %w", p.name, err)
}

return nil
}

func handlePkgTypeLocalComponent(tempDir string, p *pkgComponentVendor) error {
func handlePkgTypeLocalComponent(tempDir string, p *pkgComponentVendor, atmosConfig *schema.AtmosConfiguration) error {
// Get the symlink policy from config
policy := security.GetPolicyFromConfig(atmosConfig)

copyOptions := cp.Options{
PreserveTimes: false,
PreserveOwner: false,
// OnSymlink specifies what to do on symlink
// Override the destination file if it already exists
OnSymlink: func(src string) cp.SymlinkAction {
return cp.Deep
},
// OnSymlink specifies what to do on symlink based on security policy
// Use the source directory (p.uri) as the boundary for symlink validation
OnSymlink: security.CreateSymlinkHandler(p.uri, policy),
}

tempDir2 := tempDir
Expand Down Expand Up @@ -534,6 +554,9 @@ func installMixin(p *pkgComponentVendor, atmosConfig *schema.AtmosConfiguration)
return fmt.Errorf("%w %s for package %s", errUtils.ErrUnknownPackageType, p.pkgType.String(), p.name)
}

// Get the symlink policy from config
policy := security.GetPolicyFromConfig(atmosConfig)

// Copy from the temp folder to the destination folder
copyOptions := cp.Options{
// Preserve the atime and the mtime of the entries
Expand All @@ -542,13 +565,10 @@ func installMixin(p *pkgComponentVendor, atmosConfig *schema.AtmosConfiguration)
// Preserve the uid and the gid of all entries
PreserveOwner: false,

// OnSymlink specifies what to do on symlink
// Override the destination file if it already exists
// OnSymlink specifies what to do on symlink based on security policy
// Prevent the error:
// symlink components/terraform/mixins/context.tf components/terraform/infra/vpc-flow-logs-bucket/context.tf: file exists
OnSymlink: func(src string) cp.SymlinkAction {
return cp.Deep
},
OnSymlink: security.CreateSymlinkHandler(tempDir, policy),
}

if err := cp.Copy(tempDir, p.componentPath, copyOptions); err != nil {
Expand Down
8 changes: 6 additions & 2 deletions internal/exec/vendor_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/cloudposse/atmos/internal/tui/templates/term"
"github.com/cloudposse/atmos/pkg/downloader"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/security"
"github.com/cloudposse/atmos/pkg/ui/theme"
u "github.com/cloudposse/atmos/pkg/utils"
)
Expand Down Expand Up @@ -346,7 +347,7 @@ func downloadAndInstall(p *pkgAtmosVendor, dryRun bool, atmosConfig *schema.Atmo
if err := p.installer(&tempDir, atmosConfig); err != nil {
return newInstallError(err, p.name)
}
if err := copyToTargetWithPatterns(tempDir, p.targetPath, &p.atmosVendorSource, p.sourceIsLocalFile); err != nil {
if err := copyToTargetWithPatterns(tempDir, p.targetPath, &p.atmosVendorSource, p.sourceIsLocalFile, atmosConfig); err != nil {
return newInstallError(fmt.Errorf("failed to copy package: %w", err), p.name)
}
return installedPkgMsg{
Expand All @@ -372,11 +373,14 @@ func (p *pkgAtmosVendor) installer(tempDir *string, atmosConfig *schema.AtmosCon
}

case pkgTypeLocal:
// Get the symlink policy from config
policy := security.GetPolicyFromConfig(atmosConfig)

// Copy from local file system
copyOptions := cp.Options{
PreserveTimes: false,
PreserveOwner: false,
OnSymlink: func(src string) cp.SymlinkAction { return cp.Deep },
OnSymlink: security.CreateSymlinkHandler(p.uri, policy),
}
Comment on lines +376 to 384
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Fix boundary root when copying a single local file.

Using p.uri as the baseDir breaks boundary checks when the source is a file (base must be a directory). This can misclassify symlinks and weaken protections.

Apply:

-		// Get the symlink policy from config
-		policy := security.GetPolicyFromConfig(atmosConfig)
+		// Get the symlink policy from config.
+		policy := security.GetPolicyFromConfig(atmosConfig)
@@
-		copyOptions := cp.Options{
+		// Determine boundary root for symlink validation.
+		baseDir := p.uri
+		if p.sourceIsLocalFile {
+			baseDir = filepath.Dir(p.uri)
+		}
+		if abs, err := filepath.Abs(baseDir); err == nil {
+			baseDir = abs
+		}
+
+		copyOptions := cp.Options{
 			PreserveTimes: false,
 			PreserveOwner: false,
-			OnSymlink:     security.CreateSymlinkHandler(p.uri, policy),
+			OnSymlink:     security.CreateSymlinkHandler(baseDir, policy),
 		}

Add a test that copies a single local symlinked file where the target escapes the parent directory and confirm it is skipped under allow_safe/reject_all and allowed under allow_all.


🏁 Script executed:

#!/bin/bash
# Inspect CreateSymlinkHandler usage
rg -n -C4 'CreateSymlinkHandler' internal/exec/vendor_model.go

# Check if p.sourceIsLocalFile exists
rg -n -C2 'sourceIsLocalFile' internal/exec/vendor_model.go

# View surrounding context for copyOptions
rg -n -C5 'copyOptions' internal/exec/vendor_model.go

Length of output: 1959


🏁 Script executed:

#!/bin/bash
set -eu

# Find the CreateSymlinkHandler declaration and signature
rg -n -C5 'func CreateSymlinkHandler' -g '*.go'
rg -n -C5 'CreateSymlinkHandler' -g '*.go'

Length of output: 10010


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate symlink_validator.go
file=$(fd --hidden --strip-cwd-prefix symlink_validator.go)

# Print the CreateSymlinkHandler implementation
sed -n '1,100p' "$file"

Length of output: 3304


Fix symlink boundary for single-file copy in vendor_model.go

  • When p.sourceIsLocalFile, set baseDir = abs(filepath.Dir(p.uri)) and pass baseDir into CreateSymlinkHandler instead of p.uri.
  • Add a test that copies a local symlink pointing outside its parent directory and asserts it’s skipped under allow_safe/reject_all and followed under allow_all.
🤖 Prompt for AI Agents
In internal/exec/vendor_model.go around lines 376 to 384, when
p.sourceIsLocalFile the symlink handler is being created with p.uri which is the
full file path; compute baseDir := filepath.Abs(filepath.Dir(p.uri)) (handle
error) and pass baseDir into security.CreateSymlinkHandler instead of p.uri so
symlink boundary checks use the file's parent directory; also add unit tests
that create a local symlink pointing outside its parent directory and assert it
is skipped under allow_safe/reject_all and followed under allow_all.

if p.sourceIsLocalFile {
*tempDir = filepath.Join(*tempDir, SanitizeFileName(p.uri))
Expand Down
19 changes: 0 additions & 19 deletions internal/exec/vendor_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (

"github.com/bmatcuk/doublestar/v4"
log "github.com/charmbracelet/log"
cp "github.com/otiai10/copy"
"github.com/pkg/errors"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -460,24 +459,6 @@ func determineSourceType(uri *string, vendorConfigFilePath string) (bool, bool,
return useOciScheme, useLocalFileSystem, sourceIsLocalFile, nil
}

func copyToTarget(tempDir, targetPath string, s *schema.AtmosVendorSource, sourceIsLocalFile bool, uri string) error {
copyOptions := cp.Options{
Skip: generateSkipFunction(tempDir, s),
PreserveTimes: false,
PreserveOwner: false,
OnSymlink: func(src string) cp.SymlinkAction { return cp.Deep },
}

// Adjust the target path if it's a local file with no extension
if sourceIsLocalFile && filepath.Ext(targetPath) == "" {
// Sanitize the URI for safe filenames, especially on Windows
sanitizedBase := SanitizeFileName(uri)
targetPath = filepath.Join(targetPath, sanitizedBase)
}

return cp.Copy(tempDir, targetPath, copyOptions)
}

// GenerateSkipFunction creates a function that determines whether to skip files during copying.
// Based on the vendor source configuration. It uses the provided patterns in ExcludedPaths.
// And IncludedPaths to filter files during the copy operation.
Expand Down
Loading
Loading