TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Forward Parameters Safely in PowerShell with $PSBoundParameters for Thin, Predictable Wrappers

When you write PowerShell wrappers around existing cmdlets, it’s easy for parameters to drift: wrappers grow new switches, defaults diverge, and callers can’t predict what actually gets forwarded. A simple pattern fixes this: clone $PSBoundParameters, prune wrapper-only keys, splat the rest to the target cmdlet, and log the final call. You’ll get clearer intent, less maintenance, and more predictable behavior.

The Core Pattern: Clone, Prune, Splat

$PSBoundParameters contains exactly what the caller supplied (including values bound from pipeline input), not your parameter defaults. That makes it ideal for forwarding. The baseline steps are:

  1. Clone $PSBoundParameters so you don’t mutate the live dictionary.
  2. Remove wrapper-only keys.
  3. Optionally whitelist allowed parameters (to prevent accidental forwarding).
  4. Splat to the inner cmdlet for predictable behavior.
  5. Log what you’re calling so code reviews see the intent.

Baseline example

The following wrapper narrows results by age while forwarding only what Get-ChildItem supports:

function Find-OldFiles {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$Path,
    [int]$Days = 30,
    [switch]$Recurse,
    [string]$Filter
  )

  $limit = (Get-Date).AddDays(-$Days)

  # 1) Clone and 2) prune wrapper-only keys
  $forward = $PSBoundParameters.Clone()
  $null = $forward.Remove('Days')

  # 3) Whitelist keys you intend to forward
  $target = @{}
  foreach ($k in @('Path','Recurse','Filter')) {
    if ($forward.ContainsKey($k)) { $target[$k] = $forward[$k] }
  }

  # 4) Splat to the inner cmdlet
  Get-ChildItem @target -File -ErrorAction Stop |
    Where-Object { $_.LastWriteTime -lt $limit }
}

# Usage
Find-OldFiles -Path 'C:\Logs' -Days 14 -Recurse -Filter '*.log' |
  Select-Object -First 3 FullName, LastWriteTime

Because you splat a curated hashtable, you avoid accidental overrides and keep the wrapper’s behavior aligned with the source cmdlet.

Make It Safer and More Maintainable

You can harden the pattern for real-world use: whitelist dynamically, handle switches carefully, map parameter names, and respect common parameters.

1) Dynamically whitelist by reflecting the target cmdlet

Instead of a hardcoded allowlist, use the target’s metadata. This prevents forwarding unsupported options (especially useful when wrapping third-party modules or external binaries):

$gciParams = (Get-Command -Name Get-ChildItem -CommandType Cmdlet).Parameters.Keys
$forward = $PSBoundParameters.Clone()
$null = $forward.Remove('Days')
$target = @{}
foreach ($k in $forward.Keys) {
  if ($gciParams -contains $k) {
    # Drop false-valued switches to avoid forcing defaults
    $v = $forward[$k]
    $isFalseSwitch = (
      ($v -is [System.Management.Automation.SwitchParameter] -and -not $v.IsPresent) -or
      ($v -is [bool] -and -not $v)
    )
    if (-not $isFalseSwitch) { $target[$k] = $v }
  }
}

Note: Common parameters (like -Verbose, -ErrorAction) are part of advanced function metadata, so they’ll be forwarded if supported; if you’re wrapping an external process, reflection will naturally filter them out.

2) Map wrapper parameter names to the target names

Sometimes you want wrapper-friendly names that differ from the inner cmdlet. Build a small translation map and apply it before splatting:

# Example mapping: wrapper -Pattern => Get-ChildItem -Filter
$map = @{ Pattern = 'Filter' }
$forward = $PSBoundParameters.Clone()
$null = $forward.Remove('Days')

# Normalize keys
foreach ($key in @($forward.Keys)) {
  if ($map.ContainsKey($key)) {
    $forward[$map[$key]] = $forward[$key]
    $null = $forward.Remove($key)
  }
}

Keep the map small and explicit. Avoid silently changing semantics.

3) Respect defaults by using presence, not value

Because $PSBoundParameters only contains parameters the caller actually supplied, presence checks tell you whether to forward. Don’t force your wrapper’s defaults into the inner cmdlet unless you truly intend to change its behavior.

# Good: forward -Filter only if caller provided it
if ($PSBoundParameters.ContainsKey('Filter')) { $target.Filter = $PSBoundParameters.Filter }

# Avoid: forcing a default value even when the caller didn't supply it
# $target.Filter = '*.log'  # changes semantics unexpectedly

4) Combine with ShouldProcess for safe operations

When your wrapper performs changes (delete, move, write), expose -WhatIf and -Confirm with [CmdletBinding(SupportsShouldProcess=$true)] and use $PSCmdlet.ShouldProcess(). Still forward only the allowed set:

