agenshield 2026.8.2-beta.5083739 → 2026.8.2

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.
package/bin/agenshield CHANGED
@@ -225,14 +225,34 @@ macos_residue_uninstall() {
225
225
  kill -9 "$_deact" 2>/dev/null || true
226
226
  fi
227
227
 
228
- # 3. Remove the AgenShield CA from the System keychain (sweep by CN, delete by
229
- # SHA-1) — mirrors the CLI's removeInstalledCaTrust.
230
- $SUDO /usr/bin/security find-certificate -a -Z -c "AgenShield" /Library/Keychains/System.keychain 2>/dev/null |
228
+ # 3. Remove the AgenShield CA from the System keychain (select by SUBJECT,
229
+ # delete by SHA-1) — mirrors the CLI's removeInstalledCaTrust.
230
+ #
231
+ # `find-certificate -c "AgenShield"` is a SUBSTRING match on the cert's
232
+ # NAME, so the previous version of this loop deleted every trusted root
233
+ # that merely mentioned the product: the local-CDN development CA, and any
234
+ # customer/MDM root called something like "AgenShield Corp Root". Deleting
235
+ # a root we did not issue is damage, not cleanup. Enumerate PEMs instead
236
+ # and keep only the subjects AgenShield's own CA builder emits — the same
237
+ # set as `isAgenShieldIssuedCaCn` in @agenshield/utils.
238
+ _pem=""
239
+ $SUDO /usr/bin/security find-certificate -a -p /Library/Keychains/System.keychain 2>/dev/null |
231
240
  while IFS= read -r _line; do
241
+ _pem="$_pem$_line
242
+ "
232
243
  case "$_line" in
233
- "SHA-1 hash: "*)
234
- _sha1="${_line#SHA-1 hash: }"
235
- $SUDO /usr/bin/security delete-certificate -Z "$_sha1" /Library/Keychains/System.keychain >/dev/null 2>&1 || true
244
+ "-----END CERTIFICATE-----")
245
+ _subj="$(printf '%s' "$_pem" | openssl x509 -noout -subject 2>/dev/null || true)"
246
+ _cn="${_subj##*CN=}"
247
+ _cn="${_cn%%,*}"
248
+ _cn="${_cn%"${_cn##*[![:space:]]}"}"
249
+ case "$_cn" in
250
+ "AgenShield CA"|"AgenShield Dev CA"|"AgenShield MITM CA"*)
251
+ _sha1="$(printf '%s' "$_pem" | openssl x509 -noout -fingerprint -sha1 2>/dev/null | cut -d= -f2 | tr -d ':')"
252
+ [ -n "$_sha1" ] && $SUDO /usr/bin/security delete-certificate -Z "$_sha1" /Library/Keychains/System.keychain >/dev/null 2>&1 || true
253
+ ;;
254
+ esac
255
+ _pem=""
236
256
  ;;
237
257
  esac
238
258
  done
