@@ -24,9 +24,17 @@ public sealed class ExecApprovalsStore
2424 } ;
2525
2626 private readonly string _filePath ;
27+ private readonly string ? _legacyFilePath ;
2728 private readonly IOpenClawLogger _logger ;
2829 private readonly SemaphoreSlim _lock = new ( 1 , 1 ) ;
2930
31+ private enum LegacyMigrationStatus
32+ {
33+ NotNeeded ,
34+ Migrated ,
35+ Blocked ,
36+ }
37+
3038 private enum LoadFileStatus
3139 {
3240 Missing ,
@@ -37,8 +45,31 @@ private enum LoadFileStatus
3745 private readonly record struct LoadFileResult ( LoadFileStatus Status , ExecApprovalsFile ? File ) ;
3846
3947 public ExecApprovalsStore ( string dataPath , IOpenClawLogger logger )
48+ : this (
49+ dataPath ,
50+ logger ,
51+ Environment . GetEnvironmentVariable ( "OPENCLAW_STATE_DIR" ) ,
52+ Environment . GetEnvironmentVariable ( "OPENCLAW_HOME" ) ,
53+ FirstUsablePathValue (
54+ Environment . GetEnvironmentVariable ( "HOME" ) ,
55+ Environment . GetEnvironmentVariable ( "USERPROFILE" ) ,
56+ Environment . GetFolderPath ( Environment . SpecialFolder . UserProfile ) ) )
4057 {
41- _filePath = Path . Combine ( dataPath , "exec-approvals.json" ) ;
58+ }
59+
60+ internal ExecApprovalsStore (
61+ string dataPath ,
62+ IOpenClawLogger logger ,
63+ string ? stateDirOverride ,
64+ string ? openClawHomeOverride = null ,
65+ string ? osHomeOverride = null )
66+ {
67+ var stateDir = string . IsNullOrWhiteSpace ( stateDirOverride )
68+ ? dataPath
69+ : ResolveStateDirPath ( stateDirOverride , openClawHomeOverride , osHomeOverride ) ;
70+ _filePath = Path . Combine ( stateDir , "exec-approvals.json" ) ;
71+ var legacyFilePath = Path . Combine ( dataPath , "exec-approvals.json" ) ;
72+ _legacyFilePath = PathsEqual ( _filePath , legacyFilePath ) ? null : legacyFilePath ;
4273 _logger = logger ;
4374 }
4475
@@ -47,6 +78,9 @@ public ExecApprovalsStore(string dataPath, IOpenClawLogger logger)
4778 // No side effects; does not create the file. Used by the evaluator (PR5).
4879 public ExecApprovalsResolved ResolveReadOnly ( string ? agentId )
4980 {
81+ if ( _legacyFilePath is not null && File . Exists ( _legacyFilePath ) && ! File . Exists ( _filePath ) )
82+ return UnmigratedLegacyFallback ( agentId ) ;
83+
5084 var result = LoadFile ( ) ;
5185 return result . Status != LoadFileStatus . Loaded || result . File is null
5286 ? DefaultResolved ( NormalizeAgentId ( agentId ) )
@@ -69,14 +103,19 @@ public async Task<ExecApprovalsResolved> ResolveAsync(string? agentId)
69103 }
70104 }
71105
106+ public void MigrateLegacyFileIfNeeded ( ) => TryMigrateLegacyFile ( ) ;
107+
72108 // ── File I/O ──────────────────────────────────────────────────────────────
73109
74110 private LoadFileResult LoadFile ( )
111+ => LoadFile ( _filePath ) ;
112+
113+ private LoadFileResult LoadFile ( string filePath )
75114 {
76- if ( ! File . Exists ( _filePath ) ) return new LoadFileResult ( LoadFileStatus . Missing , null ) ;
115+ if ( ! File . Exists ( filePath ) ) return new LoadFileResult ( LoadFileStatus . Missing , null ) ;
77116 try
78117 {
79- var json = File . ReadAllText ( _filePath ) ;
118+ var json = File . ReadAllText ( filePath ) ;
80119 var file = JsonSerializer . Deserialize < ExecApprovalsFile > ( json , JsonOptions ) ;
81120 if ( file is null )
82121 {
@@ -105,6 +144,9 @@ private LoadFileResult LoadFile()
105144
106145 private async Task < ExecApprovalsFile > EnsureFileAsync ( )
107146 {
147+ if ( TryMigrateLegacyFile ( ) == LegacyMigrationStatus . Blocked )
148+ return UnmigratedLegacyFallbackFile ( ) ;
149+
108150 var result = LoadFile ( ) ;
109151 if ( result . Status == LoadFileStatus . Loaded && result . File is not null )
110152 {
@@ -136,6 +178,118 @@ private async Task<ExecApprovalsFile> EnsureFileAsync()
136178 return newFile ;
137179 }
138180
181+ private LegacyMigrationStatus TryMigrateLegacyFile ( )
182+ {
183+ if ( _legacyFilePath is null || ! File . Exists ( _legacyFilePath ) || File . Exists ( _filePath ) )
184+ return LegacyMigrationStatus . NotNeeded ;
185+
186+ var legacyResult = LoadFile ( _legacyFilePath ) ;
187+ if ( legacyResult . Status != LoadFileStatus . Loaded || legacyResult . File is null )
188+ {
189+ _logger . Warn ( $ "[EXEC-APPROVALS] Legacy approvals at { _legacyFilePath } could not be migrated; applying default-deny without creating { _filePath } ") ;
190+ return LegacyMigrationStatus . Blocked ;
191+ }
192+
193+ var targetDir = Path . GetDirectoryName ( _filePath ) ! ;
194+ var archivePath = NextArchivePath ( _legacyFilePath ) ;
195+ var tempPath = Path . Combine ( targetDir , $ ".exec-approvals-migration-{ Guid . NewGuid ( ) : N} .tmp") ;
196+ try
197+ {
198+ Directory . CreateDirectory ( targetDir ) ;
199+ var data = File . ReadAllBytes ( _legacyFilePath ) ;
200+ using ( var stream = new FileStream ( tempPath , FileMode . CreateNew , FileAccess . Write , FileShare . None ) )
201+ {
202+ stream . Write ( data ) ;
203+ stream . Flush ( flushToDisk : true ) ;
204+ }
205+ File . Move ( tempPath , _filePath ) ;
206+ try
207+ {
208+ File . Move ( _legacyFilePath , archivePath ) ;
209+ }
210+ catch ( Exception ex )
211+ {
212+ _logger . Warn ( $ "[EXEC-APPROVALS] Migrated approvals to { _filePath } , but could not archive { _legacyFilePath } ({ ex . Message } )") ;
213+ return LegacyMigrationStatus . Migrated ;
214+ }
215+ _logger . Info ( $ "[EXEC-APPROVALS] Migrated { _legacyFilePath } to { _filePath } ; archived source as { archivePath } ") ;
216+ return LegacyMigrationStatus . Migrated ;
217+ }
218+ catch ( IOException ) when ( File . Exists ( _filePath ) )
219+ {
220+ try { if ( File . Exists ( tempPath ) ) File . Delete ( tempPath ) ; } catch { }
221+ return LegacyMigrationStatus . NotNeeded ;
222+ }
223+ catch ( Exception ex )
224+ {
225+ try { if ( File . Exists ( tempPath ) ) File . Delete ( tempPath ) ; } catch { }
226+ _logger . Warn ( $ "[EXEC-APPROVALS] Failed to migrate { _legacyFilePath } to { _filePath } ({ ex . Message } ); applying default-deny without creating a replacement file") ;
227+ return LegacyMigrationStatus . Blocked ;
228+ }
229+ }
230+
231+ private static string NextArchivePath ( string legacyFilePath )
232+ {
233+ var archivePath = $ "{ legacyFilePath } .migrated";
234+ return File . Exists ( archivePath ) ? $ "{ archivePath } -{ Guid . NewGuid ( ) : N} " : archivePath ;
235+ }
236+
237+ private static bool PathsEqual ( string left , string right ) =>
238+ string . Equals ( Path . GetFullPath ( left ) , Path . GetFullPath ( right ) , StringComparison . OrdinalIgnoreCase ) ;
239+
240+ private static string ResolveStateDirPath (
241+ string stateDirOverride ,
242+ string ? openClawHomeOverride ,
243+ string ? osHomeOverride )
244+ {
245+ var osHome = NormalizePathValue ( osHomeOverride ) ?? Environment . CurrentDirectory ;
246+ var openClawHome = NormalizePathValue ( openClawHomeOverride ) ;
247+ var effectiveHome = openClawHome is null
248+ ? Path . GetFullPath ( osHome )
249+ : Path . GetFullPath ( ExpandHomePrefix ( openClawHome , osHome ) ) ;
250+ return Path . GetFullPath ( ExpandHomePrefix ( stateDirOverride . Trim ( ) , effectiveHome ) ) ;
251+ }
252+
253+ private static string ExpandHomePrefix ( string path , string home ) =>
254+ path == "~"
255+ ? home
256+ : path . StartsWith ( $ "~{ Path . DirectorySeparatorChar } ", StringComparison . Ordinal )
257+ || path . StartsWith ( $ "~{ Path . AltDirectorySeparatorChar } ", StringComparison . Ordinal )
258+ ? Path . Combine ( home , path [ 2 ..] )
259+ : path ;
260+
261+ private static string ? NormalizePathValue ( string ? value )
262+ {
263+ var trimmed = value ? . Trim ( ) ;
264+ return string . IsNullOrEmpty ( trimmed ) || trimmed is "undefined" or "null" ? null : trimmed ;
265+ }
266+
267+ private static string ? FirstUsablePathValue ( params string ? [ ] values )
268+ {
269+ foreach ( var value in values )
270+ {
271+ var normalized = NormalizePathValue ( value ) ;
272+ if ( normalized is not null ) return normalized ;
273+ }
274+ return null ;
275+ }
276+
277+ private static ExecApprovalsFile UnmigratedLegacyFallbackFile ( ) =>
278+ new ( )
279+ {
280+ Version = 1 ,
281+ Defaults = new ExecApprovalsDefaults
282+ {
283+ Security = ExecSecurity . Deny ,
284+ Ask = ExecAsk . Always ,
285+ AskFallback = ExecSecurity . Deny ,
286+ } ,
287+ Agents = [ ] ,
288+ } ;
289+
290+ private static ExecApprovalsResolved UnmigratedLegacyFallback ( string ? agentId ) =>
291+ ResolveFromFile ( UnmigratedLegacyFallbackFile ( ) , agentId ) ;
292+
139293 private async Task SaveFileAsync ( ExecApprovalsFile file )
140294 {
141295 var dir = Path . GetDirectoryName ( _filePath ) ! ;
@@ -276,7 +430,7 @@ private static ExecApprovalsResolved ResolveFromFile(ExecApprovalsFile file, str
276430 // Cascade: agentEntry → wildcard → defaults → systemDefault
277431 var security = agentEntry ? . Security ?? wildcardEntry ? . Security ?? defaults ? . Security ?? ExecSecurity . Deny ;
278432 var ask = agentEntry ? . Ask ?? wildcardEntry ? . Ask ?? defaults ? . Ask ?? ExecAsk . OnMiss ;
279- var askFallback = agentEntry ? . AskFallback ?? wildcardEntry ? . AskFallback ?? defaults ? . AskFallback ?? ExecAsk . Deny ;
433+ var askFallback = agentEntry ? . AskFallback ?? wildcardEntry ? . AskFallback ?? defaults ? . AskFallback ?? ExecSecurity . Deny ;
280434 var autoAllowSkills = agentEntry ? . AutoAllowSkills ?? wildcardEntry ? . AutoAllowSkills ?? defaults ? . AutoAllowSkills ?? false ;
281435
282436 // Allowlist: wildcard first, then agent; then normalize dropInvalid=true.
@@ -307,7 +461,7 @@ private static ExecApprovalsResolved DefaultResolved(string agentId) =>
307461 {
308462 Security = ExecSecurity . Deny ,
309463 Ask = ExecAsk . OnMiss ,
310- AskFallback = ExecAsk . Deny ,
464+ AskFallback = ExecSecurity . Deny ,
311465 AutoAllowSkills = false ,
312466 } ,
313467 Allowlist = [ ] ,
0 commit comments