@rightkit/release 0.2.150 → 0.2.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +119 -8
  4. package/build-release.mjs +16 -2
  5. package/cli/right-release.mjs +18 -5
  6. package/committed-source.mjs +19 -1
  7. package/crate-graph.mjs +121 -0
  8. package/development-signing.mjs +8 -0
  9. package/development.mjs +46 -13
  10. package/devtools-policy.json +5 -0
  11. package/devtools-policy.mjs +119 -0
  12. package/direct-bootstrap.mjs +9 -3
  13. package/dmg-build.mjs +19 -5
  14. package/dmg-layout.mjs +2 -2
  15. package/dmg-spec.mjs +97 -0
  16. package/execution-policy.mjs +17 -1
  17. package/github-release.mjs +25 -7
  18. package/github-upload-gate.mjs +84 -0
  19. package/hardeningscan.mjs +3 -0
  20. package/inventory-check.mjs +4 -4
  21. package/mac-bundle-signing.mjs +1 -1
  22. package/mac-sign-core.mjs +97 -0
  23. package/mac-signing-matrix.mjs +186 -0
  24. package/macos-notarization.mjs +18 -2
  25. package/msix-appinstaller.mjs +4 -1
  26. package/msix-config.mjs +5 -1
  27. package/msix-repair-guard.mjs +24 -0
  28. package/msix-repair-guard.ps1 +356 -0
  29. package/notarize-macos.mjs +4 -3
  30. package/ownership-catalog-config.json +6 -7
  31. package/ownership-command.mjs +69 -8
  32. package/ownership-evidence.mjs +1 -1
  33. package/ownership-key-authority.mjs +1 -1
  34. package/ownership-key-command.mjs +15 -3
  35. package/ownership-key-elevation.mjs +11 -2
  36. package/ownership-mandatory-set.json +420 -0
  37. package/ownership-mandatory-set.mjs +273 -0
  38. package/ownership.mjs +2 -2
  39. package/package.json +15 -3
  40. package/packaged-app-identity.mjs +88 -0
  41. package/preflight.mjs +25 -3
  42. package/prune-r2.mjs +28 -3
  43. package/publication-gate.mjs +49 -10
  44. package/publish-cargo.mjs +21 -1
  45. package/publish-swift.mjs +78 -141
  46. package/publish-update.mjs +24 -2
  47. package/qa-build-features.mjs +216 -0
  48. package/qa-control-scan.mjs +435 -0
  49. package/registry-parity.mjs +18 -1
  50. package/release-invocation.mjs +2 -1
  51. package/release-state.mjs +9 -0
  52. package/release-switch-registry.json +33 -0
  53. package/release-switch-scan.mjs +452 -0
  54. package/release.mjs +324 -26
  55. package/renamed-away.mjs +29 -0
  56. package/rightkit-ownership.json +682 -709
  57. package/rightkit-sync.mjs +8 -4
  58. package/rightkit-version-policy.mjs +18 -0
  59. package/rightkit-versions.json +212 -127
  60. package/runtime-budget-classes.json +4 -0
  61. package/runtime-budgets.mjs +870 -0
  62. package/runtime-bundle-scan.mjs +61 -0
  63. package/sign-macos.mjs +28 -30
  64. package/sign-updater.mjs +5 -2
  65. package/swift-mac-dmg.mjs +7 -2
  66. package/swift-package-command.mjs +1 -1
  67. package/upload-release.mjs +10 -37
