@@ -245,6 +245,10 @@ func FetchIncludeFromSource(ctx context.Context, includePath string, baseSpec *W
245245// markdown body; this function handles the YAML frontmatter 'imports:' field.
246246// Import failures are non-fatal (best-effort); the compiler will report any still-missing files.
247247func fetchAndSaveRemoteFrontmatterImports (ctx context.Context , content string , spec * WorkflowSpec , targetDir string , verbose bool , force bool , tracker * FileTracker ) error {
248+ return fetchAndSaveRemoteFrontmatterImportsWithOptions (ctx , content , spec , targetDir , verbose , force , tracker , false )
249+ }
250+
251+ func fetchAndSaveRemoteFrontmatterImportsWithOptions (ctx context.Context , content string , spec * WorkflowSpec , targetDir string , verbose bool , force bool , tracker * FileTracker , strict bool ) error {
248252 if spec .RepoSlug == "" {
249253 return nil
250254 }
@@ -280,7 +284,7 @@ func fetchAndSaveRemoteFrontmatterImports(ctx context.Context, content string, s
280284 // cycles (A imports B, B imports A) are broken without infinite recursion.
281285 seen := make (map [string ]struct {
282286 })
283- fetchFrontmatterImportsRecursive (ctx , content , workflowBaseDir , frontmatterImportsOpts {
287+ return fetchFrontmatterImportsRecursive (ctx , content , workflowBaseDir , frontmatterImportsOpts {
284288 owner : owner ,
285289 repo : repo ,
286290 ref : ref ,
@@ -289,9 +293,9 @@ func fetchAndSaveRemoteFrontmatterImports(ctx context.Context, content string, s
289293 verbose : verbose ,
290294 force : force ,
291295 tracker : tracker ,
296+ strict : strict ,
292297 seen : seen ,
293298 })
294- return nil
295299}
296300
297301// frontmatterImportsOpts holds the constant parameters for fetchFrontmatterImportsRecursive.
@@ -305,6 +309,7 @@ type frontmatterImportsOpts struct {
305309 verbose bool
306310 force bool
307311 tracker * FileTracker
312+ strict bool
308313 seen map [string ]struct {}
309314 // downloadFn is the function used to fetch file content from the source repository.
310315 // When nil, parser.DownloadFileFromGitHub is used. Tests may inject a stub to avoid
@@ -323,15 +328,15 @@ type frontmatterImportsOpts struct {
323328// - originalBaseDir: directory of the top-level workflow (used to map remote paths → local paths)
324329// - targetDir: the `.github/workflows` directory in the user's repo
325330// - seen: shared visited set (keyed by fully-resolved remote path) — prevents cycles & duplicates
326- func fetchFrontmatterImportsRecursive (ctx context.Context , content , currentBaseDir string , opts frontmatterImportsOpts ) {
331+ func fetchFrontmatterImportsRecursive (ctx context.Context , content , currentBaseDir string , opts frontmatterImportsOpts ) error {
327332 result , err := parser .ExtractFrontmatterFromContent (content )
328333 if err != nil || result .Frontmatter == nil {
329- return
334+ return nil
330335 }
331336
332337 importsField , exists := result .Frontmatter ["imports" ]
333338 if ! exists {
334- return
339+ return nil
335340 }
336341
337342 var importPaths []string
@@ -359,15 +364,18 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
359364 }
360365
361366 if len (importPaths ) == 0 {
362- return
367+ return nil
363368 }
364369
365370 remoteWorkflowLog .Printf ("Processing %d frontmatter imports recursively: owner=%s, repo=%s, ref=%s" , len (importPaths ), opts .owner , opts .repo , opts .ref )
366371
367372 // Pre-compute the absolute target directory once for path-traversal boundary checks.
368373 absTargetDir , err := filepath .Abs (opts .targetDir )
369374 if err != nil {
370- return
375+ if opts .strict {
376+ return fmt .Errorf ("failed to resolve import target directory: %w" , err )
377+ }
378+ return nil
371379 }
372380
373381 for _ , importPath := range importPaths {
@@ -431,6 +439,9 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
431439
432440 // Reject paths that try to escape the repository root (e.g. "../../etc/passwd")
433441 if remoteFilePath == ".." || strings .HasPrefix (remoteFilePath , "../" ) {
442+ if opts .strict {
443+ return fmt .Errorf ("import path %q escapes repository root" , importPath )
444+ }
434445 if opts .verbose {
435446 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Skipping import with unsafe path: %q" , importPath )))
436447 }
@@ -466,16 +477,25 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
466477 // ".." cannot appear here because remoteFilePath was already rejected above if it
467478 // started with "..", and path.Clean cannot introduce new ".." components.
468479 if localRelPath == "" || localRelPath == "." {
480+ if opts .strict {
481+ return fmt .Errorf ("invalid import path %q" , importPath )
482+ }
469483 continue
470484 }
471485 targetPath := filepath .Join (opts .targetDir , localRelPath )
472486
473487 // Belt-and-suspenders: verify the resolved path is inside targetDir
474488 absTargetPath , absErr := filepath .Abs (targetPath )
475489 if absErr != nil {
490+ if opts .strict {
491+ return fmt .Errorf ("failed to resolve import target path %q: %w" , importPath , absErr )
492+ }
476493 continue
477494 }
478495 if rel , relErr := filepath .Rel (absTargetDir , absTargetPath ); relErr != nil || strings .HasPrefix (rel , ".." ) {
496+ if opts .strict {
497+ return fmt .Errorf ("refusing to write import outside target directory: %q" , importPath )
498+ }
479499 if opts .verbose {
480500 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Refusing to write import outside target directory: %q" , importPath )))
481501 }
@@ -498,8 +518,13 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
498518 // any missing transitive dependencies.
499519 if existingContent , readErr := os .ReadFile (targetPath ); readErr == nil {
500520 importedBaseDir := path .Dir (remoteFilePath )
501- fetchFrontmatterImportsRecursive (ctx , string (existingContent ), importedBaseDir , opts )
521+ if err := fetchFrontmatterImportsRecursive (ctx , string (existingContent ), importedBaseDir , opts ); err != nil && opts .strict {
522+ return err
523+ }
502524 } else {
525+ if opts .strict {
526+ return fmt .Errorf ("failed to read existing import %s: %w" , targetPath , readErr )
527+ }
503528 remoteWorkflowLog .Printf ("Failed to read existing import %s for recursion: %v" , targetPath , readErr )
504529 }
505530 continue
@@ -513,6 +538,9 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
513538 }
514539 importContent , err := downloadFn (ctx , opts .owner , opts .repo , remoteFilePath , opts .ref )
515540 if err != nil {
541+ if opts .strict {
542+ return fmt .Errorf ("failed to fetch import %s: %w" , remoteFilePath , err )
543+ }
516544 remoteWorkflowLog .Printf ("Failed to download import %s from %s/%s@%s: %v" , remoteFilePath , opts .owner , opts .repo , opts .ref , err )
517545 if opts .verbose {
518546 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Failed to fetch import %s: %v" , remoteFilePath , err )))
@@ -522,6 +550,9 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
522550
523551 // Create the parent directory if needed
524552 if err := os .MkdirAll (filepath .Dir (targetPath ), constants .DirPermPublic ); err != nil {
553+ if opts .strict {
554+ return fmt .Errorf ("failed to create directory for import %s: %w" , remoteFilePath , err )
555+ }
525556 if opts .verbose {
526557 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Failed to create directory for import %s: %v" , remoteFilePath , err )))
527558 }
@@ -530,6 +561,9 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
530561
531562 // Write the file
532563 if err := os .WriteFile (targetPath , importContent , constants .FilePermSensitive ); err != nil {
564+ if opts .strict {
565+ return fmt .Errorf ("failed to write import %s: %w" , remoteFilePath , err )
566+ }
533567 if opts .verbose {
534568 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Failed to write import %s: %v" , remoteFilePath , err )))
535569 }
@@ -552,13 +586,20 @@ func fetchFrontmatterImportsRecursive(ctx context.Context, content, currentBaseD
552586 // Recurse into the imported file's imports. Use the imported file's directory as
553587 // currentBaseDir so that relative paths inside it resolve correctly.
554588 importedBaseDir := path .Dir (remoteFilePath )
555- fetchFrontmatterImportsRecursive (ctx , string (importContent ), importedBaseDir , opts )
589+ if err := fetchFrontmatterImportsRecursive (ctx , string (importContent ), importedBaseDir , opts ); err != nil && opts .strict {
590+ return err
591+ }
556592 }
593+ return nil
557594}
558595
559596// fetchAndSaveRemoteIncludes parses the workflow content for @include directives and fetches them from the remote source.
560597// The optional fetchFn parameter overrides the default FetchIncludeFromSource implementation; pass nil to use the default.
561598func fetchAndSaveRemoteIncludes (ctx context.Context , content string , spec * WorkflowSpec , targetDir string , verbose bool , force bool , tracker * FileTracker , fetchFn includesFetcher ) error {
599+ return fetchAndSaveRemoteIncludesWithOptions (ctx , content , spec , targetDir , verbose , force , tracker , fetchFn , false )
600+ }
601+
602+ func fetchAndSaveRemoteIncludesWithOptions (ctx context.Context , content string , spec * WorkflowSpec , targetDir string , verbose bool , force bool , tracker * FileTracker , fetchFn includesFetcher , strict bool ) error {
562603 remoteWorkflowLog .Printf ("Fetching remote includes for workflow: %s" , spec .String ())
563604 if fetchFn == nil {
564605 fetchFn = FetchIncludeFromSource
@@ -667,7 +708,10 @@ func fetchAndSaveRemoteIncludes(ctx context.Context, content string, spec *Workf
667708 }
668709
669710 // Recursively fetch includes from the fetched file
670- if err := fetchAndSaveRemoteIncludes (ctx , string (includeContent ), spec , targetDir , verbose , force , tracker , fetchFn ); err != nil {
711+ if err := fetchAndSaveRemoteIncludesWithOptions (ctx , string (includeContent ), spec , targetDir , verbose , force , tracker , fetchFn , strict ); err != nil {
712+ if strict {
713+ return fmt .Errorf ("failed to fetch nested includes from %s: %w" , filePath , err )
714+ }
671715 if verbose {
672716 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Failed to fetch nested includes from %s: %v" , filePath , err )))
673717 }
@@ -686,9 +730,20 @@ func fetchAndSaveRemoteIncludes(ctx context.Context, content string, spec *Workf
686730// verbose is true but do not stop the overall operation.
687731// - Dispatch-workflow and resource errors are fatal and are returned to the caller.
688732func fetchAllRemoteDependencies (ctx context.Context , content string , spec * WorkflowSpec , targetDir string , verbose bool , force bool , tracker * FileTracker ) error {
733+ return fetchAllRemoteDependenciesWithOptions (ctx , content , spec , targetDir , verbose , force , tracker , false )
734+ }
735+
736+ func fetchAllRemoteDependenciesStrict (ctx context.Context , content string , spec * WorkflowSpec , targetDir string , verbose bool , force bool , tracker * FileTracker ) error {
737+ return fetchAllRemoteDependenciesWithOptions (ctx , content , spec , targetDir , verbose , force , tracker , true )
738+ }
739+
740+ func fetchAllRemoteDependenciesWithOptions (ctx context.Context , content string , spec * WorkflowSpec , targetDir string , verbose bool , force bool , tracker * FileTracker , strict bool ) error {
689741 remoteWorkflowLog .Printf ("Fetching all remote dependencies: spec=%s, targetDir=%s, force=%v" , spec .String (), targetDir , force )
690742 // Fetch and save @include directive dependencies (best-effort: errors are not fatal).
691- if err := fetchAndSaveRemoteIncludes (ctx , content , spec , targetDir , verbose , force , tracker , nil ); err != nil {
743+ if err := fetchAndSaveRemoteIncludesWithOptions (ctx , content , spec , targetDir , verbose , force , tracker , nil , strict ); err != nil {
744+ if strict {
745+ return fmt .Errorf ("failed to fetch include dependencies: %w" , err )
746+ }
692747 if verbose {
693748 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Failed to fetch include dependencies: %v" , err )))
694749 }
@@ -697,14 +752,20 @@ func fetchAllRemoteDependencies(ctx context.Context, content string, spec *Workf
697752 // locally during compilation. Keeping these as relative paths (not workflowspecs)
698753 // ensures the compiler resolves them from disk rather than downloading from GitHub.
699754 // Best-effort: errors are not fatal.
700- if err := fetchAndSaveRemoteFrontmatterImports (ctx , content , spec , targetDir , verbose , force , tracker ); err != nil {
755+ if err := fetchAndSaveRemoteFrontmatterImportsWithOptions (ctx , content , spec , targetDir , verbose , force , tracker , strict ); err != nil {
756+ if strict {
757+ return fmt .Errorf ("failed to fetch frontmatter import dependencies: %w" , err )
758+ }
701759 if verbose {
702760 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (fmt .Sprintf ("Failed to fetch frontmatter import dependencies: %v" , err )))
703761 }
704762 }
705763 // Fetch and save required runtime-import dependencies so installs include the
706764 // explicit runtime-import closure without copying unrelated .github contents.
707765 if err := fetchAndSaveRemoteRuntimeImports (ctx , content , spec , targetDir , verbose , force , tracker ); err != nil {
766+ if strict {
767+ return fmt .Errorf ("failed to fetch runtime-import dependencies: %w" , err )
768+ }
708769 fmt .Fprintln (os .Stderr , console .FormatWarningMessage ("Failed to fetch runtime-import dependencies; activation may fail" ))
709770 if verbose {
710771 fmt .Fprintln (os .Stderr , console .FormatWarningMessage (err .Error ()))
0 commit comments