Skip to content
Open
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
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 @@ -295,6 +306,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
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
6 changes: 6 additions & 0 deletions pkg/config/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@
atmosConfig.Vendor.BasePath = vendorBasePath
}

vendorPolicySymlinks := os.Getenv("ATMOS_VENDOR_POLICY_SYMLINKS")

Check failure

Code scanning / golangci-lint

use of os.Getenv forbidden because "Use viper.BindEnv for new environment variables instead of os.Getenv" Error

use of os.Getenv forbidden because "Use viper.BindEnv for new environment variables instead of os.Getenv"
if len(vendorPolicySymlinks) > 0 {
log.Debug(foundEnvVarMessage, "ATMOS_VENDOR_POLICY_SYMLINKS", vendorPolicySymlinks)
atmosConfig.Vendor.Policy.Symlinks = vendorPolicySymlinks
}

Comment on lines +194 to +199
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Do not introduce new os.Getenv; bind and read via Viper.

This violates the repo rule and fails golangci-lint. Use viper.BindEnv and viper.GetString for ATMOS_VENDOR_POLICY_SYMLINKS, then set atmosConfig from that value.

Apply this diff within this hunk:

-	vendorPolicySymlinks := os.Getenv("ATMOS_VENDOR_POLICY_SYMLINKS")
-	if len(vendorPolicySymlinks) > 0 {
-		log.Debug(foundEnvVarMessage, "ATMOS_VENDOR_POLICY_SYMLINKS", vendorPolicySymlinks)
-		atmosConfig.Vendor.Policy.Symlinks = vendorPolicySymlinks
-	}
+	// Read via Viper (ENV override should be bound in load.go).
+	vendorPolicySymlinks := viper.GetString("vendor.policy.symlinks")
+	if vendorPolicySymlinks != "" {
+		log.Debug(foundEnvVarMessage, "ATMOS_VENDOR_POLICY_SYMLINKS", vendorPolicySymlinks)
+		atmosConfig.Vendor.Policy.Symlinks = vendorPolicySymlinks
+	}

Add the import in this file:

import (
    // ...
    "github.com/spf13/viper"
)

And bind the ENV once (outside this file) in pkg/config/load.go using the project’s helper:

// Somewhere in setEnv(...) alongside other bindings.
bindEnv(v, "vendor.policy.symlinks", "ATMOS_VENDOR_POLICY_SYMLINKS")
🧰 Tools
🪛 GitHub Check: golangci-lint

[failure] 194-194:
use of os.Getenv forbidden because "Use viper.BindEnv for new environment variables instead of os.Getenv"

🤖 Prompt for AI Agents
In pkg/config/utils.go around lines 194 to 199, replace the direct os.Getenv
usage with Viper: import "github.com/spf13/viper" in this file, remove os.Getenv
call and instead read the value via viper.GetString("vendor.policy.symlinks")
and set atmosConfig.Vendor.Policy.Symlinks from that result if it's non-empty;
do not call viper.BindEnv here (the review asks to bind the env key once in
pkg/config/load.go using bindEnv(v, "vendor.policy.symlinks",
"ATMOS_VENDOR_POLICY_SYMLINKS")), so ensure only the GetString read is used in
this file and remove the os package dependency if no longer needed.

stacksBasePath := os.Getenv("ATMOS_STACKS_BASE_PATH")
if len(stacksBasePath) > 0 {
log.Debug(foundEnvVarMessage, "ATMOS_STACKS_BASE_PATH", stacksBasePath)
Expand Down
41 changes: 22 additions & 19 deletions pkg/downloader/git_getter.go
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
package downloader

import (
"fmt"
"net/url"
"os"
"path/filepath"

log "github.com/charmbracelet/log"
"github.com/hashicorp/go-getter"

"github.com/cloudposse/atmos/pkg/security"
)

// CustomGitGetter is a custom getter for git (git::) that removes symlinks.
// CustomGitGetter is a custom getter for git (git::) that validates symlinks based on security policy.
type CustomGitGetter struct {
getter.GitGetter
// Policy defines how symlinks should be handled. If not set, defaults to PolicyAllowSafe.
Policy security.SymlinkPolicy
}

// Get implements the custom getter logic removing symlinks.
// Get implements the custom getter logic with symlink validation.
func (c *CustomGitGetter) Get(dst string, url *url.URL) error {
// Normal clone
if err := c.GetCustom(dst, url); err != nil {
return err
return fmt.Errorf("failed to clone %s to %s: %w", url, dst, err)
}

// Validate symlinks based on policy (default to allow_safe if not configured)
policy := c.Policy
if policy == "" {
policy = security.PolicyAllowSafe
}
// Remove symlinks
return removeSymlinks(dst)

if err := security.ValidateSymlinks(dst, policy); err != nil {
return fmt.Errorf("symlink validation failed for %s at %s: %w", url, dst, err)
}

return nil
}

// removeSymlinks walks the directory and removes any symlinks it encounters.
// Deprecated: Use security.ValidateSymlinks instead.
func removeSymlinks(root string) error {
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
log.Debug("Removing symlink", "path", path)
// Symlinks are removed for the entire repo, regardless if there are any subfolders specified
return os.Remove(path)
}
return nil
})
return security.ValidateSymlinks(root, security.PolicyRejectAll)
}
2 changes: 1 addition & 1 deletion pkg/hooks/store_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"fmt"
"strings"

"github.com/charmbracelet/log"
log "github.com/charmbracelet/log"
e "github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/spf13/cobra"
Expand Down
Loading
Loading