@@ -17,21 +17,31 @@ public static class ReferenceResolver
1717 /// References are resolved relative to the .prompty file's parent directory.
1818 /// </summary>
1919 /// <param name="promptyFilePath">Absolute path to the .prompty file (for resolving relative ${file:} refs).</param>
20+ /// <param name="allowedFileRoots">Additional directories that ${file:} references may read from.</param>
2021 /// <returns>A callback suitable for LoadContext.PreProcess.</returns>
21- public static Func < Dictionary < string , object ? > , Dictionary < string , object ? > > CreatePreProcess ( string promptyFilePath )
22+ public static Func < Dictionary < string , object ? > , Dictionary < string , object ? > > CreatePreProcess (
23+ string promptyFilePath ,
24+ IEnumerable < string > ? allowedFileRoots = null )
2225 {
23- var parentDir = Path . GetDirectoryName ( Path . GetFullPath ( promptyFilePath ) )
26+ var parentDir = Path . GetDirectoryName ( GetCanonicalPath ( promptyFilePath ) )
2427 ?? throw new ArgumentException ( $ "Cannot determine parent directory of '{ promptyFilePath } '") ;
28+ var allowedRoots = new [ ] { parentDir }
29+ . Concat ( allowedFileRoots ?? [ ] )
30+ . Select ( GetCanonicalPath )
31+ . ToArray ( ) ;
2532
26- return data => ResolveReferences ( data , parentDir ) ;
33+ return data => ResolveReferences ( data , parentDir , allowedRoots ) ;
2734 }
2835
2936 /// <summary>
3037 /// Walks a dictionary and resolves any string values matching ${protocol:value} patterns.
3138 /// Only processes top-level string values in the given dictionary (recursive walking is
3239 /// handled by LoadContext calling PreProcess on each nested dict).
3340 /// </summary>
34- internal static Dictionary < string , object ? > ResolveReferences ( Dictionary < string , object ? > data , string parentDir )
41+ internal static Dictionary < string , object ? > ResolveReferences (
42+ Dictionary < string , object ? > data ,
43+ string parentDir ,
44+ IReadOnlyCollection < string > allowedRoots )
3545 {
3646 foreach ( var key in data . Keys . ToList ( ) )
3747 {
@@ -55,7 +65,7 @@ public static class ReferenceResolver
5565 data [ key ] = ResolveEnvVar ( remainder , key ) ;
5666 break ;
5767 case "file" :
58- data [ key ] = ResolveFileRef ( remainder , parentDir , key ) ;
68+ data [ key ] = ResolveFileRef ( remainder , parentDir , allowedRoots , key ) ;
5969 break ;
6070 // Unknown protocol: leave unchanged per spec §4
6171 }
@@ -100,13 +110,26 @@ private static object ResolveEnvVar(string remainder, string key)
100110 /// Resolves ${file:relative/path}. Loads content based on file extension:
101111 /// .json → parsed as JSON object, .yaml/.yml → parsed as YAML object, else → raw text.
102112 /// </summary>
103- private static object ResolveFileRef ( string relativePath , string parentDir , string key )
113+ private static object ResolveFileRef (
114+ string relativePath ,
115+ string parentDir ,
116+ IReadOnlyCollection < string > allowedRoots ,
117+ string key )
104118 {
105- var fullPath = Path . GetFullPath ( Path . Combine ( parentDir , relativePath ) ) ;
119+ var fullPath = Path . IsPathRooted ( relativePath )
120+ ? Path . GetFullPath ( relativePath )
121+ : Path . GetFullPath ( Path . Combine ( parentDir , relativePath ) ) ;
106122 if ( ! File . Exists ( fullPath ) )
107123 throw new FileNotFoundException (
108124 $ "Referenced file '{ relativePath } ' not found (resolved to '{ fullPath } ', key: '{ key } ').") ;
109125
126+ fullPath = GetCanonicalPath ( fullPath ) ;
127+ if ( ! allowedRoots . Any ( root => IsWithinRoot ( fullPath , root ) ) )
128+ {
129+ throw new InvalidOperationException (
130+ $ "File reference '{ relativePath } ' resolves outside allowed roots (resolved to '{ fullPath } ', key: '{ key } ').") ;
131+ }
132+
110133 var content = File . ReadAllText ( fullPath ) ;
111134 var ext = Path . GetExtension ( fullPath ) . ToLowerInvariant ( ) ;
112135
@@ -136,4 +159,30 @@ private static object LoadYamlAsDict(string content)
136159 var result = YamlUtils . Deserializer . Deserialize < Dictionary < string , object ? > > ( content ) ;
137160 return result ?? new Dictionary < string , object ? > ( ) ;
138161 }
162+
163+ private static bool IsWithinRoot ( string path , string root )
164+ {
165+ var relative = Path . GetRelativePath ( root , path ) ;
166+ return relative == "."
167+ || ( ! relative . StartsWith ( ".." , StringComparison . Ordinal )
168+ && ! Path . IsPathRooted ( relative ) ) ;
169+ }
170+
171+ private static string GetCanonicalPath ( string path )
172+ {
173+ var fullPath = Path . GetFullPath ( path ) ;
174+ if ( File . Exists ( fullPath ) )
175+ {
176+ var file = new FileInfo ( fullPath ) ;
177+ var target = file . ResolveLinkTarget ( returnFinalTarget : true ) ;
178+ return Path . GetFullPath ( target ? . FullName ?? file . FullName ) ;
179+ }
180+ if ( Directory . Exists ( fullPath ) )
181+ {
182+ var directory = new DirectoryInfo ( fullPath ) ;
183+ var target = directory . ResolveLinkTarget ( returnFinalTarget : true ) ;
184+ return Path . GetFullPath ( target ? . FullName ?? directory . FullName ) ;
185+ }
186+ return fullPath ;
187+ }
139188}
0 commit comments