@@ -0,0 +1,356 @@
1
+ # RightKit MSIX repair guard (canonical source: rightkit/packages/release/msix-repair-guard.ps1). ASCII only; Windows PowerShell 5.1 compatible.
2
+ #
3
+ # Why this exists: cancelling an MSIX update while Windows registers it can leave the OLD package
4
+ # "Modified, NeedsRemediation" (event 751: "Windows failed to undo changes ... The package needs to be reinstalled").
5
+ # A package in that state cannot be launched, so nothing inside the package can repair it. This script lives OUTSIDE the
6
+ # package (the app writes it to %LOCALAPPDATA%\<AppName>\repair\ on every normal launch) and is run by a per-user scheduled
7
+ # task `<PFN>-Repair` (no admin) on event 751 in Microsoft-Windows-AppXDeploymentServer/Operational, at logon, and at a
8
+ # one-shot retry time after a failed repair.
9
+ #
10
+ # msix-repair-guard.ps1 -Install -Pfn <family> [-Feed <.appinstaller uri>] register/refresh the task and write guard.json
11
+ # msix-repair-guard.ps1 (task action) repair if needed, clean up if uninstalled
12
+ #
13
+ # Repair order: `Add-AppxPackage -AppInstallerFile <feed>` (known-good), then `Add-AppxPackage -Path <msix>` (the msix is downloaded
14
+ # first and its manifest identity checked). Every decision is logged to repair.log next to this script; every run also writes a
15
+ # machine-readable last-result.json (status, code, time, detail), because the task runs under `conhost --headless` and hides exit codes.
16
+ #
17
+ # Exit codes (also in last-result.json): 0 healthy / repaired / cleaned up / another run active, 1 repair failed (a retry is scheduled),
18
+ # 2 guard misconfigured or refused (guard.json or an install input failed validation), 3 no install source known, 4 unhandled error
19
+ # (logged; a retry is scheduled).
20
+ #
21
+ # Retry: after a failed repair (exit 1 or 4) the script gives the task a one-shot time trigger at +2, +5, +15, +60 minutes and then
22
+ # hourly, for at most 24 hours from the first failure; the trigger is removed on success, when the package is healthy, or when the
23
+ # app is uninstalled. State: retry.json. A later logon or event 751 run still starts a repair at any time.
24
+ #
25
+ # Uninstall cleanup: a run that finds the package absent (twice, 10 s apart) removes this folder and the task. It runs at the next
26
+ # logon, the next event 751, or the next retry tick; Windows has no "package removed" trigger for a per-user task.
27
+ #
28
+ # Hardening: feed and msix URIs must be https, or http to a loopback host (a local test feed). A feed or msix whose identity (Name,
29
+ # Publisher) differs from the installed package is refused and never installed. guard.json must name this folder's task.
30
+ #
31
+ # Requirement: the app's data directory must be physical (MSIX fileSystemWrite virtualization disabled), otherwise the packaged app
32
+ # writes this folder into its private LocalCache and the task cannot see it.
33
+ [CmdletBinding()]
34
+ param([switch]$Install, [string]$Pfn = '', [string]$Feed = '')
35
+ $ErrorActionPreference = 'Stop'
36
+ $ProgressPreference = 'SilentlyContinue' # Windows PowerShell 5.1 renders a progress bar per downloaded chunk: a 30 MB Invoke-WebRequest -OutFile then takes minutes
37
+ $GuardVersion = 2
38
+ $Dir = Split-Path -Parent $PSCommandPath
39
+ $LogPath = Join-Path $Dir 'repair.log'
40
+ $ConfigPath = Join-Path $Dir 'guard.json'
41
+ $RetryPath = Join-Path $Dir 'retry.json'
42
+ $AttemptPath = Join-Path $Dir 'install-attempt.json'
43
+ $ResultPath = Join-Path $Dir $(if ($Install) { 'install-result.json' } else { 'last-result.json' })
44
+ $Mode = $(if ($Install) { 'install' } else { 'repair' })
45
+ $RetryDelaysMinutes = @(2, 5, 15, 60) # then hourly
46
+ $RetryWindowHours = 24
47
+ $PfnPattern = '^[A-Za-z0-9][A-Za-z0-9.\-]*_[a-z0-9]{13}$'
48
+ $script:Ctx = ''
49
+
50
+ function Log([string]$m) {
51
+ try {
52
+ if ((Test-Path -LiteralPath $LogPath) -and ((Get-Item -LiteralPath $LogPath).Length -gt 262144)) { Move-Item -LiteralPath $LogPath -Destination ($LogPath + '.old') -Force }
53
+ Add-Content -LiteralPath $LogPath -Value ((Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffzzz') + ' [' + $Mode + '] ' + $m)
54
+ } catch { }
55
+ }
56
+ function Write-Json([string]$path, $obj) {
57
+ try {
58
+ $tmp = $path + '.tmp'
59
+ [IO.File]::WriteAllText($tmp, ($obj | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
60
+ Move-Item -LiteralPath $tmp -Destination $path -Force
61
+ } catch { Log ('could not write ' + $path + ': ' + $_.Exception.Message) }
62
+ }
63
+ function Write-Result([string]$status, [int]$code, [string]$detail) {
64
+ $r = [ordered]@{ status = $status; code = $code; mode = $Mode; timeUtc = (Get-Date).ToUniversalTime().ToString('o'); guardVersion = $GuardVersion; detail = $detail }
65
+ $st = Read-Json $RetryPath
66
+ if ($st -and $st.nextEpoch) { $r.nextRetryUtc = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.nextEpoch).ToString('o'); $r.retryAttempt = [int]$st.attempt }
67
+ Write-Json $ResultPath $r
68
+ }
69
+ function Finish([string]$status, [int]$code, [string]$detail) {
70
+ Log ($status.ToUpper() + ' (code ' + $code + '): ' + $detail)
71
+ Write-Result $status $code $detail
72
+ exit $code
73
+ }
74
+ function Read-Json([string]$path) { try { return (Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json) } catch { return $null } }
75
+ function Read-Config { return (Read-Json $ConfigPath) }
76
+
77
+ # ------------------------------------------------------------------ scheduled task (shared by install and the retry logic)
78
+ function Get-TaskXml([string]$family, $retryLocal) {
79
+ $id = [Security.Principal.WindowsIdentity]::GetCurrent()
80
+ $esc = { param($s) [Security.SecurityElement]::Escape([string]$s) }
81
+ $sub = '<QueryList><Query Id="0" Path="Microsoft-Windows-AppXDeploymentServer/Operational"><Select Path="Microsoft-Windows-AppXDeploymentServer/Operational">*[System[(EventID=751)]]</Select></Query></QueryList>'
82
+ $taskArgs = '--headless powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $PSCommandPath + '"'
83
+ $retryXml = ''
84
+ if ($retryLocal) { $retryXml = '<TimeTrigger><StartBoundary>' + ([datetime]$retryLocal).ToString('yyyy-MM-ddTHH:mm:ss') + '</StartBoundary><Enabled>true</Enabled></TimeTrigger>' }
85
+ return @"
86
+ <?xml version="1.0" encoding="UTF-16"?>
87
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
88
+ <RegistrationInfo><Description>Repair guard for $(& $esc $family): re-adds the app from its App Installer feed when an interrupted update leaves it NeedsRemediation; retries after a failure. Removes itself when the app is uninstalled.</Description></RegistrationInfo>
89
+ <Triggers>
90
+ <EventTrigger><Enabled>true</Enabled><Subscription>$(& $esc $sub)</Subscription></EventTrigger>
91
+ <LogonTrigger><Enabled>true</Enabled><UserId>$(& $esc $id.Name)</UserId></LogonTrigger>
92
+ $retryXml
93
+ </Triggers>
94
+ <Principals><Principal id="Author"><UserId>$(& $esc $id.User.Value)</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
95
+ <Settings>
96
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
97
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
98
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
99
+ <AllowHardTerminate>true</AllowHardTerminate>
100
+ <StartWhenAvailable>true</StartWhenAvailable>
101
+ <AllowStartOnDemand>true</AllowStartOnDemand>
102
+ <Enabled>true</Enabled>
103
+ <Hidden>true</Hidden>
104
+ <ExecutionTimeLimit>PT30M</ExecutionTimeLimit>
105
+ </Settings>
106
+ <Actions Context="Author"><Exec><Command>%SystemRoot%\System32\conhost.exe</Command><Arguments>$(& $esc $taskArgs)</Arguments></Exec></Actions>
107
+ </Task>
108
+ "@
109
+ }
110
+ function Register-GuardTask([string]$family, $retryLocal) {
111
+ [void](Register-ScheduledTask -TaskName ($family + '-Repair') -Xml (Get-TaskXml $family $retryLocal) -Force)
112
+ }
113
+ function Get-RetryLocal {
114
+ $st = Read-Json $RetryPath
115
+ if ($st -and $st.nextEpoch) { return ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds([double]$st.nextEpoch).ToLocalTime() }
116
+ return $null
117
+ }
118
+
119
+ # ------------------------------------------------------------------ retry state (repair mode)
120
+ function Clear-Retry([string]$family) {
121
+ if (-not (Test-Path -LiteralPath $RetryPath)) { return }
122
+ try { Remove-Item -LiteralPath $RetryPath -Force } catch { }
123
+ try { Register-GuardTask $family $null; Log 'retry cleared (task has no one-shot trigger)' } catch { Log ('could not clear the retry trigger: ' + $_.Exception.Message) }
124
+ }
125
+ function Set-Retry([string]$family, [string]$why) {
126
+ $epoch0 = [datetime]'1970-01-01T00:00:00Z'
127
+ $now = [datetime]::UtcNow
128
+ $nowEpoch = [math]::Floor(($now - $epoch0.ToUniversalTime()).TotalSeconds)
129
+ $st = Read-Json $RetryPath
130
+ $attempt = 1; $first = $nowEpoch
131
+ if ($st -and $st.firstFailureEpoch -and (($nowEpoch - [double]$st.firstFailureEpoch) -lt ($RetryWindowHours * 3600))) { $attempt = [int]$st.attempt + 1; $first = [double]$st.firstFailureEpoch }
132
+ $delay = $(if ($attempt -le $RetryDelaysMinutes.Count) { $RetryDelaysMinutes[$attempt - 1] } else { 60 })
133
+ $next = $nowEpoch + ($delay * 60)
134
+ if (($next - $first) -gt ($RetryWindowHours * 3600)) {
135
+ Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; gaveUp = $true; reason = $why })
136
+ try { Register-GuardTask $family $null } catch { }
137
+ Log ('retry window of ' + $RetryWindowHours + ' h used up after ' + ($attempt - 1) + ' retries; no further automatic retry (next logon / event 751 starts again)')
138
+ return
139
+ }
140
+ Write-Json $RetryPath ([ordered]@{ attempt = $attempt; firstFailureEpoch = $first; nextEpoch = $next; delayMinutes = $delay; reason = $why })
141
+ $local = $epoch0.ToUniversalTime().AddSeconds($next).ToLocalTime()
142
+ try { Register-GuardTask $family $local; Log ('retry ' + $attempt + ' scheduled in ' + $delay + ' min at ' + $local.ToString('s')) } catch { Log ('could not schedule the retry: ' + $_.Exception.Message) }
143
+ }
144
+
145
+ # ------------------------------------------------------------------ package state
146
+ function Get-Family([string]$family) {
147
+ $name = $family.Substring(0, $family.LastIndexOf('_'))
148
+ return @(Get-AppxPackage -Name $name -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object { [string]$_.PackageFamilyName -eq $family })
149
+ }
150
+ function Get-StateText([string]$family) {
151
+ $p = @(Get-Family $family)
152
+ if ($p.Count -eq 0) { return 'absent' }
153
+ return (($p | ForEach-Object { [string]$_.Version + ':' + [string]$_.Status }) -join ',')
154
+ }
155
+ function Test-Healthy([string]$family) {
156
+ $p = @(Get-Family $family)
157
+ return ($p.Count -gt 0 -and @($p | Where-Object { [string]$_.Status -ne 'Ok' }).Count -eq 0)
158
+ }
159
+ function Test-AllowedUri([string]$uri) {
160
+ try {
161
+ $u = [Uri]$uri
162
+ if (-not $u.IsAbsoluteUri -or $u.UserInfo) { return $false }
163
+ if ($u.Scheme -eq 'https') { return $true }
164
+ if ($u.Scheme -eq 'http' -and $u.IsLoopback) { return $true }
165
+ } catch { }
166
+ return $false
167
+ }
168
+ function Same-Text([string]$a, [string]$b) { return ([string]::Equals($a.Trim(), $b.Trim(), [StringComparison]::OrdinalIgnoreCase)) }
169
+ function Test-Identity($expected, [string]$name, [string]$publisher) {
170
+ if (-not $expected.name -or -not $expected.publisher) { return 'no expected identity recorded' }
171
+ if (-not (Same-Text $expected.name $name)) { return ('Name ' + $name + ' differs from ' + $expected.name) }
172
+ if (-not (Same-Text $expected.publisher $publisher)) { return ('Publisher ' + $publisher + ' differs from ' + $expected.publisher) }
173
+ return ''
174
+ }
175
+ function Get-FeedInfo([string]$feedUri) {
176
+ $r = Invoke-WebRequest -Uri $feedUri -UseBasicParsing -TimeoutSec 20 -MaximumRedirection 3
177
+ if (-not (Test-AllowedUri ([string]$r.BaseResponse.ResponseUri))) { throw ('feed redirected to a disallowed uri ' + $r.BaseResponse.ResponseUri) }
178
+ $text = $r.Content
179
+ if ($text -is [byte[]]) { $text = [Text.Encoding]::UTF8.GetString($text) } # a non-text content type returns bytes
180
+ $x = [xml]($text.TrimStart([char]0xFEFF))
181
+ $n = $x.GetElementsByTagName('MainPackage') | Select-Object -First 1
182
+ if (-not $n) { $n = $x.GetElementsByTagName('MainBundle') | Select-Object -First 1 }
183
+ if (-not $n) { throw 'feed has no MainPackage or MainBundle' }
184
+ return [pscustomobject]@{ uri = [string]$n.Uri; name = [string]$n.Name; publisher = [string]$n.Publisher; version = [string]$n.Version }
185
+ }
186
+ function Save-Msix([string]$uri, [string]$dest) {
187
+ $r = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 900 -MaximumRedirection 3 -OutFile $dest -PassThru
188
+ if (-not (Test-AllowedUri ([string]$r.BaseResponse.ResponseUri))) { throw ('msix redirected to a disallowed uri ' + $r.BaseResponse.ResponseUri) }
189
+ }
190
+ function Get-MsixIdentity([string]$file) {
191
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
192
+ $zip = [IO.Compression.ZipFile]::OpenRead($file)
193
+ try {
194
+ $entry = $zip.GetEntry('AppxManifest.xml')
195
+ $kind = 'Package'
196
+ if (-not $entry) { $entry = $zip.GetEntry('AppxMetadata/AppxBundleManifest.xml'); $kind = 'Bundle' }
197
+ if (-not $entry) { throw 'no AppxManifest.xml or AppxBundleManifest.xml in the archive' }
198
+ $sr = New-Object IO.StreamReader($entry.Open())
199
+ try { $x = [xml]($sr.ReadToEnd().TrimStart([char]0xFEFF)) } finally { $sr.Dispose() }
200
+ $idn = $x.GetElementsByTagName('Identity') | Select-Object -First 1
201
+ if (-not $idn) { throw 'manifest has no Identity' }
202
+ return [pscustomobject]@{ name = [string]$idn.Name; publisher = [string]$idn.Publisher; version = [string]$idn.Version; kind = $kind }
203
+ } finally { $zip.Dispose() }
204
+ }
205
+
206
+ # ================================================================== install: register / refresh the task
207
+ if ($Install) {
208
+ trap { Log ('UNHANDLED ERROR: ' + $_.Exception.Message + ' at line ' + $_.InvocationInfo.ScriptLineNumber); Write-Result 'error' 4 $_.Exception.Message; exit 4 }
209
+ if (-not $Pfn) { Finish 'misconfigured' 2 'install refused: no -Pfn' }
210
+ if ($Pfn -notmatch $PfnPattern) { Finish 'refused' 2 ('install refused: -Pfn is not a package family name: ' + $Pfn) }
211
+ $prev = Read-Config
212
+ $feedUri = $Feed
213
+ try {
214
+ [void][Windows.Management.Deployment.PackageManager, Windows.Management.Deployment, ContentType = WindowsRuntime]
215
+ $pm = New-Object Windows.Management.Deployment.PackageManager
216
+ foreach ($x in @($pm.FindPackagesForUser('', $Pfn))) {
217
+ $ai = $x.GetAppInstallerInfo()
218
+ if ($ai -and $ai.Uri) { $feedUri = [string]$ai.Uri; break }
219
+ }
220
+ } catch { Log ('App Installer info unreadable: ' + $_.Exception.Message) }
221
+ if (-not $feedUri -and $prev -and $prev.feed) { $feedUri = [string]$prev.feed; Log 'package has no App Installer association; keeping the recorded feed' }
222
+ $msix = $(if ($prev -and $prev.msix) { [string]$prev.msix } else { '' })
223
+ if ($feedUri -and -not (Test-AllowedUri $feedUri)) { Finish 'refused' 2 ('install refused: feed uri is not https (or http to loopback): ' + $feedUri) }
224
+ if ($feedUri) {
225
+ try {
226
+ $fi = Get-FeedInfo $feedUri
227
+ if ($fi.uri) { if (Test-AllowedUri $fi.uri) { $msix = $fi.uri } else { Log ('feed msix uri not recorded (not https): ' + $fi.uri) } }
228
+ } catch { Log ('feed not readable at install time (' + $feedUri + '): ' + $_.Exception.Message) }
229
+ } else { Log 'no App Installer feed known: repair can only use a recorded msix uri' }
230
+ if ($msix -and -not (Test-AllowedUri $msix)) { Finish 'refused' 2 ('install refused: msix uri is not https (or http to loopback): ' + $msix) }
231
+
232
+ # expected identity: what is installed now (this run is started by the packaged app), else what was recorded
233
+ $name = $(if ($prev -and $prev.name) { [string]$prev.name } else { '' })
234
+ $publisher = $(if ($prev -and $prev.publisher) { [string]$prev.publisher } else { '' })
235
+ $pk = @(Get-Family $Pfn) | Select-Object -First 1
236
+ if ($pk) { $name = [string]$pk.Name; $publisher = [string]$pk.Publisher }
237
+ if (-not $name -or -not $publisher) { Log 'WARNING: package identity (Name, Publisher) unknown; the repair will refuse every source until a launch of the installed app records it' }
238
+
239
+ $taskName = $Pfn + '-Repair'
240
+ try { Register-GuardTask $Pfn (Get-RetryLocal) } catch { Finish 'misconfigured' 2 ('task registration failed: ' + $_.Exception.Message) }
241
+ $cfg = [ordered]@{ guardVersion = $GuardVersion; pfn = $Pfn; name = $name; publisher = $publisher; feed = $feedUri; msix = $msix; task = $taskName; dir = $Dir; installedUtc = (Get-Date).ToUniversalTime().ToString('o') }
242
+ [IO.File]::WriteAllText($ConfigPath, ($cfg | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
243
+ try { Remove-Item -LiteralPath $AttemptPath -Force -ErrorAction SilentlyContinue } catch { }
244
+ Log ('task ' + $taskName + ' registered; identity=' + $name + ' / ' + $publisher + '; feed=' + $(if ($feedUri) { $feedUri } else { '(none)' }) + ' msix=' + $(if ($msix) { $msix } else { '(none)' }))
245
+ Write-Result 'installed' 0 ('task ' + $taskName + ' registered')
246
+ exit 0
247
+ }
248
+
249
+ # ================================================================== repair (task action)
250
+ trap {
251
+ Log ('UNHANDLED ERROR: ' + $_.Exception.Message + ' at line ' + $_.InvocationInfo.ScriptLineNumber)
252
+ Write-Result 'error' 4 $_.Exception.Message
253
+ if ($script:Ctx) { try { Set-Retry $script:Ctx 'unhandled error' } catch { } }
254
+ exit 4
255
+ }
256
+ $cfg = Read-Config
257
+ if (-not $cfg -or -not $cfg.pfn) { Finish 'misconfigured' 2 'guard.json missing or unreadable; nothing to do' }
258
+ $family = [string]$cfg.pfn
259
+ $feedUri = [string]$cfg.feed
260
+ $msix = [string]$cfg.msix
261
+ if ($family -notmatch $PfnPattern) { Finish 'refused' 2 ('guard.json pfn is not a package family name: ' + $family) }
262
+ if ([string]$cfg.task -ne ($family + '-Repair')) { Finish 'refused' 2 ('guard.json task ' + [string]$cfg.task + ' is not ' + $family + '-Repair') }
263
+ if ((Split-Path -Leaf $Dir) -ne 'repair') { Finish 'refused' 2 ('script folder is not named repair: ' + $Dir) }
264
+ if ($feedUri -and -not (Test-AllowedUri $feedUri)) { Finish 'refused' 2 ('guard.json feed uri is not https (or http to loopback): ' + $feedUri) }
265
+ if ($msix -and -not (Test-AllowedUri $msix)) { Finish 'refused' 2 ('guard.json msix uri is not https (or http to loopback): ' + $msix) }
266
+ $script:Ctx = $family
267
+ $mutex = [Threading.Mutex]::new($false, ('Local\' + $family + '-Repair'))
268
+ if (-not $mutex.WaitOne(0)) { Log 'another repair run is active; exiting (last-result.json left to that run)'; exit 0 }
269
+ $tmpMsix = ''
270
+ try {
271
+ $state = Get-StateText $family
272
+ if ($state -eq 'absent') {
273
+ Start-Sleep -Seconds 10 # an update or reinstall can briefly show no package; require absence twice
274
+ $state = Get-StateText $family
275
+ }
276
+ if ($state -eq 'absent') {
277
+ # uninstalled: remove the repair folder (never its parent: that holds user data), then the task itself (last: it ends this run)
278
+ $note = Join-Path $env:TEMP ($family + '-repair-cleanup.log')
279
+ try { Add-Content -LiteralPath $note -Value ((Get-Date).ToString('o') + ' package ' + $family + ' absent; removing task ' + $cfg.task + ' and ' + $Dir) } catch { }
280
+ try { Remove-Item -LiteralPath $Dir -Recurse -Force -ErrorAction Stop } catch { try { Add-Content -LiteralPath $note -Value ('folder removal failed: ' + $_.Exception.Message) } catch { } }
281
+ try { Unregister-ScheduledTask -TaskName ([string]$cfg.task) -Confirm:$false -ErrorAction Stop; Add-Content -LiteralPath $note -Value 'task unregistered' } catch { try { Add-Content -LiteralPath $note -Value ('task removal failed: ' + $_.Exception.Message) } catch { } }
282
+ exit 0
283
+ }
284
+ if (Test-Healthy $family) { Clear-Retry $family; Finish 'healthy' 0 ('package healthy: ' + $state) }
285
+ Log ('package NOT healthy: ' + $state + '; letting the deployment service settle')
286
+ Start-Sleep -Seconds 8
287
+ if (Test-Healthy $family) { Clear-Retry $family; Finish 'healthy' 0 ('package recovered by itself: ' + (Get-StateText $family)) }
288
+
289
+ if (-not $feedUri -and -not $msix) { Finish 'no-source' 3 'FAILED: no App Installer feed and no msix uri recorded; cannot repair. Reinstall from the original source or use Settings > Apps > Advanced options > Repair.' }
290
+ # expected identity: recorded at install; fall back to the (broken) installed registration, which still reports Name and Publisher
291
+ $expect = [pscustomobject]@{ name = [string]$cfg.name; publisher = [string]$cfg.publisher }
292
+ if (-not $expect.name -or -not $expect.publisher) {
293
+ $pk = @(Get-Family $family) | Select-Object -First 1
294
+ if ($pk) { $expect.name = [string]$pk.Name; $expect.publisher = [string]$pk.Publisher }
295
+ }
296
+ if (-not $expect.name -or -not $expect.publisher) {
297
+ Log 'FAILED: expected package identity unknown; refusing to install anything'
298
+ Set-Retry $family 'identity unknown'
299
+ Write-Result 'failed' 1 'expected package identity unknown'
300
+ exit 1
301
+ }
302
+ $why = @()
303
+ $feedOk = $false
304
+ if ($feedUri) {
305
+ $fi = $null
306
+ try { $fi = Get-FeedInfo $feedUri } catch { Log ('feed unreachable, skipping -AppInstallerFile: ' + $feedUri + ' : ' + $_.Exception.Message); $why += ('feed unreachable: ' + $_.Exception.Message) }
307
+ if ($fi) {
308
+ $bad = Test-Identity $expect $fi.name $fi.publisher
309
+ if (-not $bad -and $fi.uri -and -not (Test-AllowedUri $fi.uri)) { $bad = 'feed msix uri is not https (or http to loopback): ' + $fi.uri }
310
+ if ($bad) { Log ('REFUSED feed ' + $feedUri + ': ' + $bad); $why += ('feed refused: ' + $bad) }
311
+ else {
312
+ $feedOk = $true
313
+ if ($fi.uri) { $msix = $fi.uri } # newer than the one recorded
314
+ Log ('Add-AppxPackage -AppInstallerFile ' + $feedUri + ' (declares ' + $fi.version + ')')
315
+ try { Add-AppxPackage -AppInstallerFile $feedUri -ErrorAction Stop } catch { Log ('-AppInstallerFile failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-AppInstallerFile failed: ' + $_.Exception.Message) }
316
+ if (Test-Healthy $family) {
317
+ $now = @(Get-Family $family) | Select-Object -First 1
318
+ $bad = Test-Identity $expect ([string]$now.Name) ([string]$now.Publisher)
319
+ if ($bad) { Log ('FAILED: identity mismatch after repair via feed: ' + $bad); $why += ('identity after repair: ' + $bad) }
320
+ else { Clear-Retry $family; Finish 'repaired' 0 ('REPAIRED via feed: ' + (Get-StateText $family)) }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ if ($msix) {
326
+ $tmpMsix = Join-Path $env:TEMP ($family + '-repair-' + [guid]::NewGuid().ToString('N') + '.msix')
327
+ $got = $false
328
+ try { Log ('downloading ' + $msix); Save-Msix $msix $tmpMsix; $got = $true } catch { Log ('msix unreachable, skipping -Path: ' + $msix + ' : ' + $_.Exception.Message); $why += ('msix unreachable: ' + $_.Exception.Message) }
329
+ if ($got) {
330
+ $mi = $null
331
+ try { $mi = Get-MsixIdentity $tmpMsix } catch { Log ('REFUSED msix: corrupt or unreadable package: ' + $_.Exception.Message); $why += ('msix corrupt: ' + $_.Exception.Message) }
332
+ if ($mi) {
333
+ $bad = Test-Identity $expect $mi.name $mi.publisher
334
+ if ($bad) { Log ('REFUSED msix ' + $msix + ': ' + $bad); $why += ('msix refused: ' + $bad) }
335
+ else {
336
+ Log ('Add-AppxPackage -Path ' + $msix + ' (' + $mi.kind + ' ' + $mi.version + ')')
337
+ try { Add-AppxPackage -Path $tmpMsix -ErrorAction Stop } catch { Log ('-Path failed: ' + $_.Exception.Message + ' | ' + $_.FullyQualifiedErrorId); $why += ('-Path failed: ' + $_.Exception.Message) }
338
+ if (Test-Healthy $family) {
339
+ $now = @(Get-Family $family) | Select-Object -First 1
340
+ $bad = Test-Identity $expect ([string]$now.Name) ([string]$now.Publisher)
341
+ if ($bad) { Log ('FAILED: identity mismatch after repair via msix: ' + $bad); $why += ('identity after repair: ' + $bad) }
342
+ else { Clear-Retry $family; Finish 'repaired' 0 ('REPAIRED via msix uri: ' + (Get-StateText $family)) }
343
+ }
344
+ }
345
+ }
346
+ }
347
+ } else { $why += 'no msix uri recorded' }
348
+ $detail = 'FAILED: package still ' + (Get-StateText $family) + '. Reasons: ' + ($why -join ' | ')
349
+ Set-Retry $family 'repair failed'
350
+ Write-Result 'failed' 1 $detail
351
+ Log ($detail + '. A retry is scheduled (see retry.json); manual: Settings > Apps > Advanced options > Repair.')
352
+ exit 1
353
+ } finally {
354
+ if ($tmpMsix) { try { Remove-Item -LiteralPath $tmpMsix -Force -ErrorAction SilentlyContinue } catch { } }
355
+ try { $mutex.ReleaseMutex() } catch { }
356
+ }
@@ -5,14 +5,15 @@ import { notarizeMacosArtifact } from "./macos-notarization.mjs";
5
5
 
6
6
  const args = process.argv.slice(2);
7
7
  const dryRun = args.includes("--dry-run");
8
+ const noStaple = args.includes("--no-staple");
8
9
  const receiptIndex = args.indexOf("--receipt");
9
10
  const profileIndex = args.indexOf("--profile");
10
11
  const appIndex = args.indexOf("--app");
11
12
  const app = appIndex >= 0 ? args[appIndex + 1] : undefined;
12
13
  const receiptPath = receiptIndex >= 0 ? args[receiptIndex + 1] : null;
13
14
  const profile = profileIndex >= 0 ? args[profileIndex + 1] : undefined;
14
- const archiveArgs = args.filter((arg, index) => arg !== "--dry-run" && arg !== "--receipt" && arg !== "--profile" && arg !== "--app" && (appIndex < 0 || index !== appIndex + 1) && (receiptIndex < 0 || index !== receiptIndex + 1) && (profileIndex < 0 || index !== profileIndex + 1));
15
- if (archiveArgs.length !== 1) fail("usage: node notarize-macos.mjs [--dry-run] --receipt <path> [--profile <name>] [--app <bundle>] <archive>");
15
+ const archiveArgs = args.filter((arg, index) => arg !== "--dry-run" && arg !== "--no-staple" && arg !== "--receipt" && arg !== "--profile" && arg !== "--app" && (appIndex < 0 || index !== appIndex + 1) && (receiptIndex < 0 || index !== receiptIndex + 1) && (profileIndex < 0 || index !== profileIndex + 1));
16
+ if (archiveArgs.length !== 1) fail("usage: node notarize-macos.mjs [--dry-run] [--no-staple] --receipt <path> [--profile <name>] [--app <bundle>] <archive>");
16
17
  if (!receiptPath || receiptPath.startsWith("--")) fail("--receipt requires a path");
17
18
  if (appIndex >= 0 && (!app || app.startsWith("--"))) fail("--app requires an app bundle path");
18
19
  const archive = path.resolve(archiveArgs[0]);
@@ -23,7 +24,7 @@ if (dryRun) {
23
24
  if (process.platform !== "darwin") fail("macOS notarization must run on macOS");
24
25
  if (!existsSync(archive)) fail(`missing archive: ${archive}`);
25
26
 
26
- try { notarizeMacosArtifact({ archive, receiptPath, profile, app }); }
27
+ try { notarizeMacosArtifact({ archive, receiptPath, profile, app, staple: noStaple ? false : true }); }
27
28
  catch (error) {
28
29
  if (error.report) console.error(JSON.stringify(error.report));
29
30
  fail(`${error.code ? `[${error.code}] ` : ""}${error.message}`);
@@ -3,16 +3,15 @@
3
3
  "newCapabilityApprovalFlag": "--approve-new-capabilities",
4
4
  "approvalScope": "Local catalog generation only; installation still requires protected authorization. This flag cannot approve owner changes.",
5
5
  "ownerFamilies": [
6
- { "capability": "agent-room", "packages": ["agent-room-core", "@rightkit/agent-room-mcp"], "reason": "Native room core & MCP adapter share room contract" },
7
6
  { "capability": "licensing", "packages": ["rightkit-license", "@rightkit/license"], "reason": "Native entitlement enforcement & renderer/network client have separate roles" },
8
7
  { "capability": "structured-logging", "packages": ["rightkit-logs", "@rightkit/logs"], "reason": "Native JSONL writer & typed renderer invoke adapter" },
9
- { "capability": "text-to-speech", "packages": ["rightkit-qts", "rightkit-qts-ggml", "rightkit-qts-ggml-sys", "rightkit-tts"], "reason": "Speech facade & its QTS inference, safe-wrapper & FFI layers" },
10
- { "capability": "os-credential-store", "packages": ["rightkit-secrets", "RightKitSwift"], "reason": "Cross-platform Rust & Apple-native Swift credential APIs" },
11
- { "capability": "desktop-shell", "packages": ["rightkit-shell", "@rightkit/shell"], "reason": "Native Tauri shell & typed renderer bridge" },
12
- { "capability": "tauri-host-adapters", "packages": ["rightkit-tauri", "@rightkit/tauri"], "reason": "Native license/device services & renderer command adapters" }
8
+ { "capability": "text-to-speech", "packages": ["rightkit-tts-qwen", "rightkit-tts-qwen-sys", "rightkit-tts"], "reason": "Speech facade & its QTS inference, safe-wrapper & FFI layers" },
9
+ { "capability": "os-credential-store", "packages": ["rightkit-credentials", "RightKitSwift"], "reason": "Cross-platform Rust & Apple-native Swift credential APIs" },
10
+ { "capability": "desktop-shell", "packages": ["rightkit-tauri-shell", "@rightkit/tauri-shell"], "reason": "Native Tauri shell & typed renderer bridge" },
11
+ { "capability": "tauri-host-adapters", "packages": ["rightkit-device", "@rightkit/tauri"], "reason": "Native license/device services & renderer command adapters" }
13
12
  ],
14
13
  "testOwners": [
15
- { "path": "crates/rightkit-control/examples/control-test-app/Cargo.toml", "package": "control-test-app", "reason": "Unpublished desktop-control test fixture" },
16
- { "path": "crates/rightkit-shell/example-app/Cargo.toml", "package": "shell-example-app", "reason": "Unpublished standalone shell demonstration & test host" }
14
+ { "path": "crates/rightkit-qa-driver/examples/control-test-app/Cargo.toml", "package": "control-test-app", "reason": "Unpublished desktop-control test fixture" },
15
+ { "path": "crates/rightkit-tauri-shell/example-app/Cargo.toml", "package": "shell-example-app", "reason": "Unpublished standalone shell demonstration & test host" }
17
16
  ]
18
17
  }
@@ -18,6 +18,7 @@ import { spawnSync } from "./background-process.mjs";
18
18
  import { main as stagedMain } from "./ownership-staged.mjs";
19
19
  import { loadTrustedExternal } from "./ownership-approval.mjs";
20
20
  import { trustComparison } from "./ownership-policy-bundle.mjs";
21
+ import { isBlockingFinding } from "./ownership-verdict.mjs";
21
22
 
22
23
  const packageRoot = path.dirname(fileURLToPath(import.meta.url));
23
24
 
@@ -37,7 +38,7 @@ export function trustCompare(args, { print, trustLoader = loadTrustedExternal }
37
38
  return 0;
38
39
  }
39
40
 
40
- export async function main(args, { evidenceFactory = createOwnershipEvidence, gitRunner = spawnSync, print = (value) => console.log(value), trustLoader = loadTrustedExternal } = {}) {
41
+ export async function main(args, { evidenceFactory = createOwnershipEvidence, gitRunner = spawnSync, print = (value) => console.log(value), stderr = (line) => console.error(line), trustLoader = loadTrustedExternal } = {}) {
41
42
  if (args.includes("--help")) {
42
43
  print("right-release ownership [--app <dir>] [--json]\nright-release ownership --staged [--app <dir>] [--json] (pre-commit gate: scans the index, not the working tree)\nright-release ownership --write-manifest [--rightkit <dir>] [--approve-new-capabilities]\n--approve-new-capabilities permits new capability ids during local generation only. Existing owner conflicts are always refused. Protected installation authorization remains separate.");
43
44
  return 0;
@@ -58,20 +59,27 @@ export async function main(args, { evidenceFactory = createOwnershipEvidence, gi
58
59
  }
59
60
  const app = path.resolve(flag(args, "--app") ?? process.cwd());
60
61
  const top = gitRunner("git", ["-C", app, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true });
61
- if (top.status !== 0) return failClosed(args, "repository", "could not resolve --app to a Git repository");
62
+ if (top.status !== 0) return refuse(args, { type: "repository", message: "could not resolve --app to a Git repository" }, { print, stderr });
62
63
  const repoRoot = path.resolve(String(top.stdout).trim());
63
64
  let checked;
64
65
  try { checked = evidenceFactory(repoRoot, app); }
65
- catch (error) { return failClosed(args, "protected-evidence", error.message, print); }
66
+ catch (error) {
67
+ // A refusal from the protected checkout carries its check name and offending paths.
68
+ const blocked = error?.blocked ? [blockedCheck(error.blocked.check, error.message, error.blocked.paths)] : undefined;
69
+ return refuse(args, { type: "protected-evidence", message: error.message, blocked }, { print, stderr });
70
+ }
66
71
  const result = checked.result;
67
72
  if (args.includes("--audit-json")) return printAuditJson(result, print);
73
+ // A refusal names every blocking check on stderr and in the JSON `blocked` field. Advise-tier findings never refuse.
74
+ const blocked = checked.enrollmentPending || checked.ok ? undefined : blockingChecks(checked);
75
+ if (blocked) reportBlocked(blocked, stderr);
68
76
  if (args.includes("--json")) {
69
77
  const error = checked.enrollmentPending ? undefined : !checked.floor.ok
70
78
  ? { type: "ownership-floor", message: checked.floor.problems.join("; ") }
71
79
  : result.coverage.length
72
80
  ? { type: "ownership-coverage", message: `${result.coverage.length} ownership coverage gap(s)` }
73
81
  : undefined;
74
- print(JSON.stringify({ ok: checked.ok, ...(checked.enrollmentPending ? { enrollmentPending: true, warning: checked.warning } : {}), ...(error ? { error } : {}), rows: result.rows, violations: result.violations, covered: result.covered, problems: [...result.problems, ...checked.floor.problems], coverage: result.coverage, metadata: result.metadata }, null, 2));
82
+ print(JSON.stringify({ ok: checked.ok, ...(checked.enrollmentPending ? { enrollmentPending: true, warning: checked.warning } : {}), ...(error ? { error } : {}), ...(blocked ? { blocked } : {}), rows: result.rows, violations: result.violations, covered: result.covered, problems: [...result.problems, ...checked.floor.problems], coverage: result.coverage, metadata: result.metadata }, null, 2));
75
83
  } else {
76
84
  print(`RightKit ownership: ${path.basename(app)}`);
77
85
  print(formatTable(result.rows));
@@ -79,7 +87,7 @@ export async function main(args, { evidenceFactory = createOwnershipEvidence, gi
79
87
  print(` OWN COPY ${v.fingerprint} (${v.package}) ${v.file}${v.line ? `:${v.line}` : ""} ${v.text}`);
80
88
  }
81
89
  for (const p of result.problems) print(` ${checked.enrollmentPending ? "DIAGNOSTIC" : "EXCEPTION"} ${p}`);
82
- print(checked.enrollmentPending ? `[right-release] ${checked.warning}` : checked.ok ? "[right-release] ownership passed" : `[right-release] ownership FAILED: ${[...result.problems, ...checked.floor.problems].join("; ") || "move these to RightKit or add a reviewed, dated exception"}`);
90
+ print(verdictLine(checked, result, blocked));
83
91
  }
84
92
  // CI lanes may pass `--advisory-when-pending`: before approval keys are enrolled the scan asserts
85
93
  // nothing, so findings print as diagnostics and the step exits 0. Release lanes never pass it.
@@ -115,9 +123,62 @@ function printAuditJson(result, print) {
115
123
  }
116
124
  }
117
125
 
118
- function failClosed(args, type, message, print = (value) => console.log(value)) {
119
- if (args.includes("--audit-json")) { console.error(`[right-release] ownership audit failed: ${message}`); return 1; }
120
- if (args.includes("--json")) print(JSON.stringify({ ok: false, error: { type, message }, rows: [], violations: [], covered: [], problems: [message], coverage: [], metadata: null }, null, 2));
126
+ // Each refusal names its blocking check and the first offending paths. Stderr and the JSON `blocked` field carry the same list.
127
+ const BLOCKED_PATH_LIMIT = 10;
128
+
129
+ function blockedCheck(check, message, paths = []) {
130
+ return { check, message, count: paths.length, paths: paths.slice(0, BLOCKED_PATH_LIMIT) };
131
+ }
132
+
133
+ // The checks that refuse a scan that did not pass. Advise-tier findings are never listed here.
134
+ function blockingChecks(checked) {
135
+ const result = checked.result;
136
+ const checks = [];
137
+ const blocking = (result.violations ?? []).filter(isBlockingFinding);
138
+ if (blocking.length) checks.push(blockedCheck("ownership-violation", `${blocking.length} block-tier finding(s) duplicate RightKit owners`, blocking.map((entry) => entry.file).filter(Boolean)));
139
+ const floorProblems = checked.floor?.ok === false ? checked.floor.problems ?? [] : [];
140
+ if (floorProblems.length) checks.push(blockedCheck("ownership-floor", floorProblems.join("; ")));
141
+ const problems = (result.problems ?? []).filter((problem) => !floorProblems.includes(problem));
142
+ if (problems.length) checks.push(blockedCheck("ownership-problem", problems.join("; ")));
143
+ const gaps = result.coverage ?? [];
144
+ if (gaps.length) checks.push(blockedCheck("ownership-coverage", `${gaps.length} coverage gap(s): ${[...new Set(gaps.map((gap) => gap.reason))].join(", ")}`, gaps.map((gap) => gap.path)));
145
+ if (!checks.length) checks.push(blockedCheck("ownership-unknown", "scan reported not ok without a listed cause"));
146
+ return checks;
147
+ }
148
+
149
+ function reportBlocked(checks, stderr) {
150
+ for (const check of checks) {
151
+ stderr(`[right-release] refused by check ${check.check}: ${check.message}`);
152
+ for (const file of check.paths) stderr(` - ${file}`);
153
+ if (check.count > check.paths.length) stderr(` ... and ${check.count - check.paths.length} more`);
154
+ }
155
+ }
156
+
157
+ function tierCounts(violations) {
158
+ const counts = { block: 0, advise: 0 };
159
+ for (const violation of violations) counts[isBlockingFinding(violation) ? "block" : "advise"] += 1;
160
+ return counts;
161
+ }
162
+
163
+ // The human verdict line. Advise-only results pass and say so; a refusal names its checks.
164
+ function verdictLine(checked, result, blocked) {
165
+ if (checked.enrollmentPending) return `[right-release] ${checked.warning}`;
166
+ if (checked.ok) {
167
+ const counts = tierCounts(result.violations ?? []);
168
+ return counts.advise
169
+ ? `[right-release] ownership ADVISE: block ${counts.block}, advise ${counts.advise} (reported, not refused)`
170
+ : "[right-release] ownership passed";
171
+ }
172
+ const hint = blocked.some((entry) => entry.check === "ownership-violation") ? "; move these to RightKit or add a reviewed, dated exception" : "";
173
+ return `[right-release] ownership FAILED: ${blocked.map((entry) => `${entry.check}: ${entry.message}`).join("; ")}${hint}`;
174
+ }
175
+
176
+ // Refusal before any verdict: unresolved repository, protected evidence, or a dirty protected checkout.
177
+ function refuse(args, { type, message, blocked }, { print = (value) => console.log(value), stderr = (line) => console.error(line) } = {}) {
178
+ const checks = blocked ?? [blockedCheck(type, message)];
179
+ reportBlocked(checks, stderr);
180
+ if (args.includes("--audit-json")) { stderr(`[right-release] ownership audit failed: ${message}`); return 1; }
181
+ if (args.includes("--json")) print(JSON.stringify({ ok: false, error: { type, message }, blocked: checks, rows: [], violations: [], covered: [], problems: [message], coverage: [], metadata: null }, null, 2));
121
182
  else print(`[right-release] ownership FAILED: ${message}`);
122
183
  return 1;
123
184
  }
@@ -90,7 +90,7 @@ function createEvidence(repoRoot, {
90
90
  : { ok: false, problems: ["protected ownership floor is unavailable until trust registry enrollment"] };
91
91
  if (bundleProblem) { floor.ok = false; floor.problems = [bundleProblem, ...floor.problems.filter((problem) => !problem.startsWith("ownership policy bundle"))]; }
92
92
  // Real paths on both sides: Git's root is resolved (macOS /private/var), callers may pass /var or /tmp.
93
- const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
93
+ const real = (p) => { try { return realpathSync.native(p); } catch { return path.resolve(p); } };
94
94
  const requestedRoot = path.relative(real(root), real(scanRoot)).replaceAll("\\", "/") || ".";
95
95
  if (requestedRoot.startsWith("../") || requestedRoot === ".." || path.isAbsolute(requestedRoot)) throw new Error("ownership evidence scan root must be inside repository");
96
96
  const evidenceDiagnostics = [];
@@ -37,7 +37,7 @@ function validateAuthority(authority) {
37
37
  if (typeof authority.checkoutPath !== "string" || !path.isAbsolute(authority.checkoutPath) || authority.checkoutPath.includes("\0")) throw new Error("protected key authority checkoutPath must be absolute");
38
38
  const repositoryIdentity = canonicalRepositoryIdentity(authority.repositoryIdentity);
39
39
  if (repositoryIdentity !== authority.repositoryIdentity) throw new Error("protected key authority identity must be canonical");
40
- const checkoutPath = realpathSync(authority.checkoutPath);
40
+ const checkoutPath = realpathSync.native(authority.checkoutPath);
41
41
  if (!statSync(checkoutPath).isDirectory()) throw new Error("protected key authority checkoutPath must be a directory");
42
42
  return { repositoryIdentity, checkoutPath };
43
43
  }
@@ -41,7 +41,8 @@ function readLog(root) {
41
41
  }
42
42
  function ensureCleanMetadata(root, run, rels) {
43
43
  for (const rel of rels) assertSafeMetadataParents(root, rel);
44
- const result = run("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"]);
44
+ // Limited to the metadata paths: an unscoped status of a large checkout overflows the child's output buffer.
45
+ const result = run("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all", "--", ...rels]);
45
46
  if (result.status !== 0) throw new Error("could not inspect repository metadata status");
46
47
  const status = String(result.stdout ?? "");
47
48
  for (const rel of rels) {
@@ -49,7 +50,7 @@ function ensureCleanMetadata(root, run, rels) {
49
50
  }
50
51
  }
51
52
  function metadataIsDirty(root, run) {
52
- const r = run("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"]);
53
+ const r = run("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all", "--", LOG, REQUEST_DIR]);
53
54
  if (r.status !== 0) throw new Error("could not inspect repository metadata status");
54
55
  return String(r.stdout ?? "").split(/\r?\n/).some((line) => {
55
56
  const file = line.slice(3).replaceAll('"', "");
@@ -355,7 +356,7 @@ export async function main(args, deps = {}) {
355
356
  try {
356
357
  if (!platform) throw new Error(`ownership key management unsupported platform: ${process.platform}`);
357
358
  // Delegate BEFORE taking the command lock: the desktop-session child needs that same lock.
358
- if (args[0] === "trust-sync" && needsDesktopDelegation({ hostPlatform: deps.hostPlatform, env: deps.env, run, elevatedCheck: deps.elevatedCheck })) return delegateTrustSync(deps, run, print);
359
+ if (args[0] === "trust-sync" && !args.includes("--check") && needsDesktopDelegation({ hostPlatform: deps.hostPlatform, env: deps.env, run, elevatedCheck: deps.elevatedCheck })) return delegateTrustSync(deps, run, print);
359
360
  const helper = deps.helper ?? helperFor(run);
360
361
  const lockPath = deps.lockPath ?? path.join(os.tmpdir(), "rightkit-key-command.lock");
361
362
  let lockFd;
@@ -524,6 +525,17 @@ async function trustSync(_args, d) {
524
525
  if (!trust?.trust?.rootFingerprint) throw new Error("protected root trust is not enrolled on this host; run ownership enroll");
525
526
  if (trust.trust.rootFingerprint !== pin) throw new Error("committed key log root does not match this host's protected trust");
526
527
  const baseline = d.baseline ?? parse(readFileSync(path.join(here, "ownership-default-policy.json"), "utf8"), "ownership default policy");
528
+ if (_args.includes("--check")) {
529
+ // Read-only: say whether a sync would ask for approval, and for what. Asks nothing, elevates nothing, writes no trust.
530
+ const request = makeTrustRequest(log, pin, baseline, d.authority);
531
+ try {
532
+ const summary = (d.summarize ?? summarizeRequestFile)({ file: request.file, digest: request.digest, existing: trust.trust, ...(d.summaryInputs ?? {}) });
533
+ if (!summary.changed) { d.print("trust already current: no approval needed"); return 0; }
534
+ d.print(summary.lines.join("\n"));
535
+ d.print("approval needed: trust-sync on this host would ask for the change above");
536
+ return 3;
537
+ } finally { await import("node:fs").then(({ unlinkSync }) => unlinkSync(request.file)).catch(() => {}); }
538
+ }
527
539
  const installed = await installCheckpoint(log, pin, baseline, d);
528
540
  if (installed === false) { d.print("trust already current"); return 0; }
529
541
  postVerify(d.root, log, d);