The automated security review of the previous commit found that a publisher pin alone
would run any validly signed script of ours placed at the copy's path, such as an older
loader without the held handle. The build now bakes the SHA-256 of the signed loader into
the action (wix build -d LoaderSha256=...; the package refuses to build without it), and
the action compares the bytes it read against it before the signature check.
- Package.wxs: hash check, preprocessor guard, comment
- build-msi.yml: the build step hashes the signed loader and passes it to wix
- Bootstrap.Integration.Tests.ps1: the harness substitutes the hash of the loader it serves;
a copy that is not the embedded loader, however validly signed, runs nothing
- BootstrapContractTests: the hash condition, the guard and the build step are pinned
- internal: D-384, footprint doc and plan updated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a
customer found the file on every device. Everything of ours lives under ProgramData.
- Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the
deferred SYSTEM action, started in System32, reads the copy once, checks our signature
and the publisher pin on those bytes and runs those bytes from memory, so a file a user
planted at that path leads to nothing and no handle keeps the installer from removing
the copy. Every bracket and brace is escaped for the Windows Installer formatter.
- Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the
agent-present branch (install and skip clear the base already) and an agent folder that
is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files
in every outcome while devices still enroll with 1.3.
- Tests: installer unit tests for both functions; the loader's MSI contract; the harness
asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action
read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only,
agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the
directory chain, the publisher pin, the escapes and the working directory.
- build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment
marker set and expects nothing under ProgramData or Program Files, then uninstalls.
- internal: D-384, footprint doc, plan, lessons, backlog.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The check from d4e67cdb now runs only for tenants that enable it, like the
RealmJoin Watcher: TenantConfiguration.EnableConfigMgrWatcher feeds
AnalyzerConfiguration.EnableConfigMgrWatcher (default off, ConfigVersion
42). With the setting off, DefaultComponentFactory does not create the
ConfigMgrClientHost, so other tenants' devices run no check at all.
- Portal: toggle "ConfigMgr Watcher" in Agent Settings, section field map,
tenant config report.
- Privacy page: the check is named as optional.
- Shared manifest and the web and MCP wire types regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The V1 agent reported whether the Configuration Manager client is
installed; the check was lost when the V2 agent replaced the V1 enrollment
tracker. ConfigMgrClientProbe reads what V1 read (the CCM key, CCMSetup
LastValidVersion, the assigned site code, the %windir%\CCM folder, the
CcmExec service state) and keeps the V1 confidence score.
ConfigMgrClientDetector, polled by ConfigMgrClientHost every minute, emits
configmgr_client_detected with the V1 payload when the client is first seen
and again when its reported state changes, at most 10 times per run; a
restart into the same state stays silent.
- Session Info shows the latest detection; the V1 sentence about disturbed
event sequences is gone.
- Privacy page: device context names the Configuration Manager client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ConfigMgr client installed as a Win32 app registers its device-preparation
policy provider after its own install, with InstallationState=1, and nothing
completes it. The tripwire then reported a stall long after device
preparation had ended.
- EspPhaseSnapshot.DevicePreparationOpen decides whether device preparation
can still hold the ESP (it runs or failed, or the phase is unknown).
EvaluateStallTransition takes it as a parameter; the detector reads the
phase through an injectable reader.
- The cross-restart fingerprint applies the same filter, so a restart does
not report the same stall again.
- The message names the wait: Setup/Apps providers hold the ESP without any
timeout, device-preparation providers until their timeout.
- The diagnosis names a stalled provider only for a failure in its ESP
category; the waiting list of an unfinished session stays unfiltered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security finding (CWE-863): the SignalR user id was the bare UPN and every revoke cut that UPN across the
hub. A tenant admin could add any UPN (a Global Admin, another customer's operator) and remove, disable or
demote it in a loop, restarting that person's live connections in every tenant.
- Negotiate binds the user id to the sign-in tenant plus principal key from the validated request context
(SignalRGroupHelper.UserId); a caller without a principal key negotiates without a user id, since every
revocable join gate needs the key.
- ISignalRNotificationService.DisconnectUserAsync takes the tenant and the key. LiveStreamRevoker is its
only caller and names the tenant each grant resolved under: member routes the route's tenant,
self-service routes the managing tenant, Global Admin routes the tenant of the UPN's identity binding.
Replacing or deleting a binding now cuts the streams signed in under the previous one.
- A self-service assignment confers scope only while its assignee is bound to the owning tenant, so a
self-service grant resolves under the owner alone and the owner's revokes never need another tenant.
- Remove, disable and enable of a UPN without a row in the tenant answer 404 and change nothing: no audit,
no push revoke, no end of an assignment, no cut.
- GlobalAdminService.DisableGlobalAdminAsync and its repository method had no caller and are removed.
- Tests pin the finding's attack, the 404s, the self-service gate, the binding routes and the call sites;
each part was mutated back to the old behaviour and failed its tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two follow-up findings of the security scan against D-378.
- IsPromotableUpn is a shape test (contains '@', no whitespace, no #EXT#); the "System" prefix
test is gone. It rejected real members such as systemadmin@… although every sentinel already
fails the '@' test, so such a tenant got no seat and fell back to first-caller-wins.
- BuildAuthResult keys the first-user guard off a recorded OnboardedBy, not off its promotability:
a recorded value nobody can seat still blocks every other roleless caller.
- RevokeAsync records the revoke (PreviewWhitelist row "revoked") before it deletes the activation,
fail-loud; a re-approve never clears it, and activation seats nobody while it exists. A member
the tenant removed stays removed whatever a Global Admin's revoke + re-approve does.
- Every successful first-admin seat (activation and first sign-in) writes the tenant's audit entry
CREATE TenantAdmin, fail-soft, through a new longest TenantAdminsService constructor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Security finding (ReDoS / CWE-1333): a Tenant Admin of any tenant or a Global Reader could send a catastrophic pattern with 200 non-matching lines to POST rules/gather/test-pattern and cost ~200 s of a worker thread per request (one swallowed 1 s timeout per line), repeat it up to the portal budget and disconnect at once — nothing cancelled the work, nothing limited concurrency, the pattern length was bounded only by the body cap, and RegexOptions.Compiled emitted IL for every caller pattern.
- TestLogPatternFunction: the first RegexMatchTimeoutException ends the test, a 2 s EvaluationBudget ends it when every line is merely slow, and the invocation CancellationToken (client disconnect, host shutdown) ends it between two lines — without throwing. Lines after the stop get no row; LogPatternTestResult.StopReason (regex_timeout | time_budget | cancelled) and a note name the stop and the unevaluated range. A timeout on the last line is a complete test.
- ValidateRequest rejects patterns over 2,000 characters (the MCP schema's cap) before any Regex is built.
- BuildAgentRegex keeps the agent's semantic options and its 1 s match timeout but drops Compiled: the flag changes speed only, never a match result.
- TestLogPatternGate (singleton, limit 2) is entered before the regex is built; without a free slot the function answers 429 RateLimited with Retry-After: 3 and never queues. Stops and refusals log at Warning.
- Shared: LogPatternTestResult.StopReason (additive); manifests and wire types regenerated. MCP: the tool description says that the first timeout ends the test.
- Tests: pattern cap, interpreted construction with the agent's timeout, catastrophic pattern stops after one timeout (was ~200 s), last-line timeout is no stop, budget and cancellation stops, gate slots.
Submodule pointers: internal (plan, D-379, backlog B-66p/B-j42), .claude/commands (platform-health marker).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy
admin row a few storage round-trips later. In between the tenant was activated with no
members, and auth/me seated any roleless caller of that moment as Admin; every member of the
customer's Entra tenant could poll auth/me across the auto-approve moment.
- TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional
insert, best-effort, fresh config read), then the activation row (still the arbiter), then
the welcome mail. The two tables cannot share a batch, so "before" is the guarantee.
- AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes
to that UPN only; other roleless callers stay roleless. Without one (rows from before the
field existed) the first sign-in still becomes the admin. The grant logs at Warning.
- Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync,
409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync
replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check.
- Dead after this: IsTenantAdminAsync on the service, the repository interface and the table
repository, plus its test.
- Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the
conditional write; each part was mutated back to the old code and failed its tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Findings of a Claude security scan against b4af8c7, each measured first, plus
the MSI channel incident a parallel session found.
- Installer 2.8 extracts the agent ZIP with its own containment check
(Expand-AgentPayload): every entry is resolved against the payload folder
before the first write, a rooted or escaping name refuses the package. The
inbox Expand-Archive of Windows PowerShell 5.1 already refuses such entries
(measured), so the finding was not exploitable as reported; the guarantee is
now the script's own, pinned by tests, and Expand-Archive is banned.
- Installer 2.8 also moves PowerShell's own location out of the agent folder
(Set-Location): on the MSI channel the location pointed at the deleted Msi
folder and the agent start failed with error 267 since 2.7 went live. When
the agent program never started, the Agent folder is removed again so a later
run can install; the stderr line is flattened to one line.
- Run folders younger than an hour stay (a loader may still run there); the
loader's log is taken only from a run folder that is ours.
- Loader 1.4 (minimum installer 2.8) checks before every download attempt that
its run folder is still a real folder of ours, opens the download for reading
before the signature and identity checks and holds it until the run ends, so
what it checked is what runs and nothing can replace or delete it meanwhile.
- Agent: the protected folder security names Administrators as owner; a folder
with another owner or a reparse point is not taken as protected, reparse points
are refused, the base is created protected, install mode protects base and
Agent and refuses to install when it cannot; every start re-checks both.
- Bootstrap MSI 1.3.0 installs its loader under Program Files, where a standard
user cannot create the folder first.
- Tests: containment table, hour rule, held handle, owner and junction tests
(ElevatedFact for the owner assignment), MSI location contract; harness
scenarios for an escaping package (installer and OOBE script), the hour rule,
the Program Files layout and an agent that does not start, with the install
stand-in starting a real program from PowerShell's location. OOBE golden
regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
After auto-activation, a third of new tenants' first admins were answered by
auth/me without a role and sent from the activation page to /progress. The
auto-approve worker promotes the onboarding member on another instance, so the
portal's caches never hear of it: auth/me combined a cached "no row" (refreshed
1.5 s after every activation poll by feedback/status) with a fresh member list,
and neither the role nor the first-user auto-admin applied.
- TenantAdminsService caches only an enabled membership; a missing or disabled
row is read on every call. IsTenantAdminAsync reads through the same cache.
- auth/me takes the caller's row and "the tenant has any row" from one uncached
partition read (GetMembershipSnapshotAsync); disabled and application rows
still count as members.
- PreviewWhitelistService caches only an activation; "not activated" is read on
every call, so the activation page leaves at the first poll after approval.
IsApprovedFreshAsync is folded into IsApprovedAsync.
- Removed the dead ClearCacheForTenant, GetMemberRoleAsync and
CanManageBootstrapAsync.
Tests reproduce the sequence over the real caches (auth/me and the first
dashboard request); each part fails against the old caching.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub Actions runs powershell steps with $ErrorActionPreference = 'stop', and
under Windows PowerShell 5.1 a stderr line of a native process redirected with
2>&1 then ends the test: the three publish refusals failed in CI although the
publish refused as it should. The publish dry runs now start pwsh through
System.Diagnostics.Process like every other scenario. Local run under 'stop':
199 of 199.
[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Loader 1.3 downloads into a protected run folder of its own
(%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
is signed by the pinned publisher and declares the installer role and version
2.7 or later, removes the folder after a successful run and never touches the
agent folder. Unbound arguments now reach the installer by name; the array
splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
read HKLM through the 64-bit view (a 32-bit host never saw the deployment
marker), guard 5 is covered by the agent check. An agent's folder stays as it
is, a skip without agent removes the folder, an install starts from an emptied,
protected folder and stages the ZIP in a run folder. It moves the MSI's working
directory out, writes stderr only on failure and removes the copy older loaders
left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
(#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
(loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
a real standard user.
[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signup list under the chart now links every domain to
/admin/tenants/management?tenantId=…, the deep link the ops events
"View tenant" button already uses, so the list opens narrowed to that tenant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend repeats AdminAction on every telemetry response while the session
row carries it. The agent handled each echo anew: a new admin_marked_session
with an immediate upload, whose response carried the next echo. With a running
agent on a Mark-Succeeded session that looped at the device rate limit until
the agent ended; Mark-Failed ended the agent and stayed bounded.
- ServerControlPlane: AdminActionLatch admits the first AdminAction of a
process; every later one only logs at Debug.
- AdminAction=Succeeded runs the agent's own termination pipeline with outcome
Succeeded, like Failed: what the portal's confirmation dialog and the docs
promise, and what the restart path already did.
- HandleAdminActionTests: Succeeded terminates; repeated echoes, a later
different outcome and concurrent callers are handled once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the public host a telemetry initializer adds the referrer's host and
the utm_source, utm_medium and utm_campaign tags of the entry URL to every
page view and custom event. Both come from the document itself
(document.referrer and the navigation entry), so nothing is stored and no
identifier is added; metrics, dependencies and the portal stay as they are.
The hero and final get-started buttons and the three Pro plan links now
navigate client-side, so landing, plans, get-started and the sign-in click
stay one page load. A guard fails on a plain <a> to /get-started or /plans.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tls= was empty on every report: .NET Framework's SecureChannelFailure
carries no inner Win32 code. After a terminal SecureChannelFailure the
agent now opens one direct SslStream connection to the backend host with
the client certificate (system-default protocols, normal server
validation, never a proxy) and sends one GET of the agent config with the
agent's headers - the platform asks for the certificate only once it has
seen the path. The message carries tlsp=<protocol>,<m|->,<answer>:
b<status> only when the probe's own correlation id comes back (shared
ResponseOriginClassifier), cc<status> for the platform's "Client
Certificate Required" page, st/x/io otherwise; or unreachable,
hs(<SSPI code>), hs(srvcert), timeout, error. A server certificate not
issued by Microsoft appends ,i=<issuer CN> (TLS inspection).
The probe runs beside the TPM and Schannel reads, all three within 10 s
(4 s per network step), on the failure path only. The Schannel window now
ends when the failure is detected (ReadBetween), so the probe's own
handshake never shows up in sch=. A connect that fails after its timeout
is observed (an unobserved task exception would write crash artefacts);
an answer that began is kept when the server does not close.
Live, .NET Framework 4.8 against production without a certificate:
tls12,-,cc403.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.
Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.
Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes Dependabot alerts #123 and #124 (GHSA-698v-4r3x-3vcv, launch-editor
escapeCmdArgs() omits % escaping on Windows). Lockfile-only: vite is a
transitive dev dependency of vitest, whose range already allows 8.3.x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>