PowerShellを使用したシステム再起動時の自動アプリケーション起動設定方法という問いには、UIを必要としない承認済みプログラムをAtStartupで登録し、遅延や実行アカウントは運用要件に合わせて明示するという方法で答えます。New-ScheduledTaskTrigger -AtStartupはOS起動を契機にする。-AtLogOnは対話ユーザーのサインインが契機であり、再起動直後というタイトルの要件とは別物である。この記事ではタスク名、TaskPath、実行ファイルの絶対パス、実行アカウントを組として固定するを判断軸にし、実行前の確認、記事固有のコード、合否判定、戻し方を一続きで示します。
ユーザーがログオンした後にGUIを開く目的ならAtLogOnの記事・設計へ分け、ここではOS起動時の非対話処理だけを扱う。完了は「再起動後に対象タスクのLastRunTimeが更新され、LastTaskResultが想定値となり、アプリ自身のログにも初期化完了が残る」と定義します。対象が取れない場合は「タスクが見つからなければTaskPath違いを確認し、履歴が空ならタスク履歴の有効化状態と起動条件を切り分ける」として切り分け、推測で成功扱いにしません。
再起動後とログオン後を混同しない
UIを必要としない承認済みプログラムをAtStartupで登録し、遅延や実行アカウントは運用要件に合わせて明示する。システム起動時に動くバックグラウンドアプリではこの進め方により、操作したという事実ではなく、期待する状態へ到達したかでタイトルの問いへ答えられます。ユーザーがログオンした後にGUIを開く目的ならAtLogOnの記事・設計へ分け、ここではOS起動時の非対話処理だけを扱う。
再起動後とログオン後を混同しないの合格条件は、再起動後に対象タスクのLastRunTimeが更新され、LastTaskResultが想定値となり、アプリ自身のログにも初期化完了が残ることです。作業時刻、実行ユーザー、端末名を添え、判断に使った値が後から追える形にします。
既存タスク名の衝突を調べる
既存タスク名の衝突を調べるは変更前の基準点です。タスク名、TaskPath、実行ファイルの絶対パス、実行アカウントを組として固定するを出力に含め、取得時刻と一緒に保存します。値だけを切り取ると別対象との比較になるため、識別列を省きません。
$taskName='ITtrip-Collector-AtStartup';$taskPath='\';$exePath='C:\Ops\Collector.exe';$workDirectory='C:\Ops'
$expectedExeSha256='PUT_APPROVED_EXE_SHA256_HERE';$expectedSignerThumbprint='PUT_APPROVED_SIGNER_THUMBPRINT_HERE';$appLogPath='C:\ProgramData\ITtrip\Collector.log'
function Get-IttripPathChain([string[]]$Paths){
$seen=@{};foreach($literal in $Paths){$item=Get-Item -LiteralPath $literal -Force -ErrorAction Stop;while($item){if(-not $seen.ContainsKey($item.FullName)){$seen[$item.FullName]=$item};$parent=Split-Path -Parent $item.FullName;if(-not $parent -or $parent -eq $item.FullName){break};$item=Get-Item -LiteralPath $parent -Force -ErrorAction Stop}}
@($seen.Values|Sort-Object FullName)
}
function Get-IttripExecutableIdentity {
$exeItem=Get-Item -LiteralPath $exePath -Force -ErrorAction Stop;$workItem=Get-Item -LiteralPath $workDirectory -Force -ErrorAction Stop
if($exeItem.PSIsContainer -or -not $workItem.PSIsContainer){throw 'EXE regular fileまたはworking directoryを確認できません。'}
if($expectedExeSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or $expectedSignerThumbprint.Replace(' ','') -notmatch '^[0-9A-Fa-f]{40}$'){throw '承認済みhash/signerへ置換してください。'}
$exeHash=(Get-FileHash -LiteralPath $exeItem.FullName -Algorithm SHA256 -ErrorAction Stop).Hash.ToUpperInvariant();$sig=Get-AuthenticodeSignature -FilePath $exeItem.FullName -ErrorAction Stop;$thumb=if($sig.SignerCertificate){$sig.SignerCertificate.Thumbprint.Replace(' ','').ToUpperInvariant()}else{''}
if($exeHash -ne $expectedExeSha256.ToUpperInvariant() -or $sig.Status -ne 'Valid' -or $thumb -ne $expectedSignerThumbprint.Replace(' ','').ToUpperInvariant()){throw 'EXE hash/signatureが承認値と一致しません。'}
$approvedSids=@('S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464')
$danger=[Security.AccessControl.FileSystemRights]::WriteData -bor [Security.AccessControl.FileSystemRights]::CreateFiles -bor [Security.AccessControl.FileSystemRights]::CreateDirectories -bor [Security.AccessControl.FileSystemRights]::AppendData -bor [Security.AccessControl.FileSystemRights]::Delete -bor [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor [Security.AccessControl.FileSystemRights]::ChangePermissions -bor [Security.AccessControl.FileSystemRights]::TakeOwnership -bor [Security.AccessControl.FileSystemRights]::Modify -bor [Security.AccessControl.FileSystemRights]::FullControl
$aclBindings=@(foreach($pathItem in Get-IttripPathChain @($exeItem.FullName,$workItem.FullName)){
if($pathItem.Attributes -band [IO.FileAttributes]::ReparsePoint){throw "reparse pathはSYSTEM taskに使いません: $($pathItem.FullName)"}
$acl=Get-Acl -LiteralPath $pathItem.FullName -ErrorAction Stop;$owner=(New-Object Security.Principal.NTAccount($acl.Owner)).Translate([Security.Principal.SecurityIdentifier]).Value
if($owner -notin $approvedSids){throw "非承認ownerです: $($pathItem.FullName) SID=$owner"}
foreach($rule in $acl.Access){$sid=$rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value;if($rule.AccessControlType -eq 'Allow' -and ($rule.FileSystemRights -band $danger) -and $sid -notin $approvedSids){throw "write/replace/delete-child/ACL ownership権限が非承認主体にあります: $($pathItem.FullName) SID=$sid Rights=$($rule.FileSystemRights)"}}
[pscustomobject]@{Path=$pathItem.FullName;Attributes=[string]$pathItem.Attributes;OwnerSid=$owner;AclSddl=$acl.Sddl}
})
$bindingJson=$aclBindings|ConvertTo-Json -Depth 5 -Compress;$sha=[Security.Cryptography.SHA256]::Create();try{$aclFingerprint=([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($bindingJson)))).Replace('-','')}finally{$sha.Dispose()}
[pscustomobject]@{Path=$exeItem.FullName;WorkingDirectory=$workItem.FullName;Length=$exeItem.Length;LastWriteTimeUtc=$exeItem.LastWriteTimeUtc;Sha256=$exeHash;SignerThumbprint=$thumb;PathAclFingerprint=$aclFingerprint;PathAclBindings=$aclBindings}
}
$exeBaseline=Get-IttripExecutableIdentity
$existingTasks=@(Get-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction SilentlyContinue);if($existingTasks.Count -gt 1){throw '同名taskを一意にできません。'}
if($existingTasks.Count -eq 1){$xml=Export-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction Stop;Write-Warning '既存taskは上書きせず、Task Scheduler GUIでXMLを手動exportして別承認にします。'}
[pscustomobject]@{Task="$taskPath$taskName";ExistingCount=$existingTasks.Count;ExecutableIdentity=$exeBaseline}
New-ScheduledTaskTrigger -AtStartupはOS起動を契機にする。-AtLogOnは対話ユーザーのサインインが契機であり、再起動直後というタイトルの要件とは別物である。出力が多い場合も最初から無理に一件へ絞らず、候補数と除外理由を残してから対象を決めます。
サービス向け実行ファイルと引数を確定
サービス向け実行ファイルと引数を確定では、システム起動時に動くバックグラウンドアプリの対象を「タスク名、TaskPath、実行ファイルの絶対パス、実行アカウントを組として固定する」という単位で扱います。New-ScheduledTaskTrigger -AtStartupはOS起動を契機にする。-AtLogOnは対話ユーザーのサインインが契機であり、再起動直後というタイトルの要件とは別物である。対象が複数なら表示名の部分一致で先頭を採らず、一意になる条件を追加します。
システム起動時に動くバックグラウンドアプリを始める前に、PowerShellの版、コマンドの提供元、必要権限、管理ポリシーの有無を確認します。権限不足と対象なしは意味が異なるため、例外を0件へ置き換えません。
AtStartupトリガーをプレビューする
AtStartupトリガーをプレビューするでは、UIを必要としない承認済みプログラムをAtStartupで登録し、遅延や実行アカウントは運用要件に合わせて明示する。システム起動時に動くバックグラウンドアプリの例中にある名前、パス、ID、時刻はサンプルなので、そのまま本番へ貼らず、直前の読み取り結果から承認値を入れます。
if($existingTasks.Count -ne 0){throw '既存taskがあるため新規作成を停止します。'}
$operationId=[guid]::NewGuid().ToString('D');$actionArguments="--ittrip-operation-id $operationId";$uniqueLogMarker="ITtripOperation=$operationId"
$expectedBootTriggerBinding=[ordered]@{
CimClassName='MSFT_TaskBootTrigger';Enabled=$true;Delay='';StartBoundary='';EndBoundary=''
ExecutionTimeLimit='';Id='';RepetitionInterval='';RepetitionDuration='';RepetitionStopAtDurationEnd=$false
}
$token="OPEN-TASK-SCHEDULER TASK=$taskPath$taskName OP=$operationId EXE=$($exeBaseline.Sha256) ACL=$($exeBaseline.PathAclFingerprint)"
if((Read-Host "GUI手順を表示する場合は $token を入力") -ne $token){throw '中止しました。'}
$exeRebind=Get-IttripExecutableIdentity;$taskRebind=@(Get-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction SilentlyContinue)
if($taskRebind.Count -ne 0 -or (ConvertTo-Json $exeRebind -Depth 8 -Compress) -ne (ConvertTo-Json $exeBaseline -Depth 8 -Compress)){throw 'GUI commit直前のEXE/work/全親path ACL identityが変わりました。'}
$operationUtc=(Get-Date).ToUniversalTime();$operationBootUtc=(Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime.ToUniversalTime()
[pscustomobject]@{OperationId=$operationId;OperationUtc=$operationUtc.ToString('o');OperationBootUtc=$operationBootUtc.ToString('o');ExecutableIdentitySha256=$exeBaseline.PathAclFingerprint;ActionArguments=$actionArguments;ExpectedUniqueLogMarker=$uniqueLogMarker;Description="ITtripOperation=$operationId";ExpectedBootTriggerBinding=$expectedBootTriggerBinding;ExecuteAutomatically=$false}
Write-Host 'Task Schedulerの「タスクの作成」でSYSTEM、最上位、startup triggerを1件だけ作成します。triggerはEnabled、delayなし、開始・終了境界なし、実行時間制限なし、IDなし、繰り返しなしに固定し、承認EXE/work directoryを各1件設定します。引数とDescriptionへ表示されたOperationIdを正確に入れます。'
Write-Warning 'アプリが起動後ログへ同じunique OperationId markerを出せない場合は作成せず停止します。'
デスクトップUIをAtStartupで開いても対話セッションに表示されない。ネットワークや資格情報が未準備の時点で失敗する設計にも注意する。システム起動時に動くバックグラウンドアプリでプレビュー対応コマンドを使える場合はWhatIfを先に実行し、非対応の操作は対象一覧と引数を画面へ出して人が承認してから一度だけ実行します。
画面が出ないことを失敗としない
デスクトップUIをAtStartupで開いても対話セッションに表示されない。ネットワークや資格情報が未準備の時点で失敗する設計にも注意する。画面が出ないことを失敗としないに該当したら、警告を消して継続するのではなく、どの条件で止まったかを記録します。
タスクが見つからなければTaskPath違いを確認し、履歴が空ならタスク履歴の有効化状態と起動条件を切り分ける。システム起動時に動くバックグラウンドアプリではエラー本文、FullyQualifiedErrorId、対象ID、直前に成功した段階を残すと、別担当者が安全な地点から調査できます。
履歴とLastTaskResultで成否を判定
履歴とLastTaskResultで成否を判定では同じ対象を別経路でもう一度読みます。判定したいのは「コマンドが終了したか」ではなく、再起動後に対象タスクのLastRunTimeが更新され、LastTaskResultが想定値となり、アプリ自身のログにも初期化完了が残るかどうかです。
$operationId='PASTE_OPERATION_ID';$operationUtcText='PASTE_OPERATION_UTC';$operationBootUtcText='PASTE_OPERATION_BOOT_UTC';$expectedAclFingerprint='PASTE_EXECUTABLE_IDENTITY_SHA256'
if(@($operationId,$operationUtcText,$operationBootUtcText,$expectedAclFingerprint|Where-Object{$_ -like 'PASTE_*'}).Count){throw '保存したOperation値へ置換してください。'}
$operationUtc=[datetime]$operationUtcText;$operationBootUtc=[datetime]$operationBootUtcText;$currentBootUtc=(Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime.ToUniversalTime()
if($currentBootUtc -le $operationUtc.ToUniversalTime() -or $currentBootUtc -le $operationBootUtc.ToUniversalTime()){throw '作成後の別起動境界がありません。先頭の読取block後に再実行します。'}
$expectedArguments="--ittrip-operation-id $operationId";$expectedDescription="ITtripOperation=$operationId";$expectedMarker="ITtripOperation=$operationId"
$expectedBootTriggerBinding=[ordered]@{
CimClassName='MSFT_TaskBootTrigger';Enabled=$true;Delay='';StartBoundary='';EndBoundary=''
ExecutionTimeLimit='';Id='';RepetitionInterval='';RepetitionDuration='';RepetitionStopAtDurationEnd=$false
}
function ConvertTo-IttripTaskText($Value){if($null -eq $Value){return ''};([string]$Value).Trim()}
function Get-IttripBootTriggerBinding($Trigger){
$repetition=$Trigger.Repetition
[ordered]@{
CimClassName=[string]$Trigger.CimClass.CimClassName
Enabled=[bool]$Trigger.Enabled
Delay=ConvertTo-IttripTaskText $Trigger.Delay
StartBoundary=ConvertTo-IttripTaskText $Trigger.StartBoundary
EndBoundary=ConvertTo-IttripTaskText $Trigger.EndBoundary
ExecutionTimeLimit=ConvertTo-IttripTaskText $Trigger.ExecutionTimeLimit
Id=ConvertTo-IttripTaskText $Trigger.Id
RepetitionInterval=ConvertTo-IttripTaskText $repetition.Interval
RepetitionDuration=ConvertTo-IttripTaskText $repetition.Duration
RepetitionStopAtDurationEnd=[bool]$repetition.StopAtDurationEnd
}
}
function Test-IttripExactOwnedTask($Task){
$actions=@($Task.Actions);$triggers=@($Task.Triggers)
if($actions.Count -ne 1 -or $triggers.Count -ne 1){return $false}
$actualTrigger=(Get-IttripBootTriggerBinding $triggers[0])|ConvertTo-Json -Depth 4 -Compress
$approvedTrigger=$expectedBootTriggerBinding|ConvertTo-Json -Depth 4 -Compress
$actions.Count -eq 1 -and
[StringComparer]::OrdinalIgnoreCase.Equals([string]$actions[0].Execute,$exeBaseline.Path) -and
[StringComparer]::OrdinalIgnoreCase.Equals([string]$actions[0].WorkingDirectory,$exeBaseline.WorkingDirectory) -and
[string]$actions[0].Arguments -eq $expectedArguments -and [string]$Task.Principal.UserId -eq 'SYSTEM' -and
[string]$Task.Principal.RunLevel -eq 'Highest' -and [string]$Task.Description -eq $expectedDescription -and
$actualTrigger -ceq $approvedTrigger
}
$tasks=@(Get-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction Stop);if($tasks.Count -ne 1){throw 'post-boot taskを一意にできません。'};$task=$tasks[0];$info=Get-ScheduledTaskInfo -TaskName $taskName -TaskPath $taskPath -ErrorAction Stop;$exeAfterBoot=Get-IttripExecutableIdentity
$logMatches=@(Select-String -LiteralPath $appLogPath -SimpleMatch $expectedMarker -ErrorAction Stop);$uniquePostBootEvidence=$logMatches.Count -eq 1 -and $info.LastRunTime.ToUniversalTime() -gt $operationUtc.ToUniversalTime() -and $info.LastRunTime.ToUniversalTime() -gt $currentBootUtc
$exact=Test-IttripExactOwnedTask $task
if(-not $exact -or $exeAfterBoot.PathAclFingerprint -ne $expectedAclFingerprint -or $exeAfterBoot.Sha256 -ne $exeBaseline.Sha256 -or [int64]$info.LastTaskResult -ne 0 -or -not $uniquePostBootEvidence){throw 'complete task/exe/path ACLまたはunique OperationId post-boot evidenceが不一致です。自動削除はしません。'}
[pscustomobject]@{TaskName=$taskName;OperationId=$operationId;LastRunTime=$info.LastRunTime;LastTaskResult=$info.LastTaskResult;UniqueMarkerCount=$logMatches.Count;ExecutableAclFingerprint=$exeAfterBoot.PathAclFingerprint;Exact=$true}
$deleteToken="OPEN-TASK-DELETE $taskPath$taskName OP=$operationId"
if((Read-Host "手動削除手順を表示する場合だけ $deleteToken を入力。不要ならEnter") -eq $deleteToken){
$deleteTask=@(Get-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction Stop);$deleteExe=Get-IttripExecutableIdentity
if($deleteTask.Count -ne 1 -or -not(Test-IttripExactOwnedTask $deleteTask[0]) -or $deleteExe.PathAclFingerprint -ne $expectedAclFingerprint -or $deleteExe.Sha256 -ne $exeBaseline.Sha256){throw 'delete直前のcomplete task definition/executable/path ACL identityが一致しません。'}
Write-Host 'Task Schedulerで同じpath/name/OperationId/action/arguments/principalと唯一の完全一致BootTriggerを持つtaskだけを1回削除します。';Read-Host '手動削除後にEnterで再読します';if(@(Get-ScheduledTask -TaskName $taskName -TaskPath $taskPath -ErrorAction SilentlyContinue).Count){throw '手動削除後もtaskが残っています。'}
}
タスクが見つからなければTaskPath違いを確認し、履歴が空ならタスク履歴の有効化状態と起動条件を切り分ける。システム起動時に動くバックグラウンドアプリの期待値と実測値が一致しないときは追加変更を重ねず、対象識別、権限、ポリシー、時間差の順で原因を分けます。
起動不能に備えて登録情報を保存する
実行ファイルの署名とハッシュ、作業ディレクトリ、終了コード、再試行間隔を運用票に含める。システム起動時に動くバックグラウンドアプリを繰り返す場合は、正常、対象なし、要承認、失敗を異なる終了状態として記録し、前回値との比較だけで異常を決めません。
| 起動不能に備えて登録情報を保存するの識別軸 | タスク名、TaskPath、実行ファイルの絶対パス、実行アカウントを組として固定する |
| 採用する実測 | 再起動後に対象タスクのLastRunTimeが更新され、LastTaskResultが想定値となり、アプリ自身のログにも初期化完了が残る |
| 0件時の扱い | タスクが見つからなければTaskPath違いを確認し、履歴が空ならタスク履歴の有効化状態と起動条件を切り分ける |
| 保留にする兆候 | デスクトップUIをAtStartupで開いても対話セッションに表示されない。ネットワークや資格情報が未準備の時点で失敗する設計にも注意する |
システム起動時に動くバックグラウンドアプリの実行記録には、開始前の対象候補、採用した識別値、実行したコード、終了後の実測、除外した候補と理由を同じ作業番号で残します。特に「タスク名、TaskPath、実行ファイルの絶対パス、実行アカウントを組として固定する」を省くと、後日の再確認で別対象の値を比較するおそれがあります。画面コピーだけでなく、日時と端末名を含む構造化した出力も保存します。
PowerShellを使用したシステム再起動時の自動アプリケーション起動設定方法を定期手順へ組み込む場合も、初回は対話的に候補を確認します。正常時は「再起動後に対象タスクのLastRunTimeが更新され、LastTaskResultが想定値となり、アプリ自身のログにも初期化完了が残る」、判定不能時は「タスクが見つからなければTaskPath違いを確認し、履歴が空ならタスク履歴の有効化状態と起動条件を切り分ける」、中止時は「デスクトップUIをAtStartupで開いても対話セッションに表示されない。ネットワークや資格情報が未準備の時点で失敗する設計にも注意する」をそれぞれ別の結果として扱います。これにより、0件や例外を都合よく成功へ丸めず、次の担当者が同じ対象と条件で追試できます。
修正後コードの合格条件:OS起動時の非対話処理はPrincipalをSYSTEM、LogonTypeをServiceAccountとして明示します。同名・同一TaskPathが存在すればXMLとSHA256を保存してその場では上書きせず、新規登録時だけタスク名を入力して登録します。
再修正後は、SYSTEM・Highestの永続タスクを実行ファイルの承認SHA256・署名者・ACLへ結び、登録時とは別の再起動後プロセスで実行時刻、終了値、アプリログを確認します。どれかが不一致なら、この手順が新規作成したタスクだけを削除し、不存在まで確認します。
安全版 r4a-takeover-20260719:ScheduledTasks cmdletによる自動登録・削除・rollbackを廃止しました。EXEのhash、署名、owner/ACL、同名task有無を読み取り、Windowsのタスク スケジューラで一回だけ手動作成します。別起動後の実行結果を確認し、復元もOperationIdが一致するtaskだけをGUIで手動削除します。
独立監査追補 r4a-r6-20260719:EXE、working directory、全traversed parentについてowner、write/replacement/delete-child、ChangePermissions、TakeOwnershipをallow-listで固定します。startup成功はunique OperationId log markerへ結合し、manual delete前もcomplete task/executable/ACL identityを再拘束します。
独立監査追補 final-r2-seq003-040-049-20260719:完了時と削除直前の双方でActions.Count=1、Triggers.Count=1を要求し、唯一のBootTriggerについてEnabled、delay、境界、実行時間制限、ID、繰り返し全属性をcanonical比較します。第二triggerやどの属性差でも不一致として停止します。
公式情報・参考資料
システム起動時に動くバックグラウンドアプリで使うコマンド名、引数、対応環境は次のMicrosoft一次資料で確認しました。記事の確認日は2026年7月17日です。OSやモジュール更新後は、実行端末のGet-Helpと併せて再確認してください。

コメント