@@ -0,0 +1,1037 @@
1
+ <#
2
+ .SYNOPSIS
3
+ AgenShield Installer (Windows) - the canonical, versioned installer shipped
4
+ in the npm package and served (per-campaign) by agencloud.
5
+
6
+ .DESCRIPTION
7
+ Resolves the latest (or a pinned) AgenShield release from the release
8
+ manifest, downloads and verifies the signed MSI, stages machine-scope
9
+ enrollment, installs it silently, and waits for the service to come up.
10
+ This is the Windows sibling of tools/sea/install.sh - same environment
11
+ contract, same baked CDN supply-chain guard, same "never block the base
12
+ install on enrollment plumbing" posture.
13
+
14
+ Mirror set: the version-resolution + download logic here is deliberately
15
+ kept in sync with tools/sea/install.sh (macOS/Linux) and
16
+ libs/cli/src/utils/github-releases.ts's win32 support. Windows has no
17
+ GitHub-Releases fallback - every asset is served from the first-party CDN
18
+ (see tools/build/cdn/cdn-update-manifests.sh for the versions.json shape
19
+ this script parses). Also mirrored, in the agenshield-service repo:
20
+ apps/service/src/modules/campaigns/templates/install-script-windows.template.ts
21
+ (the per-campaign served copy, with CloudUrl/Token/Org/Version baked into
22
+ the source text - there is no shared build step across the two repos, so
23
+ a change to manifest resolution, ACL hardening, the checksum gate, or the
24
+ PSEdition-branched atomic file create below must be re-applied there BY
25
+ HAND, and vice versa). This asymmetry is exactly how the two sides
26
+ independently carried the SAME broken 7-argument FileStream constructor
27
+ under .NET Core / PowerShell 7 for a time - update the OTHER side's Mirror
28
+ set note when you edit either one.
29
+
30
+ AGENSHIELD_TOKEN / AGENSHIELD_CLOUD_URL / AGENSHIELD_ORG are deliberately
31
+ ENVIRONMENT-ONLY - there is no -Token/-CloudUrl/-Org parameter. Windows
32
+ PowerShell 5.1's Start-Transcript writes a "Host Application:" header
33
+ containing the FULL launch command line of the process into the transcript
34
+ file, which lives under %ProgramData%\AgenShield\logs (inherits
35
+ BUILTIN\Users: Read, and must, since the non-elevated parent tails it). A
36
+ documented "-Token <value>" invocation would therefore persist the bearer
37
+ campaign token in plaintext to a world-readable log on every run. Env vars
38
+ are not part of the command line, so they do not appear in that header; the
39
+ internal -HandoffFile mechanism carries them across the self-elevation
40
+ boundary instead of argv, for the same reason (Win32_Process makes a
41
+ process's own command line readable by any local user).
42
+
43
+ .PARAMETER Version
44
+ Install a specific version instead of resolving the latest for the channel.
45
+ Env: AGENSHIELD_VERSION.
46
+
47
+ .PARAMETER AssetsBase
48
+ Override the release-asset base URL (dev/test only - a production install
49
+ uses the baked first-party CDN base, never a host-controlled override; the
50
+ override is honored only when the baked value is absent, i.e. an unbaked
51
+ checkout). Env: AGENSHIELD_ASSETS_BASE.
52
+
53
+ .PARAMETER Channel
54
+ Explicit release channel: stable | rc | beta | alpha. Takes precedence over
55
+ ClientAlpha/ClientBeta, matching install.sh's AGENSHIELD_CHANNEL. Env:
56
+ AGENSHIELD_CHANNEL.
57
+
58
+ .PARAMETER ClientAlpha
59
+ .PARAMETER ClientBeta
60
+ Select the alpha/beta release channel when Channel is not given (stable is
61
+ the default). Env: AGENSHIELD_CLIENT_ALPHA / AGENSHIELD_CLIENT_BETA = "true".
62
+
63
+ .EXAMPLE
64
+ .\install.ps1
65
+ irm '<cloudUrl>/resources/campaigns/v1/<token>/install.ps1' | iex
66
+ (the per-campaign copy served by agencloud, pre-filled with the
67
+ enrollment token via environment variables; Task 6's served variant
68
+ re-fetches itself from a SELF_URL to re-invoke elevated, since a piped
69
+ script has no file on disk)
70
+ #>
71
+ [CmdletBinding()]
72
+ param(
73
+ [string]$Version = $env:AGENSHIELD_VERSION,
74
+ [string]$AssetsBase = $env:AGENSHIELD_ASSETS_BASE,
75
+ [string]$Channel = $env:AGENSHIELD_CHANNEL,
76
+ [switch]$ClientAlpha = ($env:AGENSHIELD_CLIENT_ALPHA -eq 'true'),
77
+ [switch]$ClientBeta = ($env:AGENSHIELD_CLIENT_BETA -eq 'true'),
78
+ # Internal - set only by this script's own elevated self-relaunch, never by
79
+ # a caller. RunId lets the parent and the elevated child agree on the
80
+ # transcript filename; HandoffFile is the short-lived file carrying
81
+ # Token/CloudUrl/Org across the elevation boundary (see the DESCRIPTION).
82
+ # ElevatedChild marks "this invocation IS ITSELF the product of a
83
+ # -Verb RunAs relaunch" - it is what stops the elevation gate from
84
+ # relaunching a SECOND time (and raising another UAC prompt, forever) if
85
+ # the relaunched process somehow still isn't elevated.
86
+ [string]$RunId,
87
+ [string]$HandoffFile,
88
+ [switch]$ElevatedChild
89
+ )
90
+
91
+ $ErrorActionPreference = 'Stop'
92
+ # Invoke-WebRequest's progress-bar rendering is notoriously slow under
93
+ # Windows PowerShell 5.1 and can turn a multi-MB download into a multi-minute
94
+ # one; SilentlyContinue disables it without affecting the actual transfer.
95
+ $ProgressPreference = 'SilentlyContinue'
96
+ # Windows PowerShell 5.1 does not always default to TLS 1.2, and every
97
+ # endpoint this script talks to (the CDN, the daemon) requires it - an
98
+ # unpatched default here fails with an opaque "Could not create SSL/TLS
99
+ # secure channel" instead of a useful error.
100
+ [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
101
+
102
+ # Environment-only secrets (see the DESCRIPTION for why these are not
103
+ # parameters). Reassigned below from the handoff file when this run is the
104
+ # elevated child of a self-relaunch.
105
+ $Token = $env:AGENSHIELD_TOKEN
106
+ $CloudUrl = $env:AGENSHIELD_CLOUD_URL
107
+ $Org = $env:AGENSHIELD_ORG
108
+
109
+ if (-not $RunId) { $RunId = Get-Date -Format 'yyyyMMdd-HHmmss' }
110
+ # Internal parameter that reaches a log path and two msiexec log arguments - reject anything outside a safe charset rather than try to sanitize a path.
111
+ if ($RunId -notmatch '^[A-Za-z0-9_-]+$') {
112
+ throw "Invalid -RunId value: '$RunId'"
113
+ }
114
+ $script:LogDir = Join-Path $env:ProgramData 'AgenShield\logs'
115
+ $script:TranscriptPath = Join-Path $script:LogDir "install-$RunId.log"
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Output helpers (mirror install.sh's info/ok/warn/error glyph language)
119
+ # ---------------------------------------------------------------------------
120
+
121
+ function Write-Step { param([string]$Message) Write-Host ''; Write-Host "==> $Message" -ForegroundColor Cyan }
122
+ function Write-Info { param([string]$Message) Write-Host " $Message" -ForegroundColor DarkGray }
123
+ function Write-Ok { param([string]$Message) Write-Host " [ok] $Message" -ForegroundColor Green }
124
+ function Write-WarnLine { param([string]$Message) Write-Warning $Message }
125
+ function Write-ErrorLine { param([string]$Message) Write-Host " [error] $Message" -ForegroundColor Red }
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Elevation
129
+ # ---------------------------------------------------------------------------
130
+
131
+ function Test-IsAdministrator {
132
+ $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
133
+ $principal = New-Object System.Security.Principal.WindowsPrincipal($identity)
134
+ return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
135
+ }
136
+
137
+ # Escapes for the Win32 argv parser before quoting - a bare wrap lets an embedded quote inject a parameter into the elevated child.
138
+ function ConvertTo-QuotedArg {
139
+ param([string]$Value)
140
+ if ($null -eq $Value) { $Value = '' }
141
+ $escaped = ''
142
+ $backslashRun = 0
143
+ for ($i = 0; $i -lt $Value.Length; $i++) {
144
+ $char = $Value[$i]
145
+ if ($char -eq '\') {
146
+ $backslashRun++
147
+ } elseif ($char -eq '"') {
148
+ $escaped += ('\' * (($backslashRun * 2) + 1)) + '"'
149
+ $backslashRun = 0
150
+ } else {
151
+ $escaped += ('\' * $backslashRun) + $char
152
+ $backslashRun = 0
153
+ }
154
+ }
155
+ $escaped += '\' * ($backslashRun * 2)
156
+ return '"' + $escaped + '"'
157
+ }
158
+
159
+ # System32 resolved via GetSystemDirectory(), NOT $env:SystemRoot: an env var
160
+ # is caller-controlled (a non-admin can set SystemRoot=C:\evil before running
161
+ # this script), so building an "absolute path" out of it is not actually
162
+ # absolute - it is the same PATH/CWD-planting class Task 3's review fixed for
163
+ # reg.exe/wmic/powershell.exe, one level up. [System.Environment]::SystemDirectory
164
+ # is a native GetSystemDirectory() call, not an environment read.
165
+ function Get-System32Path {
166
+ param([Parameter(Mandatory)][string]$RelativePath)
167
+ return Join-Path ([System.Environment]::SystemDirectory) $RelativePath
168
+ }
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Release-manifest resolution (pure - testable against fixture JSON; see
172
+ # tools/build/windows/check-install-ps1.mjs, which hand-ports this exact
173
+ # function and runs it against fixtures with mutation checks in CI)
174
+ # ---------------------------------------------------------------------------
175
+
176
+ # Plain PSCustomObject property access breaks on hyphenated keys like
177
+ # "win32-x64" ($obj.win32-x64 parses as a subtraction), so every manifest
178
+ # field read goes through this indexer instead of dot/quoted-dot syntax.
179
+ function Get-ManifestProperty {
180
+ param($InputObject, [string]$Name)
181
+ if ($null -eq $InputObject) { return $null }
182
+ $prop = $InputObject.PSObject.Properties[$Name]
183
+ if ($null -eq $prop) { return $null }
184
+ return $prop.Value
185
+ }
186
+
187
+ function Get-Win32InstallerDescriptor {
188
+ param($VersionEntry)
189
+ $platforms = Get-ManifestProperty $VersionEntry 'platforms'
190
+ $win32 = Get-ManifestProperty $platforms 'win32-x64'
191
+ return Get-ManifestProperty $win32 'installer'
192
+ }
193
+
194
+ # Resolution order - NEVER the top-level latest/channels (those are the
195
+ # darwin-arm64 view; a Windows-only release does not advance them):
196
+ # 1. a pinned version, if given - hard-fail if it lacks a win32-x64 installer
197
+ # 2. platformChannels."win32-x64"[channel] - the fast path
198
+ # 3. walk versions[] (already published newest-first) for the first entry
199
+ # on this channel that actually carries platforms."win32-x64".installer
200
+ # Step 3 is not redundant with step 2: platformChannels can point at a version
201
+ # whose win32-x64 role was bootstrap-only (no full installer published that
202
+ # release) - independent macOS/Windows cadences mean the newest entry for a
203
+ # platform is not always the newest one that shipped an installer for it.
204
+ function Resolve-InstallerVersionEntry {
205
+ param($Manifest, [string]$PinnedVersion, [string]$Channel)
206
+
207
+ $versions = Get-ManifestProperty $Manifest 'versions'
208
+ if ($null -eq $versions) { $versions = @() }
209
+
210
+ if ($PinnedVersion) {
211
+ $entry = $versions | Where-Object { (Get-ManifestProperty $_ 'version') -eq $PinnedVersion } | Select-Object -First 1
212
+ if (-not $entry -or -not (Get-Win32InstallerDescriptor $entry)) {
213
+ throw "Pinned version '$PinnedVersion' has no win32-x64 installer in the release manifest."
214
+ }
215
+ return $entry
216
+ }
217
+
218
+ $platformChannels = Get-ManifestProperty $Manifest 'platformChannels'
219
+ $win32Channels = Get-ManifestProperty $platformChannels 'win32-x64'
220
+ $candidateVersion = Get-ManifestProperty $win32Channels $Channel
221
+ if ($candidateVersion) {
222
+ $entry = $versions | Where-Object { (Get-ManifestProperty $_ 'version') -eq $candidateVersion } | Select-Object -First 1
223
+ if ($entry -and (Get-Win32InstallerDescriptor $entry)) {
224
+ return $entry
225
+ }
226
+ }
227
+
228
+ foreach ($entry in $versions) {
229
+ if ((Get-ManifestProperty $entry 'channel') -eq $Channel -and (Get-Win32InstallerDescriptor $entry)) {
230
+ return $entry
231
+ }
232
+ }
233
+
234
+ throw "No win32-x64 installer found for channel '$Channel' in the release manifest."
235
+ }
236
+
237
+ # AGENSHIELD_CHANNEL (explicit) wins over CLIENT_ALPHA/CLIENT_BETA, matching
238
+ # install.sh's resolve_channel precedence; 'rc' is additionally accepted here
239
+ # (install.sh's own resolve_channel does not parse it, but
240
+ # platformChannels."win32-x64".rc IS published and would otherwise be
241
+ # unreachable from this script).
242
+ function Resolve-Channel {
243
+ $explicit = if ($Channel) { $Channel.Trim().ToLowerInvariant() } else { '' }
244
+ if ($explicit -eq 'stable' -or $explicit -eq 'rc' -or $explicit -eq 'beta' -or $explicit -eq 'alpha') {
245
+ return $explicit
246
+ }
247
+ if ($ClientAlpha) { return 'alpha' }
248
+ if ($ClientBeta) { return 'beta' }
249
+ return 'stable'
250
+ }
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Download + verify
254
+ # ---------------------------------------------------------------------------
255
+
256
+ function Invoke-DownloadWithRetry {
257
+ param([Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$OutFile, [int]$MaxAttempts = 3)
258
+ for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
259
+ try {
260
+ Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec 300
261
+ return
262
+ } catch {
263
+ if ($attempt -eq $MaxAttempts) { throw }
264
+ Write-WarnLine "Download attempt $attempt of $MaxAttempts failed - retrying: $($_.Exception.Message)"
265
+ Start-Sleep -Seconds (2 * $attempt)
266
+ }
267
+ }
268
+ }
269
+
270
+ # Baked at build/publish time exactly like install.sh's CDN_BASE_URL: a
271
+ # deliberate constant, never a runtime env read on a production install - a
272
+ # host-controlled override would let anyone who can set an env var redirect
273
+ # the signed-MSI download to an attacker host. CdnPlaceholder is split so the
274
+ # substitution that bakes CdnBaseUrl below can't also rewrite THIS comparison
275
+ # - an unsubstituted copy (raw repo checkout) falls back to the hardcoded
276
+ # production default rather than silently comparing a baked value to itself.
277
+ # The baked value MUST outrank -AssetsBase/AGENSHIELD_ASSETS_BASE (see the
278
+ # effective-base resolution near Main) - an override that wins over the guard
279
+ # it is supposed to be an exception to defeats the guard entirely.
280
+ $script:CdnBaseUrl = 'https://assets.frontegg.com/agenshield'
281
+ $script:CdnPlaceholder = '__AGENSHIELD_' + 'CDN_BASE_URL__'
282
+ if ($script:CdnBaseUrl -eq $script:CdnPlaceholder) { $script:CdnBaseUrl = '' }
283
+ $script:CdnBaseUrl = $script:CdnBaseUrl.TrimEnd('/')
284
+ $script:DefaultCdnBaseUrl = 'https://assets.frontegg.com/agenshield'
285
+
286
+ # Ship FALSE - no Authenticode certificate is provisioned yet. Flipping this
287
+ # to $true is meant to be a one-line change once one is; the SHA-256 checksum
288
+ # gate above is hard regardless of this flag.
289
+ $script:RequireSignature = $false
290
+ # Filled in once a signing certificate exists; unused while RequireSignature
291
+ # is $false (an unsigned MSI takes the NotSigned branch below, not this one).
292
+ $script:ExpectedSignerThumbprint = ''
293
+ $script:ExpectedSignerSubject = ''
294
+
295
+ function Test-InstallerSignature {
296
+ param([Parameter(Mandatory)][string]$Path)
297
+ $sig = Get-AuthenticodeSignature -LiteralPath $Path
298
+ $signerOk = ($sig.Status -eq 'Valid') -and $sig.SignerCertificate -and
299
+ ($sig.SignerCertificate.Thumbprint -eq $script:ExpectedSignerThumbprint) -and
300
+ ($sig.SignerCertificate.Subject -eq $script:ExpectedSignerSubject)
301
+
302
+ if ($sig.Status -eq 'NotSigned') {
303
+ if ($script:RequireSignature) { throw 'The downloaded installer is not Authenticode-signed.' }
304
+ Write-WarnLine 'The downloaded installer is not Authenticode-signed (no signing certificate is provisioned yet). The SHA-256 checksum against the release manifest is still verified as a hard gate.'
305
+ return
306
+ }
307
+ if (-not $signerOk) {
308
+ if ($script:RequireSignature) { throw "The downloaded installer's Authenticode signature did not verify (status: $($sig.Status))." }
309
+ Write-WarnLine "The downloaded installer's Authenticode signature did not match the expected signer (status: $($sig.Status)). Continuing because RequireSignature is `$false - the SHA-256 checksum against the release manifest is still a hard gate."
310
+ return
311
+ }
312
+ Write-Ok "Authenticode signature verified ($($sig.SignerCertificate.Subject))"
313
+ }
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # %ProgramData%\AgenShield\config hardening + enrollment handoff
317
+ #
318
+ # Two traps a prior task's review proved the hard way, both closed here:
319
+ # - icacls /grant:r (and the .NET equivalent of touching an EXISTING ACL)
320
+ # only ever adds/replaces grants for the principals it is given - a THIRD
321
+ # principal's explicit ACE (e.g. a low-privileged user who pre-created
322
+ # this directory, since C:\ProgramData's default DACL lets any local user
323
+ # create a subdirectory) survives untouched, and would keep permanent
324
+ # WRITE_DAC via ownership. So verification here is an ALLOWLIST (refuse
325
+ # any principal other than SYSTEM/Administrators, and any inherited ACE),
326
+ # never a denylist ("not Users").
327
+ # - Account-name literals are locale-dependent (icacls only ever prints
328
+ # resolved, localized names). Get-Acl/Set-Acl let this run entirely on
329
+ # SecurityIdentifier objects - SIDs are compared directly, no name
330
+ # resolution anywhere, in BOTH the grant and the verify half. That is
331
+ # strictly better than icacls can do: icacls's SID-form /grant is already
332
+ # locale-proof, but its listing output has no display-as-SID switch, so
333
+ # its VERIFY half stays English-only. Preferring Get-Acl/Set-Acl over
334
+ # icacls here removes that limitation entirely instead of accepting it.
335
+ #
336
+ # Ownership target is Administrators, not SYSTEM: SetSecurityInfo can only
337
+ # set the owner to the caller's own token SID/owner-capable group, or to any
338
+ # SID at all when SeRestorePrivilege is enabled - an elevated Administrator's
339
+ # token does not carry NT AUTHORITY\SYSTEM and does not have
340
+ # SeRestorePrivilege enabled by default, so SetOwner(SYSTEM) from this script
341
+ # throws ERROR_INVALID_OWNER. Administrators is always reachable from an
342
+ # elevated admin token, and Test-AllowlistedAcl already accepts either
343
+ # principal as owner. The MSI's own hardening action (which DOES run as
344
+ # LocalSystem) will re-set ownership to SYSTEM on the next install with no
345
+ # conflict - both writers already agree on "hardened" independent of which of
346
+ # the two owns it.
347
+ # ---------------------------------------------------------------------------
348
+
349
+ $script:SystemSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
350
+ $script:AdministratorsSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
351
+ $script:CurrentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
352
+
353
+ function New-LockedDownSecurity {
354
+ param([switch]$IsContainer)
355
+ $security = if ($IsContainer) { New-Object System.Security.AccessControl.DirectorySecurity } else { New-Object System.Security.AccessControl.FileSecurity }
356
+ $security.SetOwner($script:AdministratorsSid)
357
+ $security.SetAccessRuleProtection($true, $false)
358
+ $inheritance = if ($IsContainer) {
359
+ [System.Security.AccessControl.InheritanceFlags]([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit)
360
+ } else {
361
+ [System.Security.AccessControl.InheritanceFlags]::None
362
+ }
363
+ foreach ($sid in @($script:SystemSid, $script:AdministratorsSid)) {
364
+ $rule = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList @(
365
+ $sid,
366
+ [System.Security.AccessControl.FileSystemRights]::FullControl,
367
+ $inheritance,
368
+ [System.Security.AccessControl.PropagationFlags]::None,
369
+ [System.Security.AccessControl.AccessControlType]::Allow
370
+ )
371
+ $security.AddAccessRule($rule)
372
+ }
373
+ return $security
374
+ }
375
+
376
+ # Owner + a protected DACL are written in ONE Set-Acl call (one security
377
+ # descriptor, one syscall) rather than icacls's separate /setowner then
378
+ # /grant:r invocations - there is no window between "ownership changes" and
379
+ # "the DACL changes" for a pre-creating owner to race, because there is
380
+ # nothing sequential to race between.
381
+ function Set-HardenedAcl {
382
+ param([Parameter(Mandatory)][string]$Path, [switch]$IsContainer)
383
+ $security = New-LockedDownSecurity -IsContainer:$IsContainer
384
+ Set-Acl -LiteralPath $Path -AclObject $security
385
+ }
386
+
387
+ # True when $Path's OWN final path component is a reparse point (junction or
388
+ # symlink). Deliberately queries $Path as a standalone path rather than as
389
+ # part of a longer one: an intermediate reparse-point component in a longer
390
+ # path is transparently resolved through by the filesystem before Get-Item
391
+ # ever sees it, so this only tells the truth when $Path itself is the last
392
+ # segment being resolved. Callers must check every directory level they
393
+ # create-or-trust individually, in order, top-down (see
394
+ # Set-PendingEnrollmentIfRequested) - there is no ResolveLinkTarget in
395
+ # PowerShell 5.1 to ask "what does this chain actually point at" in one call.
396
+ #
397
+ # FAILS CLOSED: Test-Path returns $false for a junction whose target is
398
+ # missing (a "dangling" junction), so a caller's own not-exists check lets
399
+ # New-Item run, and Get-Item -Force on that same dangling junction then
400
+ # raises ItemNotFoundException - which, under $ErrorActionPreference='Stop',
401
+ # would otherwise terminate the whole script (including the base MSI
402
+ # install this check has nothing to do with) rather than just refuse to
403
+ # harden one directory. Catching it and returning $true treats "cannot even
404
+ # confirm this is a normal directory" the same as "it's a reparse point" -
405
+ # refuse to proceed, exactly what a real reparse point would already do
406
+ # here, so this can never be the reason the install itself fails.
407
+ function Test-IsReparsePoint {
408
+ param([Parameter(Mandatory)][string]$Path)
409
+ try {
410
+ $info = Get-Item -LiteralPath $Path -Force
411
+ return [bool]($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
412
+ } catch {
413
+ return $true
414
+ }
415
+ }
416
+
417
+ function Test-AllowlistedAcl {
418
+ param([Parameter(Mandatory)][string]$Path)
419
+ $acl = Get-Acl -LiteralPath $Path
420
+ if (-not $acl.AreAccessRulesProtected) { return $false }
421
+ # Ownership grants WRITE_DAC implicitly, regardless of the DACL's own
422
+ # contents - an owner can always reopen a DACL that doesn't even mention
423
+ # them. Set-HardenedAcl sets owner+DACL in one call, but this check does not
424
+ # assume that call is atomic on every filesystem/Windows build; it verifies
425
+ # the CURRENT owner directly rather than trusting the prior write. Either
426
+ # SYSTEM or Administrators is an acceptable owner (see the note above
427
+ # New-LockedDownSecurity on why install.ps1 sets Administrators, not SYSTEM).
428
+ $ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier])
429
+ if ($ownerSid.Value -ne $script:SystemSid.Value -and $ownerSid.Value -ne $script:AdministratorsSid.Value) {
430
+ return $false
431
+ }
432
+ $rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
433
+ $sawSystem = $false
434
+ $sawAdministrators = $false
435
+ foreach ($rule in $rules) {
436
+ if ($rule.IsInherited) { return $false }
437
+ # Deny ACEs and partial-rights grants are not "hardened" just because the SID matches (mirrors config-dir-acl-repair.ts's isAllowlistedConfigDirAcl).
438
+ if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { return $false }
439
+ if ($rule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { return $false }
440
+ $sidValue = $rule.IdentityReference.Value
441
+ if ($sidValue -eq $script:SystemSid.Value) { $sawSystem = $true }
442
+ elseif ($sidValue -eq $script:AdministratorsSid.Value) { $sawAdministrators = $true }
443
+ else { return $false }
444
+ }
445
+ return $sawSystem -and $sawAdministrators
446
+ }
447
+
448
+ # Creates the %TEMP%-scoped Token/CloudUrl/Org handoff file WITH its final
449
+ # ACL applied ATOMICALLY - not create-then-Set-Acl. A separate create, then a
450
+ # later Set-Acl call, leaves a window where the file exists under whatever
451
+ # DACL %TEMP% happens to inherit; %TEMP% is a per-user env var the SAME
452
+ # caller could point somewhere looser than the default per-user profile temp
453
+ # dir (e.g. a shared C:\Temp), so another local user could read the
454
+ # plaintext token in that window. FileMode.CreateNew both makes the create
455
+ # atomic with the ACL and fails loudly if the GUID-named path were ever
456
+ # somehow already occupied, rather than silently overwriting something.
457
+ #
458
+ # EDITION-BRANCHED: the FileSecurity-accepting FileStream constructor is
459
+ # .NET-Framework-only. A later review found this the hard way - Windows
460
+ # PowerShell 5.1 (PSEdition 'Desktop') runs on .NET Framework, where it is
461
+ # the sole 7-parameter FileStream constructor; PowerShell 7 (PSEdition
462
+ # 'Core') runs on .NET Core, which REMOVES it entirely -
463
+ # [System.IO.FileStream].GetConstructors() there yields zero 7-arg
464
+ # candidates, confirmed empirically under pwsh 7.6.5, where the unconditional
465
+ # call this function used to make fails with "Cannot find an overload for
466
+ # FileStream and the argument count: 7". A PS7 user with enrollment env vars
467
+ # set would hit that on every install. .NET Core's atomic-create-with-ACL
468
+ # equivalent is [System.IO.FileSystemAclExtensions]::Create (an extension
469
+ # method on FileInfo, not a constructor) - same atomicity guarantee, same
470
+ # argument shape, different type to call it through.
471
+ #
472
+ # A NARROWER threat model than Test-AllowlistedAcl's config-dir allowlist:
473
+ # this file only needs to resist OTHER non-admin local users, so it
474
+ # explicitly grants the CURRENT user full control too - the parent process
475
+ # (not elevated) must retain delete rights for its own cleanup. Owner is set
476
+ # to the current user (a no-op in practice, since NTFS already assigns the
477
+ # creator as owner - no SeRestorePrivilege question here, unlike
478
+ # New-LockedDownSecurity's SYSTEM/Administrators case).
479
+ function New-ProtectedHandoffFile {
480
+ param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Content)
481
+ $security = New-Object System.Security.AccessControl.FileSecurity
482
+ $security.SetOwner($script:CurrentUserSid)
483
+ $security.SetAccessRuleProtection($true, $false)
484
+ foreach ($sid in @($script:CurrentUserSid, $script:SystemSid, $script:AdministratorsSid)) {
485
+ $rule = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList @(
486
+ $sid,
487
+ [System.Security.AccessControl.FileSystemRights]::FullControl,
488
+ [System.Security.AccessControl.InheritanceFlags]::None,
489
+ [System.Security.AccessControl.PropagationFlags]::None,
490
+ [System.Security.AccessControl.AccessControlType]::Allow
491
+ )
492
+ $security.AddAccessRule($rule)
493
+ }
494
+ $bytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes($Content)
495
+ if ($PSVersionTable.PSEdition -eq 'Core') {
496
+ $fileInfo = New-Object System.IO.FileInfo($Path)
497
+ $stream = [System.IO.FileSystemAclExtensions]::Create(
498
+ $fileInfo,
499
+ [System.IO.FileMode]::CreateNew,
500
+ [System.Security.AccessControl.FileSystemRights]::Write,
501
+ [System.IO.FileShare]::None,
502
+ 4096,
503
+ [System.IO.FileOptions]::None,
504
+ $security
505
+ )
506
+ } else {
507
+ $stream = New-Object -TypeName System.IO.FileStream -ArgumentList @(
508
+ $Path,
509
+ [System.IO.FileMode]::CreateNew,
510
+ [System.Security.AccessControl.FileSystemRights]::Write,
511
+ [System.IO.FileShare]::None,
512
+ 4096,
513
+ [System.IO.FileOptions]::None,
514
+ $security
515
+ )
516
+ }
517
+ try {
518
+ $stream.Write($bytes, 0, $bytes.Length)
519
+ } finally {
520
+ $stream.Dispose()
521
+ }
522
+ }
523
+
524
+ function Set-PendingEnrollmentIfRequested {
525
+ param([string]$Token, [string]$CloudUrl, [string]$Org)
526
+ if (-not $Token -or -not $CloudUrl) {
527
+ Write-Info 'No enrollment token/cloud URL provided - skipping pending-enrollment.json (sign in from the tray, or use the registry/MDM channel).'
528
+ return
529
+ }
530
+
531
+ # Any local user can create a subdirectory under C:\ProgramData, including a
532
+ # directory junction (`mklink /J`, no privilege required) pointing anywhere
533
+ # - and this applies to EVERY level we create or trust, not just the config
534
+ # leaf: C:\ProgramData\AgenShield itself does not exist on a first install,
535
+ # so a non-admin can pre-plant it as a junction (e.g. at C:\Windows\System32)
536
+ # before this script ever runs. Checking only the leaf misses this: querying
537
+ # attributes on a path where the suspect component is an INTERMEDIATE
538
+ # segment (not the path's own final component) transparently resolves
539
+ # through it at the filesystem level - Get-Item on
540
+ # "...\AgenShield\config" would report the TARGET's attributes, not
541
+ # AgenShield's own, so the leaf looks like an ordinary directory even
542
+ # though it is a junction one level up. PowerShell 5.1 has no
543
+ # ResolveLinkTarget/.ResolvedTarget to ask "what does this actually point
544
+ # at", so the only correct check is per-component: verify EACH directory we
545
+ # are about to create-or-trust while it is still the FINAL component of the
546
+ # path being queried, before ever constructing a longer path through it.
547
+ #
548
+ # NOT ATOMIC: this is a check, not a lock. Between this Test-IsReparsePoint
549
+ # call returning "safe" and Set-HardenedAcl/WriteAllText actually touching
550
+ # the path a few lines below, a sufficiently fast attacker could still
551
+ # delete the real directory and drop a junction in its place - a
552
+ # create-check-use race PowerShell 5.1 cannot close without P/Invoking a
553
+ # handle-based open (e.g. CreateFile with FILE_FLAG_OPEN_REPARSE_POINT
554
+ # held across the whole operation). This walk closes the window the round-2
555
+ # review named (the PARENT directory not being checked at all) - it is a
556
+ # real narrowing of the attack surface, not proof the surface is zero.
557
+ $agenShieldRoot = Join-Path $env:ProgramData 'AgenShield'
558
+ if (-not (Test-Path -LiteralPath $agenShieldRoot)) {
559
+ New-Item -ItemType Directory -Path $agenShieldRoot -Force | Out-Null
560
+ }
561
+ if (Test-IsReparsePoint -Path $agenShieldRoot) {
562
+ Write-WarnLine "$agenShieldRoot is a reparse point (junction/symlink) - refusing to harden or write under it. Remove it and re-run, or use the registry/MDM channel instead."
563
+ return
564
+ }
565
+
566
+ # This root's OWNER is set to Administrators unconditionally in the elevated top-level block below - never gated on a token; see the WHY block there.
567
+ $configDir = Join-Path $agenShieldRoot 'config'
568
+ if (-not (Test-Path -LiteralPath $configDir)) {
569
+ New-Item -ItemType Directory -Path $configDir -Force | Out-Null
570
+ }
571
+ # Set-Acl and [System.IO.File]::WriteAllText both follow a junction
572
+ # transparently, so without this check (now that AgenShieldRoot is
573
+ # confirmed real) a non-admin who plants the junction AT THIS level
574
+ # instead could redirect this ELEVATED process into rewriting an
575
+ # arbitrary directory's owner/DACL to SYSTEM+Administrators-only - a
576
+ # denial-of-service against the box via an elevated installer, not merely
577
+ # against this one directory.
578
+ if (Test-IsReparsePoint -Path $configDir) {
579
+ Write-WarnLine "$configDir is a reparse point (junction/symlink) - refusing to harden or write into it. Remove it and re-run, or use the registry/MDM channel instead."
580
+ return
581
+ }
582
+
583
+ try {
584
+ Set-HardenedAcl -Path $configDir -IsContainer
585
+ } catch {
586
+ Write-WarnLine "Failed to harden $configDir : $($_.Exception.Message)"
587
+ }
588
+
589
+ if (-not (Test-AllowlistedAcl -Path $configDir)) {
590
+ # Not necessarily a locale problem (unlike the MSI's icacls-based reader,
591
+ # this check is SID-based and locale-independent) - most often means
592
+ # hardening above failed (e.g. a privilege issue) or the directory is
593
+ # owned/ACL'd by something other than SYSTEM/Administrators.
594
+ Write-WarnLine "$configDir is not verified SYSTEM+Administrators-only - skipping pending-enrollment.json write. Enroll via the registry/MDM channel instead."
595
+ return
596
+ }
597
+
598
+ $payload = [ordered]@{ token = $Token; cloudUrl = $CloudUrl }
599
+ if ($Org) { $payload['org'] = $Org }
600
+ # ConvertTo-Json handles escaping; the write below uses a BOM-less UTF8
601
+ # encoder explicitly - PowerShell 5.1's -Encoding UTF8 on Set-Content/
602
+ # Out-File emits a UTF-8 BOM, which breaks the reader's plain JSON.parse()
603
+ # (libs/cloud/src/pending-enrollment.ts) with no error, just silent
604
+ # non-enrollment.
605
+ $json = $payload | ConvertTo-Json -Compress
606
+
607
+ $pendingPath = Join-Path $configDir 'pending-enrollment.json'
608
+ if (Test-Path -LiteralPath $pendingPath) {
609
+ Remove-Item -LiteralPath $pendingPath -Force
610
+ }
611
+ [System.IO.File]::WriteAllText($pendingPath, $json, (New-Object System.Text.UTF8Encoding($false)))
612
+
613
+ try {
614
+ Set-HardenedAcl -Path $pendingPath
615
+ } catch {
616
+ Remove-Item -LiteralPath $pendingPath -Force -ErrorAction SilentlyContinue
617
+ Write-WarnLine "Failed to lock down pending-enrollment.json - deleted it rather than leave a bearer token under an unverified ACL: $($_.Exception.Message)"
618
+ return
619
+ }
620
+
621
+ if (-not (Test-AllowlistedAcl -Path $pendingPath)) {
622
+ Remove-Item -LiteralPath $pendingPath -Force -ErrorAction SilentlyContinue
623
+ Write-WarnLine 'pending-enrollment.json ACL did not verify after hardening - deleted it rather than leave a bearer token under an unverified ACL.'
624
+ return
625
+ }
626
+
627
+ Write-Ok 'Wrote pending-enrollment.json'
628
+ }
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # Install (lifted from tools/build/windows/install-agenshield.ps1, which
632
+ # already avoids Win32_Product - it triggers a repair on every enumeration -
633
+ # and already handles the tray holding resources\app.asar open)
634
+ # ---------------------------------------------------------------------------
635
+
636
+ function Get-InstalledProductCode {
637
+ param([string]$DisplayName = 'AgenShield')
638
+ $roots = @(
639
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
640
+ 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
641
+ )
642
+ foreach ($root in $roots) {
643
+ $found = Get-ChildItem $root -ErrorAction SilentlyContinue | ForEach-Object {
644
+ $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
645
+ if ($p.DisplayName -eq $DisplayName -and $_.PSChildName -match '^\{[0-9A-Fa-f-]+\}$') {
646
+ $_.PSChildName
647
+ }
648
+ } | Select-Object -First 1
649
+ if ($found) { return $found }
650
+ }
651
+ return $null
652
+ }
653
+
654
+ function Stop-AgenShieldTray {
655
+ Get-Process AgenShield -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
656
+ Start-Sleep -Seconds 1
657
+ }
658
+
659
+ function Uninstall-AgenShield {
660
+ $code = Get-InstalledProductCode
661
+ if (-not $code) { return }
662
+ Write-Info "Removing the existing AgenShield installation ($code)..."
663
+ $msiexecPath = Get-System32Path 'msiexec.exe'
664
+ $uninstallLog = Join-Path $script:LogDir "install-$RunId-uninstall.log"
665
+ $proc = Start-Process -FilePath $msiexecPath -ArgumentList "/x $code /qn /norestart /l*v `"$uninstallLog`"" -Wait -PassThru
666
+ if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) {
667
+ Write-WarnLine "Uninstalling the existing AgenShield installation exited with code $($proc.ExitCode) - continuing with install."
668
+ }
669
+ }
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # Health gate
673
+ # ---------------------------------------------------------------------------
674
+
675
+ function Wait-DaemonHealthy {
676
+ param([int]$Attempts = 5, [int]$DelaySeconds = 2)
677
+ $lastError = $null
678
+ for ($i = 1; $i -le $Attempts; $i++) {
679
+ try {
680
+ $resp = Invoke-WebRequest -Uri 'http://127.0.0.1:5200/api/health' -UseBasicParsing -TimeoutSec 5
681
+ if ($resp.StatusCode -eq 200) { return $true }
682
+ } catch {
683
+ $lastError = $_.Exception.Message
684
+ }
685
+ if ($i -lt $Attempts) { Start-Sleep -Seconds $DelaySeconds }
686
+ }
687
+ if ($lastError) { Write-WarnLine "Last health-check error: $lastError" }
688
+ return $false
689
+ }
690
+
691
+ # Bounded poll of GET /api/status for data.cloudEnrolled - the same
692
+ # unauthenticated call libs/cli/src/commands/start.ts makes. Never blocks
693
+ # indefinitely: on expiry the daemon keeps retrying enrollment in the
694
+ # background on its own, same as install.sh's non-blocking posture.
695
+ function Wait-EnrollmentComplete {
696
+ param([int]$Attempts = 30, [int]$DelaySeconds = 2)
697
+ $lastError = $null
698
+ for ($i = 1; $i -le $Attempts; $i++) {
699
+ try {
700
+ $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:5200/api/status' -TimeoutSec 5
701
+ if ($resp.data.cloudEnrolled -eq $true) { return $true }
702
+ } catch {
703
+ $lastError = $_.Exception.Message
704
+ }
705
+ Start-Sleep -Seconds $DelaySeconds
706
+ }
707
+ if ($lastError) { Write-WarnLine "Last enrollment-status error: $lastError" }
708
+ return $false
709
+ }
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Elevation gate - relaunch elevated if needed, then wait and tail
713
+ # ---------------------------------------------------------------------------
714
+
715
+ if (-not (Test-IsAdministrator)) {
716
+ # $ElevatedChild is set ONLY on the relaunch this same gate below performs
717
+ # - so a non-elevated process reaching this point with it already set
718
+ # means the -Verb RunAs relaunch completed but the resulting process is
719
+ # STILL not elevated (an unusual token/policy edge case, not the normal
720
+ # path). Without this guard, that child would relaunch itself AGAIN,
721
+ # raising another UAC prompt, forever - refuse instead of looping.
722
+ if ($ElevatedChild) {
723
+ Write-ErrorLine 'Elevation did not succeed - this process still lacks Administrator rights after a self-relaunch. Refusing to relaunch again (that would prompt UAC indefinitely).'
724
+ exit 1
725
+ }
726
+
727
+ Write-Step 'AgenShield - Install'
728
+ Write-Info 'Administrator privileges are required - requesting elevation...'
729
+
730
+ # Resolved via Get-System32Path (GetSystemDirectory(), not $env:SystemRoot):
731
+ # Start-Process/ShellExecute can search the CURRENT WORKING DIRECTORY before
732
+ # PATH, and this script is meant to be run from wherever a user downloaded
733
+ # it - the same binary-planting class this codebase already fixed for
734
+ # reg.exe/wmic/icacls on the daemon/CA side, closed here without depending
735
+ # on an environment variable the caller controls.
736
+ $powershellPath = Get-System32Path 'WindowsPowerShell\v1.0\powershell.exe'
737
+
738
+ $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (ConvertTo-QuotedArg $PSCommandPath), '-RunId', (ConvertTo-QuotedArg $RunId), '-ElevatedChild')
739
+ if ($Version) { $relaunchArgs += @('-Version', (ConvertTo-QuotedArg $Version)) }
740
+ if ($AssetsBase) { $relaunchArgs += @('-AssetsBase', (ConvertTo-QuotedArg $AssetsBase)) }
741
+ if ($Channel) { $relaunchArgs += @('-Channel', (ConvertTo-QuotedArg $Channel)) }
742
+ if ($ClientAlpha) { $relaunchArgs += '-ClientAlpha' }
743
+ if ($ClientBeta) { $relaunchArgs += '-ClientBeta' }
744
+
745
+ # Environment variables set only in THIS (non-elevated) process are not
746
+ # reliably inherited by a `-Verb RunAs` relaunch - UAC's elevation broker
747
+ # builds the elevated process's environment from the user's persisted
748
+ # profile, not from this process's in-memory block, so $env:AGENSHIELD_TOKEN
749
+ # set only here could silently vanish across the boundary. Passing the
750
+ # token as a plain -Token argv value instead would recreate the exact bug
751
+ # class a prior task fixed for the MSI custom action (a process command
752
+ # line is readable by any local user via Win32_Process). A short-lived
753
+ # handoff file under the CURRENT user's own %TEMP% carries the secrets
754
+ # instead; only its path ever crosses the boundary, and
755
+ # New-ProtectedHandoffFile creates it with its final ACL already applied -
756
+ # atomically, not create-then-Set-Acl - rather than trusting whatever
757
+ # %TEMP% happens to inherit even for the brief window before a separate
758
+ # Set-Acl call would land. Everything below is inside one try/finally so a
759
+ # Ctrl-C (or any other terminating error) during the potentially long
760
+ # -Wait still deletes the handoff file rather than leaving a plaintext
761
+ # bearer token on disk.
762
+ $handoffPath = $null
763
+ $exitCode = 1
764
+ try {
765
+ if ($Token -or $CloudUrl -or $Org) {
766
+ $handoffPath = Join-Path $env:TEMP ('agenshield-install-' + [guid]::NewGuid().ToString('N') + '.json')
767
+ $handoffPayload = [ordered]@{ token = $Token; cloudUrl = $CloudUrl; org = $Org }
768
+ New-ProtectedHandoffFile -Path $handoffPath -Content ($handoffPayload | ConvertTo-Json -Compress)
769
+ $relaunchArgs += @('-HandoffFile', (ConvertTo-QuotedArg $handoffPath))
770
+ }
771
+
772
+ try {
773
+ $proc = Start-Process -FilePath $powershellPath -Verb RunAs -Wait -PassThru -ArgumentList ($relaunchArgs -join ' ')
774
+ # -Verb RunAs -PassThru populating .ExitCode is undocumented behavior;
775
+ # guard BOTH $proc itself and $proc.ExitCode being $null - `exit $null`
776
+ # exits 0, which would report a failed elevated install as success to
777
+ # an MDM wrapper watching this script's own exit code.
778
+ $exitCode = if ($null -eq $proc -or $null -eq $proc.ExitCode) { 1 } else { $proc.ExitCode }
779
+ } catch {
780
+ Write-ErrorLine "Elevated relaunch failed: $($_.Exception.Message)"
781
+ $exitCode = 1
782
+ }
783
+ } finally {
784
+ if ($handoffPath -and (Test-Path -LiteralPath $handoffPath)) {
785
+ Remove-Item -LiteralPath $handoffPath -Force -ErrorAction SilentlyContinue
786
+ }
787
+ }
788
+
789
+ # The parent WAITED (above), THEN tails the elevated run's transcript - the
790
+ # analogue of macOS's `sudo -n tail -F /var/log/agenshield-install.log`,
791
+ # adapted for -Verb RunAs's synchronous semantics (there is no live
792
+ # concurrent tail across an elevation boundary the way a backgrounded sudo
793
+ # command allows) so output still lands back in the caller's own window.
794
+ if (Test-Path -LiteralPath $script:TranscriptPath) {
795
+ Get-Content -LiteralPath $script:TranscriptPath | ForEach-Object { Write-Host " | $_" }
796
+ }
797
+ exit $exitCode
798
+ }
799
+
800
+ if ($HandoffFile -and (Test-Path -LiteralPath $HandoffFile)) {
801
+ # A truncated or tampered handoff file must not abort the base install (same posture as the enrollment-staging catch below).
802
+ try {
803
+ $handoff = Get-Content -LiteralPath $HandoffFile -Raw | ConvertFrom-Json
804
+ if ($handoff.token) { $Token = $handoff.token }
805
+ if ($handoff.cloudUrl) { $CloudUrl = $handoff.cloudUrl }
806
+ if ($handoff.org) { $Org = $handoff.org }
807
+ } catch {
808
+ Write-WarnLine "Failed to read the enrollment handoff file - continuing without it: $($_.Exception.Message)"
809
+ } finally {
810
+ Remove-Item -LiteralPath $HandoffFile -Force -ErrorAction SilentlyContinue
811
+ }
812
+ }
813
+
814
+ # Validated BEFORE this (elevated) process writes ANYTHING under
815
+ # %ProgramData%\AgenShield - Set-PendingEnrollmentIfRequested's own
816
+ # reparse-point check runs much later in Main, well after $script:LogDir
817
+ # would already have been created here. A non-admin junction planted at
818
+ # %ProgramData%\AgenShield (the round-2 I6 finding) would otherwise redirect
819
+ # this New-Item/Start-Transcript pair into the junction's target first.
820
+ # Bounded blast radius for that pair even so - a directory create and a .log
821
+ # write, and the new directory inherits the TARGET's own DACL rather than
822
+ # gaining anything - but validated regardless, so no elevated write of any
823
+ # kind precedes it. The stakes are higher now than when this check was added:
824
+ # the ownership Set-Acl below would rewrite the junction TARGET's owner, so it
825
+ # lives inside the validated branch and never runs on a reparse point. Skips
826
+ # the transcript and the ownership fix (falling back to console-only output)
827
+ # rather than aborting the install if the root is compromised, matching
828
+ # Start-Transcript's own already-non-fatal failure handling below.
829
+ $agenShieldRoot = Join-Path $env:ProgramData 'AgenShield'
830
+ if (-not (Test-Path -LiteralPath $agenShieldRoot)) {
831
+ New-Item -ItemType Directory -Path $agenShieldRoot -Force | Out-Null
832
+ }
833
+ if (Test-IsReparsePoint -Path $agenShieldRoot) {
834
+ Write-WarnLine "$agenShieldRoot is a reparse point (junction/symlink) - skipping the install transcript to avoid writing under it."
835
+ } else {
836
+ New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null
837
+ try {
838
+ Start-Transcript -Path $script:TranscriptPath -Force | Out-Null
839
+ } catch {
840
+ Write-WarnLine "Could not start the install transcript: $($_.Exception.Message)"
841
+ }
842
+
843
+ # OWNERSHIP ONLY - never the leaf's restrictive protected DACL, and
844
+ # deliberately OUT here rather than inside Set-PendingEnrollmentIfRequested.
845
+ # On stock Windows, "System objects: Default owner for objects created by
846
+ # members of the Administrators group" defaults to Object Creator, so the
847
+ # New-Item above (running under an elevated ADMINISTRATOR token, not SYSTEM)
848
+ # leaves this root owned by that admin's own user SID - neither SYSTEM nor
849
+ # the Administrators GROUP. The daemon's own boot-time ACL repair
850
+ # (libs/shield-daemon) gates its write on the CURRENT OWNER of BOTH this
851
+ # root AND the config leaf being SYSTEM or Administrators (a non-admin who
852
+ # pre-creates the root keeps FILE_DELETE_CHILD via the default CREATOR OWNER
853
+ # ACE and could delete-and-replace even a hardened leaf otherwise) - so
854
+ # leaving this root owned by an arbitrary admin user permanently refuses that
855
+ # repair with harden-refused-owner on every boot, even though this script
856
+ # itself never hit an error. This block previously lived inside
857
+ # Set-PendingEnrollmentIfRequested, which returns early when no enrollment
858
+ # token was supplied - so the plain no-token one-liner install (the very
859
+ # install the public docs describe) never reached it, while the create above
860
+ # runs on EVERY elevated run and always wins the race to create the root.
861
+ # That left the backstop permanently disabled on exactly the WDAC/AppLocker
862
+ # estates it exists for, where the MSI's own hardening action is blocked and
863
+ # silently ignored. It therefore runs here: unconditionally, before Main, and
864
+ # so still ahead of the $configDir create/harden in
865
+ # Set-PendingEnrollmentIfRequested, leaving no window where an attacker-owned
866
+ # root contains an already-hardened child. Placed just after Start-Transcript
867
+ # so a failure is captured in the transcript the non-elevated parent tails
868
+ # rather than vanishing with the elevated child's console. Set OWNER only,
869
+ # preserving the current DACL exactly as read: other AgenShield components
870
+ # legitimately live under this root (agent homes, and the desktop's
871
+ # non-elevated upgrade-log reader under logs\), so applying the leaf's own
872
+ # restrictive DACL here would break them.
873
+ try {
874
+ $rootAcl = Get-Acl -LiteralPath $agenShieldRoot
875
+ $rootAcl.SetOwner($script:AdministratorsSid)
876
+ Set-Acl -LiteralPath $agenShieldRoot -AclObject $rootAcl
877
+ } catch {
878
+ Write-WarnLine "Failed to set ownership of $agenShieldRoot to Administrators: $($_.Exception.Message)"
879
+ }
880
+ }
881
+
882
+ # ---------------------------------------------------------------------------
883
+ # Main
884
+ # ---------------------------------------------------------------------------
885
+
886
+ $script:ExitCode = 0
887
+ try {
888
+ Write-Step 'AgenShield - Install'
889
+
890
+ if ($Org -and -not $CloudUrl) {
891
+ throw 'AGENSHIELD_ORG requires AGENSHIELD_CLOUD_URL to be specified.'
892
+ }
893
+
894
+ # ARM64 Windows and a 32-bit process report x86-ish values in
895
+ # PROCESSOR_ARCHITECTURE; PROCESSOR_ARCHITEW6432 carries the TRUE host
896
+ # architecture whenever the current process is running under WOW64, so
897
+ # checking it first is what makes this WOW64-proof rather than trusting
898
+ # whichever powershell.exe happened to be invoked.
899
+ $trueArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
900
+ if ($trueArch -ne 'AMD64') {
901
+ throw "AgenShield for Windows currently supports x64 only - detected '$trueArch'. ARM64/x86 Windows is not yet supported."
902
+ }
903
+ Write-Ok 'Architecture: x64'
904
+
905
+ # The baked CDN base MUST outrank -AssetsBase/AGENSHIELD_ASSETS_BASE - an
906
+ # env override that wins over the guard it's an exception to is not a
907
+ # guard. The override is honored only when the baked placeholder was never
908
+ # substituted (an unbaked checkout); DefaultCdnBaseUrl is the final
909
+ # fallback for that same unbaked case when no override is given either.
910
+ $effectiveAssetsBase = if ($script:CdnBaseUrl) { $script:CdnBaseUrl } elseif ($AssetsBase) { $AssetsBase.TrimEnd('/') } else { $script:DefaultCdnBaseUrl }
911
+ # Named distinctly from the $Channel PARAMETER on purpose - PowerShell
912
+ # variable names are case-insensitive, so a local $channel here would be
913
+ # the SAME variable as $Channel and silently overwrite the caller's raw
914
+ # input with the resolved value. Harmless today (nothing reads $Channel
915
+ # again afterward), but a trap for the next edit that assumes otherwise.
916
+ $channelName = Resolve-Channel
917
+ Write-Info "Resolving version (channel: $channelName)..."
918
+ $manifest = Invoke-RestMethod -Uri "$effectiveAssetsBase/versions.json" -TimeoutSec 30
919
+ $entry = Resolve-InstallerVersionEntry -Manifest $manifest -PinnedVersion $Version -Channel $channelName
920
+ $installer = Get-Win32InstallerDescriptor $entry
921
+ $resolvedVersion = Get-ManifestProperty $entry 'version'
922
+ $entryPath = Get-ManifestProperty $entry 'path'
923
+ if (-not $entryPath) { $entryPath = "v$resolvedVersion" }
924
+ $installerName = Get-ManifestProperty $installer 'name'
925
+ if (-not $installerName) { $installerName = "AgenShield-$resolvedVersion-win32-x64.msi" }
926
+ $expectedSha256 = Get-ManifestProperty $installer 'sha256'
927
+
928
+ # Defense in depth: $entryPath/$installerName came from the release manifest
929
+ # and are about to become a URL segment, a local file path, and part of a
930
+ # quoted msiexec command line. A well-formed manifest never produces
931
+ # anything outside this charset, so rejecting anything else costs nothing
932
+ # and closes off quote/backslash-based argument injection if the manifest
933
+ # itself were ever the compromised link in the chain. ".." is rejected
934
+ # explicitly - the charset alone allows it (dots are legal in a version
935
+ # string), and a path-traversal segment is exactly the shape a directory
936
+ # escape needs.
937
+ if ($entryPath -notmatch '^[A-Za-z0-9._-]+$' -or $entryPath.Contains('..')) {
938
+ throw "Refusing to use an unsafe release path from the manifest: '$entryPath'"
939
+ }
940
+ if ($installerName -notmatch '^[A-Za-z0-9._-]+\.msi$' -or $installerName.Contains('..')) {
941
+ throw "Refusing to use an unsafe installer filename from the manifest: '$installerName'"
942
+ }
943
+ Write-Ok "Resolved version $resolvedVersion"
944
+
945
+ $downloadUrl = "$effectiveAssetsBase/$entryPath/$installerName"
946
+ $tempDir = Join-Path $env:TEMP ('agenshield-install-' + [guid]::NewGuid().ToString('N'))
947
+ New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
948
+ $msiPath = Join-Path $tempDir $installerName
949
+
950
+ try {
951
+ Write-Step "Downloading AgenShield $resolvedVersion"
952
+ Invoke-DownloadWithRetry -Uri $downloadUrl -OutFile $msiPath
953
+ Write-Ok "Downloaded $installerName"
954
+
955
+ # An ABSENT sha256 is a hard gate too, not just a mismatch: it is
956
+ # attacker-selectable (anyone who can serve versions.json simply omits
957
+ # the key), and cdn-update-manifests.sh refuses to publish an asset that
958
+ # is not in checksums.sha256 - a well-formed manifest always carries it,
959
+ # so leniency here protects nothing real. Windows has no Developer-ID-style
960
+ # backstop the way macOS's installer(8) does, and $RequireSignature is
961
+ # $false, so this is the only integrity check that is guaranteed to run.
962
+ if (-not $expectedSha256) {
963
+ throw "No sha256 listed for $installerName in the release manifest - refusing to install an unverified artifact."
964
+ }
965
+ $actualSha256 = (Get-FileHash -LiteralPath $msiPath -Algorithm SHA256).Hash.ToLowerInvariant()
966
+ if ($actualSha256 -ne $expectedSha256.ToLowerInvariant()) {
967
+ throw "Checksum mismatch for $installerName (expected $expectedSha256, got $actualSha256)."
968
+ }
969
+ Write-Ok 'Checksum verified'
970
+
971
+ Test-InstallerSignature -Path $msiPath
972
+
973
+ Write-Step 'Staging enrollment'
974
+ # Enrollment plumbing must never be the reason the base install aborts
975
+ # (the header's own "never block the base install on enrollment
976
+ # plumbing" posture) - belt-and-suspenders alongside Test-IsReparsePoint
977
+ # now failing closed instead of throwing: anything else unexpected in
978
+ # here (permissions, a locked file, ...) degrades to a warning, not a
979
+ # skipped MSI install.
980
+ try {
981
+ Set-PendingEnrollmentIfRequested -Token $Token -CloudUrl $CloudUrl -Org $Org
982
+ } catch {
983
+ Write-WarnLine "Enrollment staging failed - continuing with the base install: $($_.Exception.Message)"
984
+ }
985
+
986
+ Write-Step "Installing AgenShield $resolvedVersion"
987
+ Stop-AgenShieldTray
988
+ Uninstall-AgenShield
989
+ $msiexecPath = Get-System32Path 'msiexec.exe'
990
+ $msiLogPath = Join-Path $script:LogDir "install-$RunId-msiexec.log"
991
+ $installProc = Start-Process -FilePath $msiexecPath -ArgumentList "/i `"$msiPath`" /qn /norestart /l*v `"$msiLogPath`"" -Wait -PassThru
992
+ if ($installProc.ExitCode -eq 0) {
993
+ Write-Ok "Installed AgenShield $resolvedVersion"
994
+ } elseif ($installProc.ExitCode -eq 3010) {
995
+ Write-Ok "Installed AgenShield $resolvedVersion (a reboot is required to finish)"
996
+ } else {
997
+ Write-ErrorLine "msiexec failed (exit $($installProc.ExitCode)) - last 25 lines of $msiLogPath`:"
998
+ if (Test-Path -LiteralPath $msiLogPath) {
999
+ Get-Content -LiteralPath $msiLogPath -Tail 25 | ForEach-Object { Write-Host " | $_" }
1000
+ }
1001
+ throw "AgenShield installation failed (msiexec exit $($installProc.ExitCode))."
1002
+ }
1003
+ } finally {
1004
+ Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
1005
+ }
1006
+
1007
+ Write-Step 'Waiting for the AgenShield service'
1008
+ if (Wait-DaemonHealthy) {
1009
+ Write-Ok 'Daemon is running'
1010
+ if ($Token -and $CloudUrl) {
1011
+ Write-Info 'Waiting for enrollment to complete...'
1012
+ if (Wait-EnrollmentComplete) {
1013
+ Write-Ok 'Device enrolled'
1014
+ } else {
1015
+ Write-WarnLine 'Enrollment did not complete in time - the service will keep retrying in the background.'
1016
+ }
1017
+ }
1018
+ } else {
1019
+ Write-WarnLine 'The AgenShield service did not become healthy in time - it may still be starting.'
1020
+ }
1021
+
1022
+ # No sign-in: installation must not require or trigger an OAuth browser
1023
+ # flow. The device is enrolled (campaign token) or waits to be, and a user
1024
+ # signs in LATER from the tray to attach their account - same rule as
1025
+ # macOS's install.sh.
1026
+ Write-Host ''
1027
+ Write-Host 'AgenShield installed.' -ForegroundColor Green
1028
+ Write-Host ' Dashboard: http://localhost:5200'
1029
+ Write-Host ' Sign in any time from the AgenShield tray to link your account.'
1030
+ Write-Host ''
1031
+ } catch {
1032
+ Write-ErrorLine $_.Exception.Message
1033
+ $script:ExitCode = 1
1034
+ } finally {
1035
+ try { Stop-Transcript | Out-Null } catch { }
1036
+ }
1037
+ exit $script:ExitCode
package/bin/install.sh CHANGED
@@ -4,8 +4,10 @@
4
4
  # Downloads and installs the AgenShield SEA binaries for the current platform.
5
5
  #
6
6
  # Mirror set: the version-resolution + download logic here is deliberately
7
- # kept in sync with libs/cli/src/utils/github-releases.ts (CLI) and
8
- # tools/build/pkg/bootstrap/scripts/postinstall (MDM bootstrap pkg).
7
+ # kept in sync with libs/cli/src/utils/github-releases.ts (CLI),
8
+ # tools/build/pkg/bootstrap/scripts/postinstall (MDM bootstrap pkg), and
9
+ # tools/sea/install.ps1 (the Windows sibling — same environment contract and
10
+ # baked CDN supply-chain guard, CDN-only with no GitHub-Releases fallback).
9
11
  #
10
12
  # Multi-binary layout:
11
13
  # ~/.agenshield/bin/agenshield (CLI — on PATH)
@@ -620,11 +622,22 @@ TAILEOF
620
622
  AGENSHIELD_CLI="/usr/local/bin/agenshield"
621
623
  command -v "$AGENSHIELD_CLI" >/dev/null 2>&1 || AGENSHIELD_CLI="agenshield"
622
624
  # Reattach the controlling terminal as stdin so the CLI's readline +
623
- # isTTY work even though our own stdin is the curl pipe. An older
624
- # pinned CLI without --post-install falls back to plain `activate`.
625
- "$AGENSHIELD_CLI" activate --post-install </dev/tty || \
625
+ # isTTY work even though our own stdin is the curl pipe.
626
+ #
627
+ # The fallback is chosen by CAPABILITY, not by failure. It used to be
628
+ # `activate --post-install || activate || warn`, which re-ran the ENTIRE
629
+ # stepper on any non-zero exit — including Ctrl-C, so interrupting the
630
+ # approvals restarted them and the only way out was to interrupt twice.
631
+ # Worse, the retry dropped --post-install, which is what opens the app
632
+ # and the dashboard once approvals finish: an interrupted install ended
633
+ # with no window at all. Only an older pinned CLI needs the plain form.
634
+ if "$AGENSHIELD_CLI" activate --help 2>/dev/null | grep -q -- '--post-install'; then
635
+ "$AGENSHIELD_CLI" activate --post-install </dev/tty || \
636
+ warn "You can finish approvals any time with: agenshield activate"
637
+ else
626
638
  "$AGENSHIELD_CLI" activate </dev/tty || \
627
- warn "You can finish approvals any time with: agenshield activate"
639
+ warn "You can finish approvals any time with: agenshield activate"
640
+ fi
628
641
  exit 0
629
642
  else
630
643
  # Non-interactive (MDM/scripted): use command-line installer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenshield",
3
- "version": "2026.8.2-beta.5083739",
3
+ "version": "2026.8.2",
4
4
  "description": "AgenShield — AI Agent Security Platform",
5
5
  "bin": {
6
6
  "agenshield": "bin/agenshield"