function Remove-OldFiles {
  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter(Mandatory)] [string]$Path,
    [int]$Days = 30,
    [switch]$Recurse,
    [string]$Filter
  )
  $limit  = (Get-Date).AddDays(-$Days)
  $forward = $PSBoundParameters.Clone(); $null = $forward.Remove('Days')

  $allowed = (Get-Command Get-ChildItem).Parameters.Keys
  $gci = @{}
  foreach ($k in $forward.Keys) { if ($allowed -contains $k) { $gci[$k] = $forward[$k] } }

  $toDelete = Get-ChildItem @gci -File -ErrorAction Stop |
              Where-Object { $_.LastWriteTime -lt $limit }

  foreach ($file in $toDelete) {
    if ($PSCmdlet.ShouldProcess($file.FullName, 'Remove-Item')) {
      Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop
    }
  }
}

This keeps destructive actions reviewable and opt-in while still passing through the caller’s intent.

5) Keep credentials and secrets out of logs

When logging (see next section), scrub sensitive keys like -Credential, -Headers, -Token, -Body, or any custom secret. Err on the side of redaction.

Log the Final Call for Predictability and Reviews

Logging exactly what you’re about to call reduces ambiguity during code reviews and incident analysis. Use Write-Verbose for opt-in traceability, or Write-Information if you want it visible by default (tunable via -InformationAction).

A minimal, safe formatter for splats

function Format-Splat {
  param(
    [Parameter(Mandatory)][hashtable]$Hash,
    [string]$CommandName,
    [string[]]$RedactKeys = @('Credential','Headers','Authorization','Token','Body','Password')
  )
  $parts = foreach ($k in $Hash.Keys) {
    if ($RedactKeys -contains $k) { "-$k <REDACTED>"; continue }
    $v = $Hash[$k]
    if ($v -is [System.Management.Automation.SwitchParameter]) {
      if ($v.IsPresent) { "-$k" }
    } elseif ($v -is [bool]) {
      if ($v) { "-$k:":$true } # skip false
    } elseif ($v -is [string]) {
      "-$k '" + ($v -replace "'","''") + "'"
    } else {
      "-$k $v"
    }
  }
  (($CommandName + ' ' + ($parts -join ' ')).Trim())
}

# Example usage
$target = @{ Path = 'C:\Logs'; Recurse = $true; Filter = '*.log' }
Write-Verbose (Format-Splat -Hash $target -CommandName 'Get-ChildItem')

With this, your wrapper logs a friendly, reviewable command line while protecting secrets. Pair it with -Verbose in CI runs for full traceability.

Putting it together: hardened Find-OldFiles

function Find-OldFiles {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string]$Path,
    [int]$Days = 30,
    [switch]$Recurse,
    [string]$Filter
  )

  $limit   = (Get-Date).AddDays(-$Days)
  $forward = $PSBoundParameters.Clone()
  $null    = $forward.Remove('Days')

  # Dynamic allowlist
  $allowed = (Get-Command Get-ChildItem).Parameters.Keys
  $target  = @{}
  foreach ($k in $forward.Keys) {
    if ($allowed -contains $k) {
      $v = $forward[$k]
      $isFalseSwitch = (
        ($v -is [System.Management.Automation.SwitchParameter] -and -not $v.IsPresent) -or
        ($v -is [bool] -and -not $v)
      )
      if (-not $isFalseSwitch) { $target[$k] = $v }
    }
  }

  Write-Verbose (Format-Splat -Hash $target -CommandName 'Get-ChildItem')

  Get-ChildItem @target -File -ErrorAction Stop |
    Where-Object { $_.LastWriteTime -lt $limit }
}

# Example:
# Find-OldFiles -Path 'C:\Logs' -Recurse -Filter '*.log' -Days 14 -Verbose

This version automatically stays in sync with the target cmdlet’s parameters, drops false-valued switches, and logs the final call. It still keeps the wrapper thin and intentional.

Real-world use cases

  • Web/DevOps automation: Wrap Invoke-RestMethod to standardize headers and retries while forwarding any caller-supplied query parameters.
  • Build tooling: Wrap Start-Process to enforce timeouts and logging, forwarding only safe flags to the underlying process.
  • CI/CD: Wrap artifact discovery (Get-ChildItem, Get-Content) with org-wide filtering standards, forwarding path and selection options from pipelines.
  • Database tasks: Wrap Invoke-Sqlcmd with standard connection policy; redact credentials in logs; forward only query-related switches.

Common pitfalls (and fixes)

  • Accidentally forwarding wrapper defaults: Only forward when $PSBoundParameters.ContainsKey('Name') is true.
  • Logging secrets: Always redact sensitive keys and consider Write-Verbose instead of Write-Host.
  • Overriding target defaults with :$false: Drop false-valued switches before splatting.
  • Wrapping external tools: Reflection won’t help on .exe argument shapes; maintain a simple allowlist or explicit mapping.

Adopting this pattern reduces wrapper drift, makes behavior predictable, and improves reviewability. Clone, prune, splat—and log. Your future self (and your teammates) will thank you.

Strengthen your parameter patterns in PowerShell. Explore more patterns like this in advanced scripting resources and cookbooks.

← All Posts Home →