@@ -100,13 +100,19 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
100100 if ( pass1 is ExecHostPolicyDecision . AllowOutcome )
101101 {
102102 // Pre-approved path (security=Full, ask=Off or allowlist satisfied): skip prompt.
103+ // Fail closed if the approved executable cannot be pinned to a resolved path.
104+ var preApprovedExecution = BuildApprovedExecution ( identity , sanitizedEnv ) ;
105+ if ( preApprovedExecution is null )
106+ return LogAndReturn ( ExecApprovalV2Result . InternalError ( "unresolved-executable-on-allow" ) ,
107+ correlationId , promptAttempted : false , fallbackUsed : false , canonical : context . DisplayCommand ) ;
108+
103109 // Side effects are best-effort: a metadata write failure must not flip an allow to a deny.
104110 try { await RecordAllowlistUsageAsync ( context ) . ConfigureAwait ( false ) ; }
105111 catch ( Exception ex ) { _logger . Warn ( $ "[EXEC-APPROVALS] [{ correlationId } ] side-effect: record-usage failed (non-fatal): { ex . Message } ") ; }
106112 _logger . Info ( $ "[EXEC-APPROVALS] [{ correlationId } ] path=new " +
107113 $ "canonical=\" { SanitizeForLog ( context . DisplayCommand ) } \" decision=allow " +
108114 $ "reason=approved fallbackUsed=false promptAttempted=false") ;
109- return ExecApprovalV2Result . Allow ( ) ;
115+ return ExecApprovalV2Result . Allow ( preApprovedExecution ) ;
110116 }
111117 // RequiresPromptOutcome → continue to prompt/fallback block
112118
@@ -186,7 +192,14 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
186192 _promptLock . Release ( ) ;
187193 }
188194
189- // Step 8: side effects — strictly after the final allow decision.
195+ // Step 8: build payload before any store writes — a fail-closed payload result
196+ // must not leave persistent allowlist state behind.
197+ var execution = BuildApprovedExecution ( identity , sanitizedEnv ) ;
198+ if ( execution is null )
199+ return LogAndReturn ( ExecApprovalV2Result . InternalError ( "unresolved-executable-on-allow" ) ,
200+ correlationId , promptAttempted , fallbackUsed , canonical : context . DisplayCommand ) ;
201+
202+ // Step 9: side effects — only reached when the payload is valid.
190203 // Each side effect is independently best-effort so a failure in one does not skip the other.
191204 if ( persistAllowlistEntry && context . Security == ExecSecurity . Allowlist )
192205 {
@@ -196,13 +209,13 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
196209 try { await RecordAllowlistUsageAsync ( context ) . ConfigureAwait ( false ) ; }
197210 catch ( Exception ex ) { _logger . Warn ( $ "[EXEC-APPROVALS] [{ correlationId } ] side-effect: record-usage failed (non-fatal): { ex . Message } ") ; }
198211
199- // Step 9 : final allow log
212+ // Step 10 : final allow log
200213 _logger . Info ( $ "[EXEC-APPROVALS] [{ correlationId } ] path=new " +
201214 $ "canonical=\" { SanitizeForLog ( context . DisplayCommand ) } \" decision=allow " +
202215 $ "reason=approved fallbackUsed={ fallbackUsed } promptAttempted={ promptAttempted } ") ;
203216
204217 // Step 10: return Allow
205- return ExecApprovalV2Result . Allow ( ) ;
218+ return ExecApprovalV2Result . Allow ( execution ) ;
206219 }
207220 catch ( Exception ex )
208221 {
@@ -217,6 +230,50 @@ public async Task<ExecApprovalV2Result> HandleAsync(NodeInvokeRequest request, s
217230 }
218231 }
219232
233+ // Builds the approved execution payload from the RESOLVED executable path, never
234+ // the raw argv[0]. The command must execute with the same canonical identity it
235+ // was evaluated under: a relative argv[0] in the payload would let Windows
236+ // re-resolve it against PATH/cwd at execution time (a hijack), and the
237+ // direct-argv runner rejects non-absolute executables anyway. Returns null when
238+ // the executable could not be resolved to a path — the caller fails closed
239+ // rather than execute a command whose identity we cannot pin.
240+ internal static ExecApprovedExecution ? BuildApprovedExecution (
241+ CanonicalCommandIdentity identity ,
242+ IReadOnlyDictionary < string , string > ? sanitizedEnv )
243+ {
244+ var resolvedPath = identity . Resolution ? . ResolvedPath ;
245+ if ( string . IsNullOrEmpty ( resolvedPath ) )
246+ return null ;
247+
248+ // A batch script (.bat/.cmd) cannot run without cmd.exe, which re-parses the
249+ // arguments and breaks the verbatim-argv guarantee. The direct-argv runner
250+ // rejects these too; reject here as well so the fail-closed result is reached
251+ // before any approval state is written, not after.
252+ if ( resolvedPath . EndsWith ( ".bat" , StringComparison . OrdinalIgnoreCase )
253+ || resolvedPath . EndsWith ( ".cmd" , StringComparison . OrdinalIgnoreCase ) )
254+ return null ;
255+
256+ // If any env wrapper in the chain carries modifiers (VAR=val assignments or
257+ // flags), the direct-argv payload cannot faithfully carry those semantics: the
258+ // modifier would be silently dropped, and the process would run in a different
259+ // environment than the one that was approved. This walks the full unwrap chain
260+ // so a nested form such as `env env FOO=bar node` is caught, not just the outer
261+ // wrapper. Fail closed rather than execute a command that differs from what was
262+ // evaluated.
263+ if ( ExecEnvInvocationUnwrapper . AnyWrapperHasModifiers ( identity . Command ) )
264+ return null ;
265+
266+ // Transparent env wrappers (no modifiers) are safe to unwrap: the inner
267+ // command is the real executable and the args are preserved verbatim.
268+ var effective = ExecEnvInvocationUnwrapper . UnwrapForResolution ( identity . Command ) ;
269+ var argv = new string [ effective . Count ] ;
270+ argv [ 0 ] = resolvedPath ;
271+ for ( var i = 1 ; i < effective . Count ; i ++ )
272+ argv [ i ] = effective [ i ] ;
273+
274+ return new ExecApprovedExecution ( argv , identity . Cwd , identity . TimeoutMs , sanitizedEnv ) ;
275+ }
276+
220277 // Persists allowAlways patterns after an AllowAlways prompt decision (non-empty only).
221278 // Caller guarantees Security == Allowlist (guard is in HandleAsync step 8).
222279 private async Task PersistAllowlistEntriesAsync ( ExecApprovalEvaluation context )
0 commit comments