feat(automation): support qualified single-user Windows operation

This commit is contained in:
cesnimda
2026-10-29 18:27:17 +01:00
parent c1663df44c
commit 1e6c80f708
30 changed files with 2213 additions and 807 deletions
+4 -2
View File
@@ -1,6 +1,7 @@
#Requires -Version 7.2
<# The owner runs NewKey/Sign on a separate Windows identity or signing machine.
Only the public certificate reaches the runner. Never export the private key. #>
<# The owner signs from a trusted operator terminal, with workers stopped.
Same-user unattended use additionally requires native signing-key denial probes.
Dedicated-runner installations may keep the key under a separate identity. #>
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('NewKey','ExportPublicKey','Sign','Verify')][string]$Action,
@@ -15,6 +16,7 @@ param(
)
$ErrorActionPreference = 'Stop'
try {
if ($Action -ne 'Verify' -and $env:JOBBJAKT_ORCHESTRATED) { throw 'Autonomous workers cannot invoke owner signing actions' }
if ($Action -eq 'NewKey') {
$certificate = New-SelfSignedCertificate -Subject 'CN=Jobjakt Roadmap Owner' -CertStoreLocation Cert:\CurrentUser\My `
-KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy NonExportable `
+75
View File
@@ -0,0 +1,75 @@
#Requires -Version 7.2
param([ValidateSet('CreateKey','RemoveKey','Probe','TestOwnerKey')][string]$Action,[string]$Tag,[string]$Thumbprint,[string]$PlanPath)
$ErrorActionPreference = 'Stop'
if ($Action -eq 'CreateKey') {
$cert = New-SelfSignedCertificate -Subject "CN=Jobjakt-SYNTHETIC-$Tag" -CertStoreLocation Cert:\CurrentUser\My `
-KeyAlgorithm RSA -KeyLength 2048 -KeyExportPolicy NonExportable -KeyUsage DigitalSignature -NotAfter (Get-Date).AddHours(2)
try {
$rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
$null = $rsa.SignData([Text.Encoding]::UTF8.GetBytes('synthetic-positive-control'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
@{thumbprint=$cert.Thumbprint;keyName=$rsa.Key.KeyName;tag=$Tag} | ConvertTo-Json -Compress
} catch {
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -DeleteKey
throw
} finally { if ($rsa) { $rsa.Dispose() } }
exit 0
}
if ($Action -eq 'TestOwnerKey') {
$cert=Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint"
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
try {
$null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('positive-control-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
@{keyName=$rsa.Key.KeyName;certificateSha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($cert.RawData)).ToLowerInvariant()} | ConvertTo-Json -Compress
} finally { $rsa.Dispose() }
exit 0
}
if ($Action -eq 'RemoveKey') {
$cert = Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint"
if ($cert.Subject -cne "CN=Jobjakt-SYNTHETIC-$Tag") { throw 'Refusing to remove any non-canary certificate' }
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint" -DeleteKey
exit 0
}
$plan = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json
$records = @()
foreach ($item in $plan.files) {
$readDenied=$false; $writeDenied=$false
try { $f=[IO.File]::Open($item.path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); $f.Dispose() } catch { $readDenied=$true }
try { $f=[IO.File]::Open($item.path,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite); $f.Dispose() } catch { $writeDenied=$true }
$records += @{category=$item.category;readDenied=$readDenied;writeDenied=$writeDenied}
}
function Can-SignCertificate([string]$CertificateThumbprint) {
if (-not $CertificateThumbprint) { return $false }
try {
$cert=Get-Item -LiteralPath "Cert:\CurrentUser\My\$CertificateThumbprint"
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
try { $null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('sandbox-canary-only-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1); return $true }
finally { if ($rsa) { $rsa.Dispose() } }
} catch { return $false }
}
$canSign = Can-SignCertificate $plan.key.thumbprint
try {
$key=[Security.Cryptography.CngKey]::Open($plan.key.keyName)
$rsa=[Security.Cryptography.RSACng]::new($key)
try { $null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('sandbox-canary-only-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1); $canSign=$true }
finally { $rsa.Dispose(); $key.Dispose() }
} catch {}
$ownerCanSign = Can-SignCertificate $plan.ownerThumbprint
if ($plan.ownerKeyName) {
try {
$key=[Security.Cryptography.CngKey]::Open($plan.ownerKeyName)
$rsa=[Security.Cryptography.RSACng]::new($key)
try { $null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('sandbox-canary-only-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1); $ownerCanSign=$true }
finally { $rsa.Dispose(); $key.Dispose() }
} catch {}
}
$networkDenied=$false
$client=[Net.Sockets.TcpClient]::new()
try { $connect=$client.ConnectAsync('127.0.0.1',[int]$plan.port); if (-not $connect.Wait(2000)) { $networkDenied=$true } else { $networkDenied=-not $client.Connected } }
catch { $networkDenied=$true }
finally { $client.Dispose() }
$positive=([IO.File]::ReadAllText($PlanPath).Length -gt 0)
$positivePath=Join-Path (Split-Path $PlanPath) 'positive.txt'
try { [IO.File]::WriteAllText($positivePath,'synthetic'); $positive=$positive -and [IO.File]::ReadAllText($positivePath).Equals('synthetic') }
catch { $positive=$false }
@{positiveControl=$positive;files=$records;networkDenied=$networkDenied;signingKeyDenied=(-not $canSign);
ownerKeyDenied=([bool]$plan.ownerThumbprint -and -not $ownerCanSign)} | ConvertTo-Json -Depth 8 -Compress
+26
View File
@@ -0,0 +1,26 @@
#Requires -Version 7.2
[CmdletBinding()]
param(
[string]$ControlRoot = (Join-Path ([Environment]::GetFolderPath('UserProfile')) '.jobjakt-backlog/single-user-local'),
[string]$Name = 'Jobjakt development backlog',
[switch]$Install
)
$ErrorActionPreference='Stop'
$root=[IO.Path]::GetFullPath($ControlRoot)
$config=Get-Content -LiteralPath (Join-Path $root 'config.json') -Raw | ConvertFrom-Json
$check='import sys,json; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,str(root)); import local_mode; c=json.loads((root/"config.json").read_text()); local_mode.require_unattended(root,c); local_mode.validate_identity(c,local_mode.detect_identity()[1])'
& $config.pythonExecutable -c $check $root
if ($LASTEXITCODE -ne 0) { throw 'Task registration requires current native filesystem/network/owner-key qualification; no task created' }
if (-not $Install) {
Write-Output "Preview: limited task '$Name', every 15 minutes and at logon, command start (preserves manual pause). Add -Install to register."
exit 0
}
if (-not [Environment]::UserInteractive -or $env:JOBBJAKT_ORCHESTRATED) { throw 'Register from your operator terminal' }
$action=New-ScheduledTaskAction -Execute $config.powershellExecutable -WorkingDirectory $root `
-Argument ('-NoProfile -WindowStyle Hidden -File "'+(Join-Path $root 'Run-JobjaktBacklog.ps1')+'" -Command start -ControlRoot "'+$root+'"')
$repeat=New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 15)
$logon=New-ScheduledTaskTrigger -AtLogOn -User $config.windowsUsername
$principal=New-ScheduledTaskPrincipal -UserId $config.windowsUsername -LogonType Interactive -RunLevel Limited
$settings=New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 8)
Register-ScheduledTask -TaskName $Name -Action $action -Trigger @($repeat,$logon) -Principal $principal -Settings $settings | Out-Null
Write-Output 'Registered for the existing login. A locked desktop is supported; logged-out execution needs separate Task Scheduler credential setup. No wake/sleep/security setting changed.'
+19 -3
View File
@@ -1,22 +1,38 @@
#Requires -Version 7.2
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidateSet('init','validate','dry-run','start','resume','status','pause','retry','current','logs','recover','configure')][string]$Command,
[Parameter(Mandatory)][string]$ControlRoot,
[Parameter(Mandatory)][ValidateSet('init','validate','dry-run','start','resume','status','pause','retry','current','logs','recover','configure','setup','auth','auth-status','qualify','approve','prepare','key','enable-unattended')][string]$Command,
[string]$ControlRoot,
[string]$Repository,
[string]$Stage,
[string]$Manifest,
[string]$Python = 'python'
)
$ErrorActionPreference = 'Stop'
if (-not $ControlRoot) {
$ControlRoot = if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'config.json')) { $PSScriptRoot } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.jobjakt-backlog/single-user-local' }
}
$root = [IO.Path]::GetFullPath($ControlRoot)
if ($Command -eq 'init') {
if ($Command -in @('init','setup')) {
$engine = Join-Path $PSScriptRoot '../automation/controller.py'
if (-not (Test-Path -LiteralPath $engine)) { $engine = Join-Path $root 'controller.py' }
if (-not $Repository) {
if (Test-Path -LiteralPath (Join-Path $root 'config.json')) {
$Repository = (Get-Content -LiteralPath (Join-Path $root 'config.json') -Raw | ConvertFrom-Json).repository
} else {
$Repository = & git -C $PSScriptRoot rev-parse --show-toplevel
if ($LASTEXITCODE -ne 0) { throw 'Specify the isolated worker repository with -Repository' }
}
}
} else {
$engine = Join-Path $root 'controller.py'
}
if (-not (Test-Path -LiteralPath $engine -PathType Leaf)) { throw "Controller missing: $engine" }
$arguments = @($engine, $Command, '--control-root', $root)
if ($Command -notin @('init','setup') -and (Test-Path -LiteralPath (Join-Path $root 'config.json'))) {
$config = Get-Content -LiteralPath (Join-Path $root 'config.json') -Raw | ConvertFrom-Json
if ($Python -eq 'python' -and $config.pythonExecutable) { $Python = $config.pythonExecutable }
}
if ($Repository) { $arguments += @('--repository', $Repository) }
if ($Stage) { $arguments += @('--stage', $Stage) }
if ($Manifest) { $arguments += @('--manifest', $Manifest) }
+6
View File
@@ -0,0 +1,6 @@
# Dot-source after setup. This changes only the current terminal, never your profile.
function global:Jobjakt {
$control = Join-Path ([Environment]::GetFolderPath('UserProfile')) '.jobjakt-backlog/single-user-local'
$config = Get-Content -LiteralPath (Join-Path $control 'config.json') -Raw | ConvertFrom-Json
& $config.powershellExecutable -NoProfile -File (Join-Path $control 'Run-JobjaktBacklog.ps1') @args
}