源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s

This commit is contained in:
agent-radar committed 2026-09-01 19:05:14 +08:00
commit 7be535059e
3358 files changed
+1306357

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Windows sandbox native evidence
This directory contains the native Rust broker/launcher and Windows evidence
tooling selected by the Windows sandbox RFC. The release build is packaged by
the Windows x64 pipeline and registered through `SandboxManager` only when the
native resource is present.
The first slice freezes the launcher request/result shapes in the shipped Rust
types and exercises them through native smoke tests. A launcher that merely
starts a process is not sufficient evidence.
Run the contract checks on Windows:
```powershell
cargo test --manifest-path experiments/windows-sandbox/launcher/Cargo.toml --locked
pwsh experiments/windows-sandbox/appcontainer-smoke.ps1
```
The smoke probes exit non-zero when an observation differs from its expectation.
Their JSON reports are evidence inputs, not claims that the parent process is
sandboxed.
`launcher/` also retains the rejected process-containment prototype. It probes a
restricted primary token, suspended process creation, post-create Job
assignment, kill-on-close descendants, and no inherited handles. The post-create
assignment is explicitly not the atomic Job guarantee and is not the production
path. It remains only as bounded negative evidence.
The current Windows 2025 evidence records an incompatibility in the
unprivileged candidate: `CreateProcessWithTokenW` creates the restricted child,
but both the native launcher self-probe and `cmd.exe /d /c exit 0` fail to finish
initialization before the 30-second safety deadline. The launcher terminates the
child and returns failure. The CI lane treats this exact bounded, fail-closed
result as evidence; any other launch error still fails the job. This candidate
does not satisfy W0 and must not be connected to the product.
`atomic-launch-capability.ps1` records whether the current Windows identity has
the privileges needed to test the separate privileged-broker prototype. A
missing privilege is an expected fail-closed capability result; it must not be
worked around by silently using the non-atomic launcher path.
`maka-windows-sandbox --broker-local <manifest>` consumes one manifest,
authorizes it in-process, launches it, restores ACLs, and exits. The lower-level
`--broker-serve-once <pipe> <account-sid> <profile-digest>` mode exposes one
bounded experimental request over a pipe. The pipe rejects remote
clients, grants access only to SYSTEM and the supplied user SID, obtains the
client PID from the kernel, enforces nonce replay and profile-digest checks,
and calls only the AppContainer atomic launch path. Authorization validates the
digest of the complete launch policy. The broker never falls back to post-create
Job assignment.
`launcher --atomic <request.json>` is the privileged-broker launch candidate.
It passes the Job handle through `PROC_THREAD_ATTRIBUTE_JOB_LIST` to
`CreateProcessAsUserW`, so successful process creation and Job membership are
one operation. The launcher verifies the restricted token and Job membership
before reporting `atomicJob:true`. The atomic candidate never falls back to the
post-create assignment path. `atomic-launch-smoke.ps1` requires complete proof
when the runner has both broker privileges and otherwise requires an explicit
non-zero, fail-closed outcome.
`launcher --appcontainer <request.json>` is the isolated-identity candidate. It
creates a fresh request-derived AppContainer identity, combines its token with
the same atomic Job attribute, and supplies no network capabilities. Before
launch, the broker persists an ACL recovery ledger, rejects reparse points, and
grants that per-launch SID only the requested roots. A short-lived global mutex
serializes ACL mutation, while a request-specific kernel lease distinguishes
live ledgers from abandoned ones without serializing child execution. The smoke
proves allowed read/write access, denial of a user-readable sibling file and
live loopback endpoint, stale-ledger recovery, concurrent launches, junction
rejection, and removal of the temporary AppContainer ACE after exit.
@@ -0,0 +1,202 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
param(
[string]$LauncherPath
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcher = if ($LauncherPath) {
$LauncherPath
} else {
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
}
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$workRoot = Join-Path $tempRoot "maka-acl-recovery-$PID"
$ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers'
# findstr exits 0 on match and nonzero on denial, and takes plain argv — no
# cmd.exe quote-parsing pitfalls inside the AppContainer.
$findstrExe = Join-Path $env:SystemRoot 'System32\findstr.exe'
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
function Get-AppContainerSid {
param([string]$RequestId)
$sid = (& $launcher --appcontainer-sid $RequestId 2>&1) -join ''
if ($LASTEXITCODE -ne 0 -or $sid -notmatch '^S-1-15-2-') {
throw "Unable to resolve AppContainer SID for ${RequestId}: $sid"
}
return $sid
}
# A fake AppContainer-shaped SID nothing else uses: pre-existing ACEs that
# recovery and cleanup must leave untouched.
$markerSid = 'S-1-15-2-1999999999-1888888888-1777777777-1666666666-1555555555-1444444444-1333333333'
function New-LaunchRequest {
param(
[string]$Name,
[string[]]$Arguments,
[string[]]$ReadRoots,
[string[]]$ExactReadRoots,
[Nullable[int]]$TimeoutMs,
[string]$Executable = $findstrExe
)
$request = [ordered]@{
version = 1
requestId = $Name
executable = $Executable
arguments = $Arguments
cwd = $env:SystemRoot
readRoots = $ReadRoots
writeRoots = @()
network = 'restricted'
environment = @{}
}
if ($ExactReadRoots) { $request.exactReadRoots = $ExactReadRoots }
if ($null -ne $TimeoutMs) { $request.timeoutMs = $TimeoutMs }
$path = Join-Path $workRoot "$Name.json"
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $path -Encoding utf8
return $path
}
function Get-Acl-Text { param([string]$Path) (& icacls.exe $Path 2>&1) -join "`n" }
try {
# --- Existing-ACE preservation + subtree vs exact grants -------------------
$dataRoot = Join-Path $workRoot 'data'
New-Item -ItemType Directory -Path $dataRoot | Out-Null
Set-Content -LiteralPath (Join-Path $dataRoot 'ok.txt') -Value 'subtree-read' -Encoding ascii
& icacls.exe $dataRoot /grant "*$markerSid`:(OI)(CI)RX" /T /Q | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Unable to seed marker ACE' }
$subtree = New-LaunchRequest -Name 'subtree-read' `
-Arguments @('/c:subtree-read', "$dataRoot\ok.txt") -ReadRoots @($dataRoot)
$subtreeSid = Get-AppContainerSid 'subtree-read'
& $launcher --appcontainer $subtree *> $null
if ($LASTEXITCODE -ne 0) { throw "subtree read launch failed: exit=$LASTEXITCODE" }
$exact = New-LaunchRequest -Name 'exact-read' `
-Arguments @('/c:subtree-read', "$dataRoot\ok.txt") `
-ReadRoots @($dataRoot) -ExactReadRoots @($dataRoot)
$exactSid = Get-AppContainerSid 'exact-read'
& $launcher --appcontainer $exact *> $null
if ($LASTEXITCODE -eq 0) {
throw 'exact-directory grant unexpectedly allowed reading a child file'
}
$dataAcl = Get-Acl-Text $dataRoot
$childAcl = Get-Acl-Text (Join-Path $dataRoot 'ok.txt')
if ($dataAcl -match [regex]::Escape($subtreeSid) -or
$childAcl -match [regex]::Escape($subtreeSid) -or
$dataAcl -match [regex]::Escape($exactSid) -or
$childAcl -match [regex]::Escape($exactSid)) {
throw "AppContainer grants were not removed after launch: $dataAcl"
}
if ($dataAcl -notmatch [regex]::Escape($markerSid) -or
$childAcl -notmatch [regex]::Escape($markerSid)) {
throw "Pre-existing marker ACE was destroyed by cleanup: $dataAcl"
}
# --- Concurrency: parallel launches on distinct roots ----------------------
$jobs = @()
$concurrentRoots = @()
for ($index = 0; $index -lt 10; $index++) {
$root = Join-Path $workRoot "concurrent-$index"
New-Item -ItemType Directory -Path $root | Out-Null
Set-Content -LiteralPath (Join-Path $root 'ok.txt') -Value "concurrent-$index" -Encoding ascii
$concurrentRoots += $root
$requestPath = New-LaunchRequest -Name "concurrent-$index" `
-Arguments @('/c:concurrent', "$root\ok.txt") -ReadRoots @($root) -TimeoutMs 120000
$jobs += Start-Job -ScriptBlock {
param($Launcher, $Request)
$output = & $Launcher --appcontainer $Request 2>&1
[pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = ($output -join ' | ') }
} -ArgumentList $launcher, $requestPath
}
$results = $jobs | Wait-Job | Receive-Job
$jobs | Remove-Job
$failed = @($results | Where-Object { $_.ExitCode -ne 0 })
if ($failed.Count -gt 0) {
$detail = ($failed | ForEach-Object { "exit=$($_.ExitCode) output=$($_.Output)" }) -join "`n"
throw "Concurrent launches failed:`n$detail"
}
foreach ($root in $concurrentRoots) {
if ((Get-Acl-Text $root) -match 'S-1-15-2-') {
throw "Concurrent launch left AppContainer grants on ${root}"
}
}
$leftover = @(Get-ChildItem -LiteralPath $ledgerRoot -Filter '*.json' -ErrorAction SilentlyContinue)
if ($leftover.Count -gt 0) {
throw "Concurrent launches left ACL ledgers behind: $($leftover.Name -join ', ')"
}
# --- Killed-broker recovery ------------------------------------------------
$staleRoot = Join-Path $workRoot 'kill-recovery'
New-Item -ItemType Directory -Path $staleRoot | Out-Null
Set-Content -LiteralPath (Join-Path $staleRoot 'ok.txt') -Value 'kill-recovery' -Encoding ascii
# The launcher's own --stdio-probe --sleep keeps the sandboxed child alive
# without needing network or console features inside the AppContainer.
$sleepRequest = New-LaunchRequest -Name 'kill-recovery' -Executable $launcher `
-Arguments @('--stdio-probe', '--sleep', '50') -ReadRoots @($staleRoot) -TimeoutMs 120000
$killRecoverySid = Get-AppContainerSid 'kill-recovery'
$emptyStdin = Join-Path $workRoot 'empty-stdin.bin'
[IO.File]::WriteAllBytes($emptyStdin, @())
$broker = Start-Process -FilePath $launcher -ArgumentList @('--appcontainer', $sleepRequest) `
-RedirectStandardInput $emptyStdin `
-RedirectStandardOutput (Join-Path $workRoot 'kill-stdout.txt') `
-RedirectStandardError (Join-Path $workRoot 'kill-stderr.txt') `
-NoNewWindow -PassThru
$deadline = [DateTime]::UtcNow.AddSeconds(30)
while ([DateTime]::UtcNow -lt $deadline -and
(Get-Acl-Text $staleRoot) -notmatch [regex]::Escape($killRecoverySid)) {
Start-Sleep -Milliseconds 200
}
if ((Get-Acl-Text $staleRoot) -notmatch [regex]::Escape($killRecoverySid)) {
Stop-Process -Id $broker.Id -Force -ErrorAction SilentlyContinue
throw 'Killed-broker scenario never observed the AppContainer grant'
}
Stop-Process -Id $broker.Id -Force
Wait-Process -Id $broker.Id -ErrorAction SilentlyContinue
$staleLedgers = @(Get-ChildItem -LiteralPath $ledgerRoot -Filter '*.json' -ErrorAction SilentlyContinue)
if ($staleLedgers.Count -eq 0) {
throw 'Killing the broker mid-launch left no ledger to recover'
}
# The next launch must replay the stale ledger and remove the dead broker's
# grants before establishing its own.
$recovery = New-LaunchRequest -Name 'recovery' `
-Arguments @('/c:subtree-read', "$dataRoot\ok.txt") -ReadRoots @($dataRoot)
& $launcher --appcontainer $recovery *> $null
if ($LASTEXITCODE -ne 0) { throw "Recovery launch failed: exit=$LASTEXITCODE" }
if ((Get-Acl-Text $staleRoot) -match [regex]::Escape($killRecoverySid)) {
throw 'Recovery did not remove the dead broker''s AppContainer grants'
}
$remaining = @(Get-ChildItem -LiteralPath $ledgerRoot -Filter '*.json' -ErrorAction SilentlyContinue)
if ($remaining.Count -gt 0) {
throw "Recovery left ACL ledgers behind: $($remaining.Name -join ', ')"
}
Write-Host 'ACL recovery verified: ACE preservation, exact roots, 10-way concurrency, killed-broker replay'
} finally {
& icacls.exe $workRoot /remove "*$markerSid" /T /L /Q 2>$null | Out-Null
Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,342 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
param(
[Parameter(Mandatory = $true)]
[string]$LauncherPath
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcher = (Resolve-Path -LiteralPath $LauncherPath).Path
$launcherDirectory = Split-Path -Parent $launcher
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$workRoot = Join-Path $tempRoot "maka-phase4-adversarial-$PID"
$ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers'
$registrySubkey = "Software\Maka\SandboxPhase4\$PID"
$registryPath = "HKCU:\$registrySubkey"
$registryValueName = 'HostSecret'
$pipeShortName = "maka-phase4-host-$PID"
$pipeName = "\\.\pipe\$pipeShortName"
$hostSecretName = 'MAKA_PHASE4_HOST_SECRET'
$listener = $null
$pipe = $null
$quarantinedSid = $null
$quarantineRoot = $null
$quarantinePath = $null
function Write-LaunchRequest {
param(
[string]$Name,
[string[]]$Arguments,
[string[]]$ReadRoots,
[string[]]$WriteRoots,
[string[]]$ExactReadRoots,
[string[]]$ExactWriteRoots
)
$request = [ordered]@{
version = 1
requestId = $Name
executable = $launcher
arguments = $Arguments
cwd = Split-Path -Parent $launcher
readRoots = $ReadRoots
writeRoots = $WriteRoots
exactReadRoots = $ExactReadRoots
exactWriteRoots = $ExactWriteRoots
network = 'restricted'
environment = @{ MAKA_PHASE4_ALLOWED = 'allowed' }
timeoutMs = 120000
}
$path = Join-Path $workRoot "$Name.json"
$requestJson = $request | ConvertTo-Json -Depth 5
[IO.File]::WriteAllText($path, $requestJson, [Text.UTF8Encoding]::new($false))
return $path
}
function Invoke-ExpectedAdmissionFailure {
param(
[string]$RequestPath,
[string]$Pattern,
[string]$Description
)
$result = Invoke-Launcher @('--appcontainer', $RequestPath)
$output = $result.Output
$exitCode = $result.ExitCode
$rendered = $output -join "`n"
if ($exitCode -eq 0 -or $rendered -notmatch $Pattern) {
throw "$Description did not fail closed: exit=$exitCode output=$rendered"
}
$global:LASTEXITCODE = 0
}
function Invoke-Launcher {
param([string[]]$Arguments)
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = & $launcher @Arguments 2>&1
return [pscustomobject]@{
ExitCode = $LASTEXITCODE
Output = @($output)
}
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
}
function Get-AppContainerSid {
param([string]$RequestId)
$sid = (& $launcher --appcontainer-sid $RequestId 2>&1) -join ''
if ($LASTEXITCODE -ne 0 -or $sid -notmatch '^S-1-15-2-') {
throw "Unable to resolve AppContainer SID for ${RequestId}: $sid"
}
return $sid
}
function Get-Sha256Hex {
param([string]$Value)
$sha = [Security.Cryptography.SHA256]::Create()
try {
return -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value)) | ForEach-Object {
$_.ToString('x2')
})
} finally {
$sha.Dispose()
}
}
function Get-AclText {
param([string]$Path)
return (& icacls.exe $Path 2>&1) -join "`n"
}
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
New-Item -ItemType Directory -Path $ledgerRoot -Force | Out-Null
try {
$outsideRoot = Join-Path $workRoot 'outside'
$allowedRoot = Join-Path $workRoot 'allowed'
New-Item -ItemType Directory -Path $outsideRoot, $allowedRoot | Out-Null
$deniedPath = Join-Path $outsideRoot 'host-secret.txt'
$allowedReadPath = Join-Path $allowedRoot 'read.txt'
$allowedWritePath = Join-Path $allowedRoot 'write.txt'
[IO.File]::WriteAllText($deniedPath, 'must-not-be-readable')
[IO.File]::WriteAllText($allowedReadPath, 'allowed-read')
[IO.File]::WriteAllText($allowedWritePath, 'seeded')
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$listener.Start()
$port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
$pipe = [IO.Pipes.NamedPipeServerStream]::new(
$pipeShortName,
[IO.Pipes.PipeDirection]::InOut,
1,
[IO.Pipes.PipeTransmissionMode]::Byte,
[IO.Pipes.PipeOptions]::Asynchronous
)
New-Item -Path $registryPath -Force | Out-Null
Set-ItemProperty -Path $registryPath -Name $registryValueName -Value 'registry-secret'
[Environment]::SetEnvironmentVariable($hostSecretName, 'environment-secret', 'Process')
$probeInputPath = Join-Path $allowedRoot 'adversarial-probe.json'
$probeJson = [ordered]@{
deniedPath = $deniedPath
allowedReadPath = $allowedReadPath
allowedWritePath = $allowedWritePath
loopbackPort = $port
pipeName = $pipeName
environmentSecretName = $hostSecretName
registrySubkey = $registrySubkey
registryValueName = $registryValueName
parentPid = $PID
} | ConvertTo-Json
[IO.File]::WriteAllText($probeInputPath, $probeJson, [Text.UTF8Encoding]::new($false))
$probeRequest = Write-LaunchRequest -Name "phase4-adversarial-$PID" `
-Arguments @('--adversarial-probe', $probeInputPath) `
-ReadRoots @($probeInputPath, $allowedReadPath, $launcherDirectory) `
-WriteRoots @($allowedWritePath) `
-ExactReadRoots @($probeInputPath, $allowedReadPath) `
-ExactWriteRoots @($allowedWritePath)
$result = Invoke-Launcher @('--appcontainer', $probeRequest)
$output = $result.Output
$exitCode = $result.ExitCode
$rendered = $output -join "`n"
$requiredEvidence = @(
'"fileDenied":true',
'"allowedRead":true',
'"allowedWrite":true',
'"tcpDenied":true',
'"namedPipeDenied":true',
'"environmentDenied":true',
'"registryDenied":true',
'"parentTokenDenied":true',
'"descendantAppContainer":true',
'"descendantInJob":true'
)
$missingEvidence = @($requiredEvidence | Where-Object { $rendered -notmatch [regex]::Escape($_) })
$descendantEvidence =
$rendered -match '"descendantSpawnDenied":true' -or
($rendered -match '"descendantAppContainer":true' -and
$rendered -match '"descendantInJob":true')
if (-not $descendantEvidence) {
$missingEvidence += 'descendantSpawnDenied or descendantAppContainer+descendantInJob'
}
$missingEvidence = @($missingEvidence | Where-Object {
$_ -notin @('"descendantAppContainer":true', '"descendantInJob":true')
})
if ($exitCode -ne 0 -or $missingEvidence.Count -gt 0) {
throw "Packaged adversarial probe failed: exit=$exitCode missing=$($missingEvidence -join ', ') output=$rendered"
}
# Recursive roots fail admission when any entry redirects to another tree.
$junctionRoot = Join-Path $workRoot 'junction-root'
New-Item -ItemType Directory -Path $junctionRoot | Out-Null
New-Item -ItemType Junction -Path (Join-Path $junctionRoot 'escape') -Target $outsideRoot | Out-Null
$junctionRequest = Write-LaunchRequest -Name "phase4-junction-$PID" `
-Arguments @('--self-probe') -ReadRoots @($junctionRoot) -WriteRoots @() `
-ExactReadRoots @() -ExactWriteRoots @()
Invoke-ExpectedAdmissionFailure -RequestPath $junctionRequest -Pattern 'reparse point' `
-Description 'Junction alias admission'
# Recursive roots also reject a file whose content is reachable through a
# second hard-link name outside the admitted tree.
$hardLinkRoot = Join-Path $workRoot 'hardlink-root'
New-Item -ItemType Directory -Path $hardLinkRoot | Out-Null
$hardLinkOutside = Join-Path $outsideRoot 'hardlink-source.txt'
[IO.File]::WriteAllText($hardLinkOutside, 'hardlink-secret')
New-Item -ItemType HardLink -Path (Join-Path $hardLinkRoot 'alias.txt') `
-Target $hardLinkOutside | Out-Null
$hardLinkRequest = Write-LaunchRequest -Name "phase4-hardlink-$PID" `
-Arguments @('--self-probe') -ReadRoots @($hardLinkRoot) -WriteRoots @() `
-ExactReadRoots @() -ExactWriteRoots @()
Invoke-ExpectedAdmissionFailure -RequestPath $hardLinkRequest -Pattern 'multi-link' `
-Description 'Hard-link alias admission'
# An unsettled identity is never interpreted or reused by later launches.
# The synthetic quarantined record carries a live ACE so this verifies both
# preservation-for-inspection and fresh-identity isolation. The test removes
# its own synthetic residue in finally.
$quarantineRequestId = "phase4-unsettled-$PID"
$quarantinedSid = Get-AppContainerSid $quarantineRequestId
$quarantineRoot = Join-Path $workRoot 'quarantined-root'
New-Item -ItemType Directory -Path $quarantineRoot | Out-Null
& icacls.exe $quarantineRoot /grant "*$quarantinedSid`:(OI)(CI)RX" /T /Q | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Unable to seed quarantined AppContainer ACE' }
$quarantinePath = Join-Path $ledgerRoot "$(Get-Sha256Hex $quarantineRequestId).json.quarantined"
[ordered]@{
version = 2
requestId = $quarantineRequestId
appContainerSid = $quarantinedSid
roots = @([ordered]@{
path = $quarantineRoot
read = $true
write = $false
readRecursive = $true
writeRecursive = $false
})
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $quarantinePath -Encoding utf8
$recoveryRequestId = "phase4-quarantine-recovery-$PID"
$recoverySid = Get-AppContainerSid $recoveryRequestId
if ($recoverySid -eq $quarantinedSid) {
throw 'A later launch reused the quarantined AppContainer identity'
}
$recoveryRequest = Write-LaunchRequest -Name $recoveryRequestId `
-Arguments @('--self-probe') -ReadRoots @($allowedReadPath) -WriteRoots @() `
-ExactReadRoots @($allowedReadPath) -ExactWriteRoots @()
$recoveryResult = Invoke-Launcher @('--appcontainer', $recoveryRequest)
if ($recoveryResult.ExitCode -ne 0) {
throw "Launch after quarantined state failed: $($recoveryResult.Output -join "`n")"
}
if (-not (Test-Path -LiteralPath $quarantinePath)) {
throw 'A later launch interpreted or deleted quarantined recovery evidence'
}
if ((Get-AclText $quarantineRoot) -notmatch [regex]::Escape($quarantinedSid)) {
throw 'A later launch removed authority whose Job was not proven empty'
}
Write-Host "Phase 4 adversarial matrix verified: $rendered"
} finally {
$cleanupErrors = [System.Collections.Generic.List[string]]::new()
if ($listener) {
try { $listener.Stop() } catch { $cleanupErrors.Add("listener stop: $($_.Exception.Message)") }
}
if ($pipe) {
try { $pipe.Dispose() } catch { $cleanupErrors.Add("pipe dispose: $($_.Exception.Message)") }
}
try {
[Environment]::SetEnvironmentVariable($hostSecretName, $null, 'Process')
} catch {
$cleanupErrors.Add("environment cleanup: $($_.Exception.Message)")
}
if (Test-Path -LiteralPath $registryPath) {
try {
Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction Stop
} catch {
$cleanupErrors.Add("registry cleanup: $($_.Exception.Message)")
}
}
if ($quarantineRoot -and $quarantinedSid) {
$aclCleanupOutput = & icacls.exe $quarantineRoot /remove "*$quarantinedSid" /T /L /Q 2>&1
$aclCleanupExitCode = $LASTEXITCODE
if ($aclCleanupExitCode -ne 0) {
$cleanupErrors.Add(
"quarantined ACE cleanup failed: exit=$aclCleanupExitCode output=$($aclCleanupOutput -join ' ')"
)
}
}
if ($quarantinePath) {
if (Test-Path -LiteralPath $quarantinePath) {
try {
Remove-Item -LiteralPath $quarantinePath -Force -ErrorAction Stop
} catch {
$cleanupErrors.Add("quarantine ledger cleanup: $($_.Exception.Message)")
}
}
if (Test-Path -LiteralPath $quarantinePath) {
$cleanupErrors.Add("quarantine ledger still exists: $quarantinePath")
}
}
if ($quarantineRoot -and (Test-Path -LiteralPath $quarantineRoot)) {
try {
if ((Get-AclText $quarantineRoot) -match [regex]::Escape($quarantinedSid)) {
$cleanupErrors.Add("quarantined ACE still exists: $quarantinedSid")
}
} catch {
$cleanupErrors.Add("quarantined ACE verification: $($_.Exception.Message)")
}
}
if (Test-Path -LiteralPath $workRoot) {
try {
Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction Stop
} catch {
$cleanupErrors.Add("work root cleanup: $($_.Exception.Message)")
}
}
if (Test-Path -LiteralPath $workRoot) {
$cleanupErrors.Add("work root still exists: $workRoot")
}
if ($cleanupErrors.Count -gt 0) {
throw "Phase 4 adversarial matrix cleanup failed: $($cleanupErrors -join '; ')"
}
}
$global:LASTEXITCODE = 0
exit 0
@@ -0,0 +1,130 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
param(
[string]$LauncherPath
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcher = if ($LauncherPath) {
$LauncherPath
} else {
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
}
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$requestPath = Join-Path $tempRoot "maka-windows-appcontainer-$PID.json"
$secretPath = Join-Path $tempRoot "maka-windows-appcontainer-secret-$PID.txt"
$allowedReadPath = Join-Path $tempRoot "maka-windows-appcontainer-allowed-read-$PID.txt"
$allowedWriteRoot = Join-Path $tempRoot "maka-windows-appcontainer-allowed-write-$PID"
$allowedWritePath = Join-Path $allowedWriteRoot 'write.txt'
$staleRoot = Join-Path $tempRoot "maka-windows-appcontainer-stale-$PID"
$ledgerRoot = Join-Path ([IO.Path]::GetTempPath()) 'maka-sandbox-acl-ledgers'
$ledgerPath = Join-Path $ledgerRoot "stale-$PID.json"
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$listener.Start()
$port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port
'must-not-be-readable' | Set-Content -LiteralPath $secretPath -Encoding utf8
[IO.File]::WriteAllText($allowedReadPath, 'allowed-read')
New-Item -ItemType Directory -Path $allowedWriteRoot | Out-Null
New-Item -ItemType Directory -Path $staleRoot | Out-Null
New-Item -ItemType Directory -Path $ledgerRoot -Force | Out-Null
$staleAppContainerSid = (& $launcher --appcontainer-sid 'stale-smoke' 2>&1) -join ''
if ($LASTEXITCODE -ne 0 -or $staleAppContainerSid -notmatch '^S-1-15-2-') {
throw "Unable to resolve stale AppContainer SID: $staleAppContainerSid"
}
& icacls.exe $staleRoot /grant "*$staleAppContainerSid`:(OI)(CI)RX" /T /Q | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Unable to prepare stale AppContainer ACL' }
@{
version = 2
requestId = 'stale-smoke'
appContainerSid = $staleAppContainerSid
roots = @(@{
path = $staleRoot
read = $true
write = $false
readRecursive = $true
writeRecursive = $false
})
} | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $ledgerPath -Encoding utf8
$request = @{
version = 1
requestId = 'appcontainer-smoke'
executable = $launcher
arguments = @(
'--boundary-probe',
$secretPath,
$allowedReadPath,
$allowedWritePath,
"$port"
)
cwd = Split-Path -Parent $launcher
readRoots = @($allowedReadPath)
writeRoots = @($allowedWriteRoot)
network = 'restricted'
environment = @{}
}
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
try {
$output = & $launcher --appcontainer $requestPath 2>&1
$exitCode = $LASTEXITCODE
$rendered = $output -join "`n"
if ($exitCode -ne 0 -or
$rendered -notmatch '"appContainer":true' -or
$rendered -notmatch '"inJob":true' -or
$rendered -notmatch '"atomicJob":true' -or
$rendered -notmatch '"fileDenied":true' -or
$rendered -notmatch '"allowedRead":true' -or
$rendered -notmatch '"allowedWrite":true' -or
$rendered -notmatch '"networkDenied":true' -or
$rendered -notmatch '"desktopPrivatePlacement":true' -or
$rendered -notmatch '"desktop":"maka-sandbox-desktop\.') {
throw "AppContainer boundary was not established: exit=$exitCode output=$rendered"
}
$readAcl = (& icacls.exe $allowedReadPath 2>&1) -join "`n"
$writeAcl = (& icacls.exe $allowedWriteRoot 2>&1) -join "`n"
if ($readAcl -match 'S-1-15-2-' -or $writeAcl -match 'S-1-15-2-') {
throw "AppContainer ACL was not restored after launch: read=$readAcl write=$writeAcl"
}
$staleAcl = (& icacls.exe $staleRoot 2>&1) -join "`n"
if ($staleAcl -match [regex]::Escape($staleAppContainerSid) -or (Test-Path -LiteralPath $ledgerPath)) {
throw "Stale AppContainer ACL ledger was not recovered: $staleAcl"
}
$junction = Join-Path $allowedWriteRoot 'junction'
New-Item -ItemType Junction -Path $junction -Target $staleRoot | Out-Null
$reparseOutput = & $launcher --appcontainer $requestPath 2>&1
if ($LASTEXITCODE -eq 0 -or ($reparseOutput -join "`n") -notmatch 'reparse point') {
throw "AppContainer accepted a reparse-point root: $($reparseOutput -join "`n")"
}
$global:LASTEXITCODE = 0
Write-Host "AppContainer token and atomic Job boundary verified: $rendered"
} finally {
$listener.Stop()
Remove-Item -LiteralPath $requestPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $secretPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $allowedReadPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $allowedWriteRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $staleRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $ledgerPath -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,34 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
$ErrorActionPreference = 'Stop'
$privileges = whoami.exe /priv 2>$null
$hasAssign = $privileges -match 'SeAssignPrimaryTokenPrivilege\s+.*Enabled'
$hasQuota = $privileges -match 'SeIncreaseQuotaPrivilege\s+.*Enabled'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
[pscustomobject]@{
user = $identity.Name
isAdministrator = $isAdmin
seAssignPrimaryToken = $hasAssign
seIncreaseQuota = $hasQuota
atomicCreateProcessAsUserReady = $hasAssign -and $hasQuota
failClosedRequired = -not ($hasAssign -and $hasQuota)
} | ConvertTo-Json -Compress
@@ -0,0 +1,70 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcherRoot = Join-Path $PSScriptRoot 'launcher'
$launcher = Join-Path $launcherRoot 'target\debug\maka-windows-sandbox.exe'
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
$capability = & (Join-Path $PSScriptRoot 'atomic-launch-capability.ps1') | ConvertFrom-Json
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$requestPath = Join-Path $tempRoot 'maka-windows-sandbox-atomic-request.json'
$request = @{
version = 1
requestId = 'ci-atomic-process-containment'
executable = $env:ComSpec
arguments = @('/d', '/c', 'exit 0')
cwd = $launcherRoot
readRoots = @()
writeRoots = @()
network = 'enabled'
environment = @{
SystemRoot = $env:SystemRoot
PATH = $env:PATH
}
}
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
try {
$output = & $launcher --atomic $requestPath 2>&1
$exitCode = $LASTEXITCODE
$rendered = $output -join "`n"
if (-not $capability.atomicCreateProcessAsUserReady) {
if ($exitCode -eq 0 -or $rendered -match '"atomicJob":true') {
throw 'Atomic candidate reported success without the required broker privileges'
}
Write-Host "Atomic candidate failed closed without broker privileges: $rendered"
exit 0
}
if ($exitCode -ne 0) {
throw "Atomic launcher exited with ${exitCode}: $rendered"
}
if ($rendered -notmatch '"restrictedToken":true' -or
$rendered -notmatch '"inJob":true' -or
$rendered -notmatch '"atomicJob":true') {
throw "Atomic launcher did not prove the complete process boundary: $rendered"
}
Write-Host "Restricted token and atomic Job membership verified: $rendered"
} finally {
Remove-Item -LiteralPath $requestPath -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,93 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcher = Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$pipeSuffix = "maka-sandbox-client-ci-$PID"
$pipeName = "\\.\pipe\$pipeSuffix"
$manifestPath = Join-Path $env:RUNNER_TEMP "maka-windows-broker-client-$PID.json"
$launch = @{
version = 1
requestId = 'broker-client-smoke-launch'
executable = $launcher
arguments = @('--self-probe')
cwd = Split-Path -Parent $launcher
readRoots = @()
writeRoots = @()
network = 'restricted'
environment = @{}
}
$digestInput = Join-Path $env:RUNNER_TEMP "maka-windows-client-digest-$PID.json"
$launch | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $digestInput -Encoding utf8
$digest = (& $launcher --launch-digest $digestInput 2>&1) -join ''
Remove-Item -LiteralPath $digestInput -Force
if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[a-f0-9]{64}$') {
throw "Unable to compute launch digest: $digest"
}
$manifest = @{
version = 1
requestId = 'broker-client-smoke'
clientPid = 0
clientNonce = '0123456789abcdef0123456789abcdef'
profileDigest = $digest
launch = $launch
}
$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $launcher
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardError = $true
$start.ArgumentList.Add('--broker-serve-once')
$start.ArgumentList.Add($pipeName)
$start.ArgumentList.Add($sid)
$start.ArgumentList.Add($digest)
$server = [Diagnostics.Process]::Start($start)
try {
$output = & $launcher --broker-client $pipeName $manifestPath 2>&1
$exitCode = $LASTEXITCODE
$rendered = $output -join "`n"
if ($exitCode -ne 0) {
throw "Broker client AppContainer launch failed: exit=$exitCode output=$rendered"
}
if (Test-Path -LiteralPath $manifestPath) {
throw 'Broker client left its launch manifest on disk'
}
if (-not $server.WaitForExit(10000)) {
$server.Kill($true)
throw 'Broker server did not exit after the client exchange'
}
if ($server.ExitCode -ne 0) {
throw "Broker server failed: $($server.StandardError.ReadToEnd())"
}
Write-Host "Broker client PID binding, manifest cleanup, and AppContainer launch verified: $rendered"
} finally {
Remove-Item -LiteralPath $manifestPath -Force -ErrorAction SilentlyContinue
if (-not $server.HasExited) { $server.Kill($true) }
$server.Dispose()
}
exit 0
@@ -0,0 +1,63 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcher = Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
if (-not (Test-Path -LiteralPath $launcher)) { throw "Missing launcher binary: $launcher" }
$manifestPath = Join-Path $env:RUNNER_TEMP "maka-windows-broker-local-$PID.json"
$digestInput = Join-Path $env:RUNNER_TEMP "maka-windows-local-digest-$PID.json"
$launch = @{
version = 1
requestId = 'broker-local-smoke-launch'
executable = $launcher
arguments = @('--self-probe')
cwd = Split-Path -Parent $launcher
readRoots = @()
writeRoots = @()
network = 'restricted'
environment = @{}
}
$launch | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $digestInput -Encoding utf8
$digest = (& $launcher --launch-digest $digestInput 2>&1) -join ''
Remove-Item -LiteralPath $digestInput -Force
if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[a-f0-9]{64}$') {
throw "Unable to compute local launch digest: $digest"
}
@{
version = 1
requestId = 'broker-local-smoke'
clientPid = 0
clientNonce = 'fedcba9876543210fedcba9876543210'
profileDigest = $digest
launch = $launch
} | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8
try {
$output = & $launcher --broker-local $manifestPath 2>&1
if ($LASTEXITCODE -ne 0) {
throw "One-shot local broker failed: $($output -join "`n")"
}
if (Test-Path -LiteralPath $manifestPath) {
throw 'One-shot local broker left its launch manifest on disk'
}
Write-Host "One-shot local broker lifecycle verified: $($output -join "`n")"
} finally {
Remove-Item -LiteralPath $manifestPath -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,115 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
$ErrorActionPreference = 'Stop'
function Read-Exact([IO.Stream]$Stream, [byte[]]$Buffer) {
$offset = 0
while ($offset -lt $Buffer.Length) {
$count = $Stream.Read($Buffer, $offset, $Buffer.Length - $offset)
if ($count -eq 0) { throw 'Broker returned a truncated response' }
$offset += $count
}
}
$launcher = Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$pipeSuffix = "maka-sandbox-ci-$PID"
$pipeName = "\\.\pipe\$pipeSuffix"
$launch = @{
version = 1
requestId = 'pipe-smoke-launch'
executable = $launcher
arguments = @('--self-probe')
cwd = Split-Path -Parent $launcher
readRoots = @()
writeRoots = @()
network = 'restricted'
environment = @{}
}
$digestInput = Join-Path $env:RUNNER_TEMP "maka-windows-digest-$PID.json"
$launch | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $digestInput -Encoding utf8
$digest = (& $launcher --launch-digest $digestInput 2>&1) -join ''
Remove-Item -LiteralPath $digestInput -Force
if ($LASTEXITCODE -ne 0 -or $digest -notmatch '^[a-f0-9]{64}$') {
throw "Unable to compute launch digest: $digest"
}
$start = [Diagnostics.ProcessStartInfo]::new()
$start.FileName = $launcher
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
$start.ArgumentList.Add('--broker-serve-once')
$start.ArgumentList.Add($pipeName)
$start.ArgumentList.Add($sid)
$start.ArgumentList.Add($digest)
$server = [Diagnostics.Process]::Start($start)
$client = $null
try {
$client = [IO.Pipes.NamedPipeClientStream]::new(
'.',
$pipeSuffix,
[IO.Pipes.PipeDirection]::InOut,
[IO.Pipes.PipeOptions]::None
)
$client.Connect(10000)
$request = @{
version = 1
requestId = 'pipe-smoke'
clientPid = $PID
clientNonce = '0123456789abcdef0123456789abcdef'
profileDigest = $digest
launch = $launch
}
$payload = [Text.Encoding]::UTF8.GetBytes(($request | ConvertTo-Json -Depth 5 -Compress))
$prefix = [BitConverter]::GetBytes([uint32]$payload.Length)
$client.Write($prefix, 0, $prefix.Length)
$client.Write($payload, 0, $payload.Length)
$client.Flush()
$responsePrefix = [byte[]]::new(4)
Read-Exact $client $responsePrefix
$responseLength = [BitConverter]::ToUInt32($responsePrefix, 0)
if ($responseLength -eq 0 -or $responseLength -gt 65536) {
throw "Broker returned invalid response length: $responseLength"
}
$responsePayload = [byte[]]::new($responseLength)
Read-Exact $client $responsePayload
$response = [Text.Encoding]::UTF8.GetString($responsePayload) | ConvertFrom-Json
if ($response.requestId -ne 'pipe-smoke' -or
$response.outcome.kind -ne 'completed' -or
$response.outcome.exitCode -ne 0) {
throw "Unexpected broker response: $($response | ConvertTo-Json -Compress)"
}
Write-Host "Secure broker pipe AppContainer launch verified: $($response | ConvertTo-Json -Compress)"
} finally {
if ($client) { $client.Dispose() }
if (-not $server.WaitForExit(10000)) {
$server.Kill($true)
throw 'Broker server did not exit after serving one request'
}
if ($server.ExitCode -ne 0) {
throw "Broker server failed: $($server.StandardError.ReadToEnd())"
}
$server.Dispose()
}
@@ -0,0 +1,60 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
$ErrorActionPreference = 'Stop'
$launcherRoot = Join-Path $PSScriptRoot 'launcher'
$launcher = Join-Path $launcherRoot 'target\debug\maka-windows-sandbox.exe'
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
$requestPath = Join-Path $env:RUNNER_TEMP 'maka-windows-sandbox-request.json'
$request = @{
version = 1
requestId = 'ci-process-containment'
executable = $env:ComSpec
arguments = @('/d', '/c', 'exit 0')
cwd = $launcherRoot
readRoots = @()
writeRoots = @()
network = 'enabled'
environment = @{
SystemRoot = $env:SystemRoot
PATH = $env:PATH
}
}
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
$output = & $launcher $requestPath 2>&1
$exitCode = $LASTEXITCODE
$rendered = $output -join "`n"
if ($exitCode -ne 0) {
if ($rendered -match 'child exceeded the 30 second W0 timeout') {
Write-Warning 'The unprivileged restricted-token candidate failed closed: its child did not complete initialization within 30 seconds.'
Write-Host 'This is a recorded W0 incompatibility, not production sandbox evidence.'
exit 0
}
throw "Launcher exited with ${exitCode}: $rendered"
}
if ($rendered -notmatch '"restrictedToken":true') {
throw 'Child process did not receive a restricted token'
}
if ($rendered -notmatch '"inJob":true') {
throw 'Child process was not created inside the Job Object'
}
Write-Host "Restricted token and post-create Job membership verified: $rendered"
@@ -0,0 +1 @@
/target/
+207
View File
@@ -0,0 +1,207 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "maka-windows-sandbox-spike"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
"sha2",
"windows-sys",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,52 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
[package]
name = "maka-windows-sandbox-spike"
version = "0.0.0"
edition = "2024"
publish = false
[[bin]]
name = "maka-windows-sandbox"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
# CreateDesktopW's signature references DEVMODEW (passed as null here), so
# windows-sys only exposes it when Win32_Graphics_Gdi is also enabled.
"Win32_Graphics_Gdi",
"Win32_Security",
# BCryptGenRandom: CSPRNG nonce for per-launch desktop names.
"Win32_Security_Cryptography",
"Win32_Security_Isolation",
"Win32_Security_Authentication_Identity",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_JobObjects",
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Registry",
"Win32_System_StationsAndDesktops",
"Win32_System_Threading",
] }
@@ -0,0 +1,705 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::ffi::OsStr;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::iter;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, INVALID_HANDLE_VALUE, LocalFree,
WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT,
};
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, SDDL_REVISION_1,
SE_KERNEL_OBJECT,
};
use windows_sys::Win32::Security::Isolation::DeleteAppContainerProfile;
use windows_sys::Win32::Security::{
OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES,
};
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_REPARSE_POINT,
FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ,
FILE_SHARE_WRITE, GetFileInformationByHandle, OPEN_EXISTING,
};
use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex, WaitForSingleObject};
use crate::broker_pipe_security::pipe_security_sddl;
use crate::protocol::LaunchRequest;
use crate::windows_launcher::{appcontainer_profile_name, current_user_sid_string, sid_string};
pub(crate) const LEDGER_VERSION: u8 = 2;
const ACL_MUTEX_TIMEOUT_MS: u32 = 30_000;
/// The ledger directory, the icacls grants and the AppContainer profiles are
/// all shared across every session of the user, so the locks that arbitrate
/// them must be machine-wide too. A `Local\` object would be private to one
/// Terminal Services session: a concurrent console/RDP session could not see
/// a live lease and would recover grants that are still in use. The names are
/// scoped by the owning user's SID and the objects carry an explicit
/// SYSTEM+user-only DACL, so another user can neither open them nor learn
/// anything from them. A squatted name fails closed at acquisition: a
/// restrictive squat is rejected by its own DACL, and a permissive squat is
/// rejected by the pre-existing-owner check in `LedgerLock::try_acquire`
/// (the DACL we pass is ignored for an object that already exists).
pub(crate) fn acl_mutex_name(user_sid: &str) -> String {
format!(r"Global\Maka.WindowsSandbox.AclLedger.v2.{user_sid}")
}
/// Serializes the readiness probe's AppContainer profile lifecycle. The probe
/// profile (`maka.readiness.<hash>`) is a per-user, machine-wide registration
/// just like the ledger's objects, so two concurrent probes in different
/// sessions would otherwise delete→create→drop each other's live profile. The
/// lease is scoped by SID and carries the same SYSTEM+owner-only DACL as the
/// ledger mutex (see [`acl_mutex_name`]), so it is `Global\` for the identical
/// cross-session reason and fails closed against a squatted name.
pub(crate) fn readiness_mutex_name(user_sid: &str) -> String {
format!(r"Global\Maka.WindowsSandbox.ReadinessProfile.v1.{user_sid}")
}
/// Timeout for acquiring the readiness profile lease. The readiness probe is a
/// short throwaway `cmd.exe /c exit 0`, so a same-user contender releases the
/// lease well within this bound; exceeding it means a stuck holder and the
/// probe fails closed rather than racing the profile lifecycle unlocked.
pub(crate) const READINESS_MUTEX_TIMEOUT_MS: u32 = 30_000;
/// Distinguishes launch failures by whether the Job was proven empty.
/// Cleanup semantics differ: a settled failure may release grants and
/// ledger normally, while an unsettled one must preserve its recovery
/// state because processes may still hold the launch identity.
#[derive(Debug)]
pub enum LaunchFailure {
/// The failure completed with the Job proven empty.
Settled(String),
/// Termination, waiting or Job accounting failed: the Job could not be
/// proven empty.
Unsettled(String),
}
impl From<String> for LaunchFailure {
fn from(message: String) -> Self {
Self::Settled(message)
}
}
impl LaunchFailure {
pub fn into_message(self) -> String {
match self {
Self::Settled(message) => message,
Self::Unsettled(message) => format!("launch left an unsettled Job: {message}"),
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct Ledger {
pub(crate) version: u8,
pub(crate) request_id: String,
pub(crate) app_container_sid: String,
pub(crate) roots: Vec<LedgerRoot>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct LedgerRoot {
pub(crate) path: String,
pub(crate) read: bool,
pub(crate) write: bool,
pub(crate) read_recursive: bool,
pub(crate) write_recursive: bool,
/// Absent in ledgers written before ACL backups existed; recovery then
/// falls back to removing this ledger's AppContainer SID grants.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) backup_path: Option<String>,
}
pub fn with_acl_grants<T>(
request: &LaunchRequest,
app_container_sid: &str,
launch: impl FnOnce() -> Result<T, LaunchFailure>,
) -> Result<T, LaunchFailure> {
if request.read_roots.is_empty() && request.write_roots.is_empty() {
return launch();
}
let user_sid = current_user_sid_string()?;
let ledger_root = std::env::temp_dir().join("maka-sandbox-acl-ledgers");
fs::create_dir_all(&ledger_root)
.map_err(|error| format!("create ACL ledger directory failed: {error}"))?;
let roots = collect_roots(request)?;
let ledger = Ledger {
version: LEDGER_VERSION,
request_id: request.request_id.clone(),
app_container_sid: app_container_sid.to_owned(),
roots,
};
let ledger_name = format!("{:x}.json", Sha256::digest(request.request_id.as_bytes()));
let ledger_path = ledger_root.join(ledger_name);
// The per-request lease stays owned through child execution and cleanup.
// Recovery can therefore distinguish a live ledger from one abandoned by
// a crashed process without serializing unrelated launches.
let _lease = LedgerLock::acquire(
&ledger_lease_name(&user_sid, &request.request_id),
&user_sid,
ACL_MUTEX_TIMEOUT_MS,
)?;
{
// The OS owns mutex recovery: a crashed process abandons the mutex and
// the next waiter acquires it without parsing a PID file. It protects
// only ACL/ledger mutation, never the child lifetime, so independent
// per-launch AppContainer identities can execute concurrently.
let _lock =
LedgerLock::acquire(&acl_mutex_name(&user_sid), &user_sid, ACL_MUTEX_TIMEOUT_MS)?;
recover_stale(&ledger_root, &user_sid)?;
write_ledger(&ledger_path, &ledger)?;
if let Err(error) = grant_roots(app_container_sid, &ledger.roots) {
let restore = remove_grants(&ledger);
if restore.is_ok() {
let _ = fs::remove_file(&ledger_path);
}
return Err(LaunchFailure::Settled(match restore {
Ok(()) => error,
Err(restore) => format!("{error}; ACL cleanup also failed: {restore}"),
}));
}
}
// Only a launch whose Job is proven empty may enter normal cleanup. An
// unsettled Job may still contain processes holding this launch identity,
// so its grants stay in place and the ledger is quarantined — preserving
// the recovery record for inspection instead of erasing it while the
// boundary might still be live. Recovery never interprets quarantined
// ledgers, and every launch uses a fresh AppContainer identity, so no
// later child can inherit this authority.
let launch_result = match launch() {
Err(LaunchFailure::Unsettled(error)) => {
let quarantine = {
let _lock = LedgerLock::acquire(
&acl_mutex_name(&user_sid),
&user_sid,
ACL_MUTEX_TIMEOUT_MS,
)?;
quarantine_ledger(
&ledger_path,
&format!("launch left an unsettled Job: {error}"),
)
};
return Err(LaunchFailure::Unsettled(match quarantine {
Ok(()) => format!("{error}; recovery state preserved for inspection"),
Err(quarantine) => {
format!("{error}; quarantining recovery state also failed: {quarantine}")
}
}));
}
other => other,
};
let restore_result = {
let _lock =
LedgerLock::acquire(&acl_mutex_name(&user_sid), &user_sid, ACL_MUTEX_TIMEOUT_MS)?;
let result = remove_grants(&ledger);
if result.is_ok() {
fs::remove_file(&ledger_path)
.map_err(|error| format!("remove completed ACL ledger failed: {error}"))?;
}
result
};
match (launch_result, restore_result) {
(Ok(value), Ok(())) => Ok(value),
(Err(error), Ok(())) => Err(error),
(Ok(_), Err(restore)) => Err(LaunchFailure::Settled(restore)),
(Err(error), Err(restore)) => Err(LaunchFailure::Settled(format!(
"{}; ACL cleanup also failed: {restore}",
error.into_message()
))),
}
}
pub(crate) struct LedgerLock {
handle: HANDLE,
}
impl LedgerLock {
pub(crate) fn acquire(name: &str, user_sid: &str, timeout_ms: u32) -> Result<Self, String> {
Self::try_acquire(name, user_sid, timeout_ms)?.ok_or_else(|| {
if name.contains(".AclLease.") {
"acquire ACL ledger lease timed out".to_owned()
} else if name.contains(".ReadinessProfile.") {
"acquire readiness profile lease timed out".to_owned()
} else {
"acquire ACL ledger mutex timed out".to_owned()
}
})
}
fn try_acquire(name: &str, user_sid: &str, timeout_ms: u32) -> Result<Option<Self>, String> {
// Machine-wide named objects are creatable by any local user, so the
// lock is born with an explicit SYSTEM+owner-only DACL instead of the
// default token DACL. That DACL only protects a mutex *we* created:
// `CreateMutexW` ignores the supplied security descriptor when the name
// already exists and simply opens the existing object. A restrictive
// squatter is rejected by its own DACL (open fails, we fail closed),
// but a *permissive* squatter would hand us an attacker-owned
// arbitration object we would then block on. So when the object
// pre-existed (`ERROR_ALREADY_EXISTS` — also the normal same-user
// contention path), the owner is verified to be the current user or
// SYSTEM before any wait; anything else fails closed.
let sddl = lock_sddl(user_sid)?;
let descriptor = lock_security_descriptor(&sddl)?;
let mut attributes: SECURITY_ATTRIBUTES = unsafe { std::mem::zeroed() };
attributes.nLength = size_of::<SECURITY_ATTRIBUTES>() as u32;
attributes.lpSecurityDescriptor = descriptor as *mut std::ffi::c_void;
attributes.bInheritHandle = 0;
let name = wide(name);
let handle = unsafe { CreateMutexW(&attributes, 0, name.as_ptr()) };
// Read the last error before any other call can clobber it.
let already_exists = !handle.is_null() && unsafe { GetLastError() } == ERROR_ALREADY_EXISTS;
let create_error = if handle.is_null() {
Some(last_error("CreateMutexW(ACL ledger mutex)"))
} else {
None
};
unsafe { LocalFree(descriptor as *mut std::ffi::c_void) };
if let Some(error) = create_error {
return Err(error);
}
if already_exists {
if let Err(error) = validate_existing_lock_owner(handle, user_sid) {
unsafe { CloseHandle(handle) };
return Err(error);
}
}
let wait = unsafe { WaitForSingleObject(handle, timeout_ms) };
if wait == WAIT_OBJECT_0 || wait == WAIT_ABANDONED {
return Ok(Some(Self { handle }));
}
unsafe { CloseHandle(handle) };
if wait == WAIT_TIMEOUT {
return Ok(None);
}
Err(last_error("WaitForSingleObject(ACL ledger mutex)"))
}
}
/// Security descriptor for a named lock. Beyond the SYSTEM+user-only DACL, the
/// owner is pinned explicitly to the user SID: without `O:`, the object owner
/// comes from the creating token's *default* owner, which is the user SID for a
/// standard token but `BUILTIN\Administrators` for an elevated one — so the
/// same user's elevated and non-elevated processes would create locks with
/// different owners and each reject the other's legitimate lock. Pinning the
/// owner makes the lock's identity elevation-independent (the user SID is
/// always an assignable owner for the user's own token, elevated or not).
pub(crate) fn lock_sddl(user_sid: &str) -> Result<String, String> {
let dacl = pipe_security_sddl(user_sid)
.map_err(|error| format!("invalid ACL lock owner SID: {error:?}"))?;
Ok(format!("O:{user_sid}{dacl}"))
}
/// Owner check for a named lock that existed before this process created it.
/// The pre-existing object keeps whatever security descriptor its creator gave
/// it, so ownership is the one property a permissive squatter cannot forge:
/// re-owning an object to another SID requires SeTakeOwnership/SeRestore, which
/// standard users do not hold. Legitimate owners are exactly three: the current
/// user SID (what `lock_sddl` pins at creation, in any elevation state),
/// SYSTEM, and `BUILTIN\Administrators` — the last because locks created by
/// builds that predate the owner pinning (or by other elevated tooling of this
/// user) carry the elevated token's default owner, and whoever can create
/// Administrators-owned objects is an elevated administrator, which RFC §1/§5
/// explicitly does not defend against. Any other owner means the name was
/// squatted and arbitration must fail closed instead of blocking on an
/// attacker-held mutex.
fn validate_existing_lock_owner(handle: HANDLE, user_sid: &str) -> Result<(), String> {
const SYSTEM_SID: &str = "S-1-5-18";
const ADMINISTRATORS_SID: &str = "S-1-5-32-544";
let mut owner: *mut std::ffi::c_void = std::ptr::null_mut();
let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
let status = unsafe {
GetSecurityInfo(
handle,
SE_KERNEL_OBJECT,
OWNER_SECURITY_INFORMATION,
&mut owner,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut descriptor,
)
};
if status != 0 {
return Err(format!(
"GetSecurityInfo(pre-existing lock owner) failed with error {status}"
));
}
let rendered = unsafe { sid_string(owner) };
unsafe { LocalFree(descriptor as *mut std::ffi::c_void) };
let rendered = rendered?;
if rendered.eq_ignore_ascii_case(user_sid)
|| rendered == SYSTEM_SID
|| rendered == ADMINISTRATORS_SID
{
return Ok(());
}
Err(format!(
"pre-existing lock is owned by {rendered}, not the current user \
({user_sid}), SYSTEM, or Administrators; refusing to arbitrate on a \
squatted mutex"
))
}
fn lock_security_descriptor(sddl: &str) -> Result<PSECURITY_DESCRIPTOR, String> {
let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
let sddl_wide = wide(sddl);
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl_wide.as_ptr(),
SDDL_REVISION_1,
&mut descriptor,
std::ptr::null_mut(),
)
} == 0
{
return Err(last_error(
"ConvertStringSecurityDescriptorToSecurityDescriptorW(ACL lock)",
));
}
Ok(descriptor)
}
impl Drop for LedgerLock {
fn drop(&mut self) {
unsafe {
ReleaseMutex(self.handle);
CloseHandle(self.handle);
}
}
}
pub(crate) fn collect_roots(request: &LaunchRequest) -> Result<Vec<LedgerRoot>, String> {
let mut roots = Vec::new();
for path in request.read_roots.iter().chain(&request.write_roots) {
if roots
.iter()
.any(|entry: &LedgerRoot| entry.path.eq_ignore_ascii_case(path))
{
continue;
}
// A root that does not exist yet (e.g. the exact target of a write
// that will create the file) has nothing to grant; access to it is
// governed by its existing ancestor's grants. Skipping keeps the
// default-deny posture rather than failing the launch.
let metadata = match fs::symlink_metadata(Path::new(path)) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(format!("inspect ACL root {path} failed: {error}"));
}
};
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(format!("ACL root contains a reparse point: {path}"));
}
// An icacls grant lands on the file object, not the name: every NTFS
// hard link — including aliases outside the declared root — shares the
// DACL that the grant mutates. Path-keyed admission cannot enumerate
// those aliases, so multi-link files fail closed here; handle/file-ID
// keyed admission is follow-up work. Directories cannot be
// hard-linked on NTFS, so only files need the check.
if !metadata.is_dir() {
reject_multi_link_file(Path::new(path))?;
}
// Only a recursive grant extends into the tree, so only a recursive
// grant requires the tree to be alias-free. Exact roots (e.g. the
// cwd metadata anchor) may legitimately contain junctions deeper in
// the workspace that the sandbox never grants.
let recursive_read = contains_path(&request.read_roots, path)
&& !contains_path(&request.exact_read_roots, path);
let recursive_write = contains_path(&request.write_roots, path)
&& !contains_path(&request.exact_write_roots, path);
if metadata.is_dir() && (recursive_read || recursive_write) {
reject_aliased_entries(Path::new(path))?;
}
roots.push(LedgerRoot {
path: path.clone(),
read: contains_path(&request.read_roots, path),
write: contains_path(&request.write_roots, path),
read_recursive: metadata.is_dir() && recursive_read,
write_recursive: metadata.is_dir() && recursive_write,
backup_path: None,
});
}
Ok(roots)
}
fn contains_path(paths: &[String], path: &str) -> bool {
paths.iter().any(|entry| entry.eq_ignore_ascii_case(path))
}
/// Rejects reparse points and multi-link files anywhere in a recursively
/// granted tree. An `(OI)(CI)` grant propagates inherited ACEs onto the
/// existing children at grant time, so a file inside the tree that also has a
/// hard link outside it would carry the grant past the declared root.
fn reject_aliased_entries(path: &Path) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("inspect ACL root {} failed: {error}", path.display()))?;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(format!(
"ACL root contains a reparse point: {}",
path.display()
));
}
if metadata.is_dir() {
for entry in fs::read_dir(path)
.map_err(|error| format!("scan ACL root {} failed: {error}", path.display()))?
{
let entry = entry.map_err(|error| format!("scan ACL root failed: {error}"))?;
reject_aliased_entries(&entry.path())?;
}
} else {
reject_multi_link_file(path)?;
}
Ok(metadata)
}
/// Fails closed on files whose kernel link count exceeds one. The DACL that a
/// grant mutates belongs to the file object shared by every hard link, so a
/// path-keyed admission that only sees one alias must not grant through it.
fn reject_multi_link_file(path: &Path) -> Result<(), String> {
let wide_path = wide(&path.to_string_lossy());
let handle = unsafe {
CreateFileW(
wide_path.as_ptr(),
0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
std::ptr::null(),
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
return Err(last_error(&format!(
"CreateFileW(inspect ACL root {})",
path.display()
)));
}
let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let queried = unsafe { GetFileInformationByHandle(handle, &mut information) };
unsafe { CloseHandle(handle) };
if queried == 0 {
return Err(last_error(&format!(
"GetFileInformationByHandle(ACL root {})",
path.display()
)));
}
if information.nNumberOfLinks > 1 {
return Err(format!(
"ACL root contains a multi-link file: {}",
path.display()
));
}
Ok(())
}
pub(crate) fn write_ledger(path: &Path, ledger: &Ledger) -> Result<(), String> {
let bytes = serde_json::to_vec(ledger).map_err(|error| error.to_string())?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|error| format!("create ACL ledger failed: {error}"))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| format!("persist ACL ledger failed: {error}"))
}
pub(crate) fn recover_stale(root: &Path, user_sid: &str) -> Result<(), String> {
for entry in fs::read_dir(root).map_err(|error| format!("read ACL ledgers failed: {error}"))? {
let path = entry
.map_err(|error| format!("read ACL ledger entry failed: {error}"))?
.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let source = fs::read(&path)
.map_err(|error| format!("read stale ACL ledger {} failed: {error}", path.display()))?;
let ledger: Ledger = match serde_json::from_slice(&source) {
Ok(ledger) => ledger,
Err(error) => {
quarantine_ledger(&path, &format!("invalid stale ACL ledger: {error}"))?;
continue;
}
};
if ledger.version != LEDGER_VERSION {
quarantine_ledger(
&path,
&format!("unsupported ACL ledger version {}", ledger.version),
)?;
continue;
}
let lease_name = ledger_lease_name(user_sid, &ledger.request_id);
let Some(_stale_lease) = LedgerLock::try_acquire(&lease_name, user_sid, 0)? else {
// The owner keeps this request-specific mutex for the entire child
// and cleanup lifetime. A busy lease means this is an active
// ledger, not stale recovery work.
continue;
};
// A root the user could grant but cannot un-grant (e.g. an ACL-locked
// file deep in the tree) must not brick every future launch. This is
// safe only because every request has a fresh AppContainer identity:
// no later child can receive the quarantined ledger's SID.
if let Err(error) = remove_grants(&ledger) {
quarantine_ledger(&path, &format!("stale ACL ledger recovery failed: {error}"))?;
continue;
}
let profile_name = appcontainer_profile_name(&ledger.request_id);
unsafe { DeleteAppContainerProfile(profile_name.as_ptr()) };
fs::remove_file(&path).map_err(|error| {
format!("remove stale ACL ledger {} failed: {error}", path.display())
})?;
}
Ok(())
}
fn grant_roots(sid: &str, ledger_roots: &[LedgerRoot]) -> Result<(), String> {
for root in ledger_roots {
if root.read {
grant(&root.path, sid, "RX", root.read_recursive)?;
}
if root.write {
grant(&root.path, sid, "M", root.write_recursive)?;
}
}
Ok(())
}
pub(crate) fn ledger_lease_name(user_sid: &str, request_id: &str) -> String {
format!(
r"Global\Maka.WindowsSandbox.AclLease.{user_sid}.{:x}",
Sha256::digest(request_id.as_bytes())
)
}
fn grant(path: &str, sid: &str, access: &str, recursive: bool) -> Result<(), String> {
let permission = if recursive {
format!("*{sid}:(OI)(CI){access}")
} else {
format!("*{sid}:{access}")
};
let mut args = vec![path, "/grant", &permission, "/L", "/Q"];
if recursive {
args.push("/T");
}
run_icacls(&args, "grant")
}
/// Undo the grants recorded by a ledger by removing exactly this
/// AppContainer SID's ACEs. The broker's only ACL mutation is adding grants
/// for its own per-app SID, so targeted removal restores the prior state while
/// preserving every pre-existing ACE. `icacls /save`+`/restore` is not usable
/// here: `/restore` requires SeRestorePrivilege (fails in the non-elevated
/// desktop process) and saved entries are relative to the saved path's parent.
fn remove_grants(ledger: &Ledger) -> Result<(), String> {
for root in &ledger.roots {
// Backups written by older broker builds are obsolete; drop them.
if let Some(backup) = root.backup_path.as_deref() {
let _ = fs::remove_file(backup);
}
if !Path::new(&root.path).exists() {
continue;
}
remove_sid_grants(
&root.path,
&ledger.app_container_sid,
root.read_recursive || root.write_recursive,
)?;
}
Ok(())
}
fn remove_sid_grants(path: &str, sid: &str, recursive: bool) -> Result<(), String> {
let principal = format!("*{sid}");
let mut args = vec![path, "/remove", &principal, "/L", "/Q"];
if recursive {
args.push("/T");
}
run_icacls(&args, "remove")
}
/// Preserve an unreadable ledger for inspection instead of letting one corrupt
/// file permanently block every future sandbox launch. The rename keeps the
/// evidence (`*.json.quarantined` no longer matches the recovery scan) and the
/// warning keeps the failure loud on stderr.
fn quarantine_ledger(path: &Path, reason: &str) -> Result<(), String> {
let mut quarantined = path.as_os_str().to_owned();
quarantined.push(".quarantined");
let quarantined = PathBuf::from(quarantined);
let _ = fs::remove_file(&quarantined);
fs::rename(path, &quarantined)
.map_err(|error| format!("quarantine ACL ledger {} failed: {error}", path.display()))?;
eprintln!(
"maka-windows-sandbox: {reason}; quarantined {} to {}",
path.display(),
quarantined.display()
);
Ok(())
}
fn run_icacls(args: &[&str], operation: &str) -> Result<(), String> {
let executable = system_icacls()?;
let output = Command::new(&executable)
.args(args)
.output()
.map_err(|error| format!("start {} failed: {error}", executable.display()))?;
if output.status.success() {
return Ok(());
}
Err(format!(
"icacls {operation} failed with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
))
}
fn system_icacls() -> Result<PathBuf, String> {
let system_root = std::env::var_os("SystemRoot")
.ok_or_else(|| "SystemRoot is unavailable to the broker".to_owned())?;
let path = PathBuf::from(system_root)
.join("System32")
.join("icacls.exe");
if !path.is_file() {
return Err(format!("system icacls is unavailable: {}", path.display()));
}
Ok(path)
}
fn wide(value: &str) -> Vec<u16> {
OsStr::new(value)
.encode_wide()
.chain(iter::once(0))
.collect()
}
fn last_error(operation: &str) -> String {
format!("{operation} failed: {}", std::io::Error::last_os_error())
}
@@ -0,0 +1,566 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use sha2::{Digest, Sha256};
use crate::acl_ledger::{
LEDGER_VERSION, LaunchFailure, Ledger, LedgerRoot, collect_roots, recover_stale,
with_acl_grants, write_ledger,
};
use crate::protocol::{LaunchRequest, NetworkMode};
// Synthetic AppContainer-shaped SIDs so the tests never touch the real
// per-app profile. icacls accepts arbitrary `*SID` principals and renders
// unresolvable SIDs verbatim in its output, which keeps assertions
// locale-independent.
const APP_SID: &str =
"S-1-15-2-1111111111-1222222222-1333333333-1444444444-1555555555-1666666666-1777777777";
const MARKER_SID: &str =
"S-1-15-2-1777777777-1666666666-1555555555-1444444444-1333333333-1222222222-1111111111";
struct Fixture {
ledger_dir: PathBuf,
target: PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let base = std::env::temp_dir().join(format!(
"maka-acl-ledger-tests-{}-{name}",
std::process::id()
));
let _ = fs::remove_dir_all(&base);
let ledger_dir = base.join("ledgers");
let target = base.join("target");
fs::create_dir_all(&ledger_dir).expect("create ledger dir");
fs::create_dir_all(target.join("child")).expect("create target tree");
fs::write(target.join("child").join("file.txt"), "payload").expect("seed file");
Self { ledger_dir, target }
}
fn target_str(&self) -> String {
self.target.to_string_lossy().into_owned()
}
fn ledger_path(&self, file_name: &str) -> PathBuf {
self.ledger_dir.join(file_name)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
if let Some(base) = self.ledger_dir.parent() {
let _ = fs::remove_dir_all(base);
}
}
}
fn icacls(args: &[&str]) -> String {
let output = Command::new("icacls.exe")
.args(args)
.output()
.expect("run icacls");
assert!(
output.status.success(),
"icacls {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn grant_recursive(path: &Path, sid: &str) {
let grant = format!("*{sid}:(OI)(CI)RX");
icacls(&[
path.to_str().expect("path"),
"/grant",
&grant,
"/L",
"/Q",
"/T",
]);
}
fn sid_listed(path: &Path, sid: &str) -> bool {
icacls(&[path.to_str().expect("path")]).contains(sid)
}
fn user_sid() -> String {
crate::windows_launcher::current_user_sid_string().expect("current user SID")
}
fn ledger(request_id: &str, roots: Vec<LedgerRoot>) -> Ledger {
Ledger {
version: LEDGER_VERSION,
request_id: request_id.to_owned(),
app_container_sid: APP_SID.to_owned(),
roots,
}
}
#[test]
fn quarantines_legacy_stable_identity_ledgers_without_interpreting_them() {
let fixture = Fixture::new("old-format");
grant_recursive(&fixture.target, APP_SID);
assert!(sid_listed(&fixture.target, APP_SID));
let ledger_path = fixture.ledger_path("old.json");
fs::write(
&ledger_path,
serde_json::json!({
"version": 1,
"requestId": "stale-old",
"appContainerSid": APP_SID,
"roots": [{ "path": fixture.target_str(), "recursive": true }],
})
.to_string(),
)
.expect("write old-format ledger");
recover_stale(&fixture.ledger_dir, &user_sid()).expect("quarantine old-format ledger");
assert!(sid_listed(&fixture.target, APP_SID));
assert!(sid_listed(&fixture.target.join("child"), APP_SID));
assert!(!ledger_path.exists());
assert!(fixture.ledger_path("old.json.quarantined").exists());
}
#[test]
fn recovery_preserves_unrelated_aces_and_drops_obsolete_backups() {
let fixture = Fixture::new("preserve");
grant_recursive(&fixture.target, MARKER_SID);
grant_recursive(&fixture.target, APP_SID);
let backup = fixture.ledger_path("obsolete.acl");
fs::write(&backup, "obsolete backup contents").expect("seed backup");
let ledger_path = fixture.ledger_path("with-backup.json");
write_ledger(
&ledger_path,
&ledger(
"preserve",
vec![LedgerRoot {
path: fixture.target_str(),
read: true,
write: false,
read_recursive: true,
write_recursive: false,
backup_path: Some(backup.to_string_lossy().into_owned()),
}],
),
)
.expect("write ledger");
recover_stale(&fixture.ledger_dir, &user_sid()).expect("recover ledger with backup");
assert!(!sid_listed(&fixture.target, APP_SID));
assert!(sid_listed(&fixture.target, MARKER_SID));
assert!(sid_listed(&fixture.target.join("child"), MARKER_SID));
assert!(!backup.exists());
assert!(!ledger_path.exists());
}
#[test]
fn recovery_skips_missing_roots() {
let fixture = Fixture::new("missing-root");
let missing = fixture.target.join("does-not-exist");
let ledger_path = fixture.ledger_path("missing-root.json");
write_ledger(
&ledger_path,
&ledger(
"missing-root",
vec![LedgerRoot {
path: missing.to_string_lossy().into_owned(),
read: true,
write: false,
read_recursive: true,
write_recursive: false,
backup_path: None,
}],
),
)
.expect("write ledger");
recover_stale(&fixture.ledger_dir, &user_sid()).expect("recover ledger with missing root");
assert!(!ledger_path.exists());
}
#[test]
fn corrupt_ledger_is_quarantined_and_siblings_still_recover() {
let fixture = Fixture::new("corrupt");
grant_recursive(&fixture.target, APP_SID);
let corrupt = fixture.ledger_path("corrupt.json");
fs::write(&corrupt, "{ not json").expect("write corrupt ledger");
let valid = fixture.ledger_path("valid.json");
write_ledger(
&valid,
&ledger(
"corrupt-valid",
vec![LedgerRoot {
path: fixture.target_str(),
read: true,
write: false,
read_recursive: true,
write_recursive: false,
backup_path: None,
}],
),
)
.expect("write valid ledger");
recover_stale(&fixture.ledger_dir, &user_sid())
.expect("recovery continues past corrupt ledger");
assert!(!corrupt.exists());
assert!(fixture.ledger_path("corrupt.json.quarantined").exists());
assert!(!sid_listed(&fixture.target, APP_SID));
assert!(!valid.exists());
}
#[test]
fn unsupported_version_is_quarantined_without_acl_changes() {
let fixture = Fixture::new("version");
grant_recursive(&fixture.target, APP_SID);
let ledger_path = fixture.ledger_path("future.json");
fs::write(
&ledger_path,
serde_json::json!({
"version": 99,
"requestId": "future",
"appContainerSid": APP_SID,
"roots": [{ "path": fixture.target_str(), "recursive": true }],
})
.to_string(),
)
.expect("write future-version ledger");
recover_stale(&fixture.ledger_dir, &user_sid())
.expect("recovery quarantines future version");
assert!(!ledger_path.exists());
assert!(fixture.ledger_path("future.json.quarantined").exists());
// An unknown version must not be interpreted: grants stay until a
// build that understands the format recovers them.
assert!(sid_listed(&fixture.target, APP_SID));
}
#[test]
fn unknown_fields_are_rejected_and_quarantined() {
let fixture = Fixture::new("unknown-field");
let ledger_path = fixture.ledger_path("extra.json");
fs::write(
&ledger_path,
serde_json::json!({
"version": 1,
"requestId": "extra",
"appContainerSid": APP_SID,
"surprise": true,
"roots": [],
})
.to_string(),
)
.expect("write ledger with unknown field");
recover_stale(&fixture.ledger_dir, &user_sid())
.expect("recovery quarantines unknown fields");
assert!(!ledger_path.exists());
assert!(fixture.ledger_path("extra.json.quarantined").exists());
}
#[test]
fn unrecoverable_ledger_is_quarantined_instead_of_blocking_recovery() {
let fixture = Fixture::new("unrecoverable");
let ledger_path = fixture.ledger_path("stuck.json");
// An invalid SID makes icacls /remove fail exactly like a root whose
// grants cannot be removed; recovery must quarantine and move on.
write_ledger(
&ledger_path,
&Ledger {
version: LEDGER_VERSION,
request_id: "stuck".to_owned(),
app_container_sid: "not-a-sid".to_owned(),
roots: vec![LedgerRoot {
path: fixture.target_str(),
read: true,
write: false,
read_recursive: true,
write_recursive: false,
backup_path: None,
}],
},
)
.expect("write stuck ledger");
recover_stale(&fixture.ledger_dir, &user_sid())
.expect("recovery continues past stuck ledger");
assert!(!ledger_path.exists());
assert!(fixture.ledger_path("stuck.json.quarantined").exists());
}
fn launch_request(
read_roots: Vec<String>,
exact_read_roots: Vec<String>,
write_roots: Vec<String>,
exact_write_roots: Vec<String>,
) -> LaunchRequest {
LaunchRequest {
version: 1,
request_id: "hard-link-admission".to_owned(),
executable: "C:\\Windows\\System32\\cmd.exe".to_owned(),
arguments: Vec::new(),
cwd: "C:\\".to_owned(),
read_roots,
write_roots,
exact_read_roots,
exact_write_roots,
network: NetworkMode::Restricted,
environment: BTreeMap::new(),
timeout_ms: None,
}
}
#[test]
fn multi_link_file_root_fails_closed() {
let fixture = Fixture::new("hardlink-root");
let original = fixture.target.join("child").join("file.txt");
let alias = fixture.target.join("alias.txt");
fs::hard_link(&original, &alias).expect("create hard link");
let root = original.to_string_lossy().into_owned();
let request = launch_request(vec![root.clone()], vec![root], Vec::new(), Vec::new());
let error = collect_roots(&request).expect_err("multi-link file root must fail closed");
assert!(error.contains("multi-link"), "unexpected error: {error}");
}
#[test]
fn multi_link_file_inside_recursive_root_fails_closed() {
let fixture = Fixture::new("hardlink-tree");
// The alias inside the granted tree shares its file object with a
// name outside it; an (OI)(CI) grant would follow that object out.
let outside = fixture
.target
.parent()
.expect("fixture base")
.join("outside.txt");
fs::write(&outside, "outside payload").expect("seed outside file");
fs::hard_link(&outside, fixture.target.join("child").join("linked.txt"))
.expect("create hard link into tree");
let request = launch_request(
vec![fixture.target_str()],
Vec::new(),
Vec::new(),
Vec::new(),
);
let error = collect_roots(&request).expect_err("multi-link file in tree must fail closed");
assert!(error.contains("multi-link"), "unexpected error: {error}");
}
#[test]
fn single_link_tree_admits_and_multi_link_below_exact_root_is_out_of_scope() {
let fixture = Fixture::new("hardlink-exact");
let roots = collect_roots(&launch_request(
vec![fixture.target_str()],
Vec::new(),
Vec::new(),
Vec::new(),
))
.expect("single-link tree admits");
assert_eq!(roots.len(), 1);
assert!(roots[0].read_recursive);
// An exact grant mutates only the directory object itself; entries
// below it receive no ACE, so aliases deeper in the tree stay out of
// admission scope exactly like reparse points already do.
let outside = fixture
.target
.parent()
.expect("fixture base")
.join("exact-outside.txt");
fs::write(&outside, "outside payload").expect("seed outside file");
fs::hard_link(&outside, fixture.target.join("child").join("linked.txt"))
.expect("create hard link into tree");
let roots = collect_roots(&launch_request(
vec![fixture.target_str()],
vec![fixture.target_str()],
Vec::new(),
Vec::new(),
))
.expect("exact root skips tree scan");
assert_eq!(roots.len(), 1);
assert!(!roots[0].read_recursive);
}
fn shared_ledger_dir() -> PathBuf {
std::env::temp_dir().join("maka-sandbox-acl-ledgers")
}
fn remove_recursive_grant(path: &Path, sid: &str) {
let principal = format!("*{sid}");
icacls(&[
path.to_str().expect("path"),
"/remove",
&principal,
"/L",
"/Q",
"/T",
]);
}
#[test]
fn unsettled_launch_preserves_grants_and_quarantines_the_ledger() {
let fixture = Fixture::new("unsettled-launch");
let root = fixture.target_str();
let mut request = launch_request(vec![root], Vec::new(), Vec::new(), Vec::new());
request.request_id = format!("unsettled-injection-{}", std::process::id());
let ledger_name = format!("{:x}.json", Sha256::digest(request.request_id.as_bytes()));
let ledger_path = shared_ledger_dir().join(&ledger_name);
let quarantined = shared_ledger_dir().join(format!("{ledger_name}.quarantined"));
let result = with_acl_grants(&request, APP_SID, || -> Result<(), LaunchFailure> {
assert!(
sid_listed(&fixture.target, APP_SID),
"grant must be live while the child runs"
);
Err(LaunchFailure::Unsettled(
"injected settlement failure".to_owned(),
))
});
let message = match result {
Err(LaunchFailure::Unsettled(message)) => message,
other => panic!("expected an unsettled failure, got {other:?}"),
};
assert!(message.contains("injected settlement failure"), "{message}");
assert!(message.contains("preserved"), "{message}");
// Processes may still hold the launch identity: the grant must stay
// and the ledger must survive as a quarantined recovery record.
assert!(sid_listed(&fixture.target, APP_SID));
assert!(!ledger_path.exists());
assert!(quarantined.exists());
let _ = fs::remove_file(&quarantined);
remove_recursive_grant(&fixture.target, APP_SID);
}
#[test]
fn settled_launch_failure_still_releases_grants_and_ledger() {
let fixture = Fixture::new("settled-launch");
let root = fixture.target_str();
let mut request = launch_request(vec![root], Vec::new(), Vec::new(), Vec::new());
request.request_id = format!("settled-injection-{}", std::process::id());
let ledger_name = format!("{:x}.json", Sha256::digest(request.request_id.as_bytes()));
let result = with_acl_grants(&request, APP_SID, || -> Result<(), LaunchFailure> {
Err(LaunchFailure::Settled(
"injected settled failure".to_owned(),
))
});
match result {
Err(LaunchFailure::Settled(message)) => {
assert!(message.contains("injected settled failure"), "{message}");
}
other => panic!("expected a settled failure, got {other:?}"),
}
// A proven-empty Job enters normal cleanup: no grant, no ledger.
assert!(!sid_listed(&fixture.target, APP_SID));
assert!(!shared_ledger_dir().join(&ledger_name).exists());
assert!(
!shared_ledger_dir()
.join(format!("{ledger_name}.quarantined"))
.exists()
);
}
#[test]
fn lock_names_are_machine_wide_and_scoped_to_the_owning_user() {
// The ledger directory, icacls grants and AppContainer profiles are
// shared across sessions; a `Local\` lock would let a concurrent
// console/RDP session mistake a live lease for a stale one.
let sid = user_sid();
let mutex_name = crate::acl_ledger::acl_mutex_name(&sid);
let lease_name = crate::acl_ledger::ledger_lease_name(&sid, "request");
for name in [&mutex_name, &lease_name] {
assert!(name.starts_with(r"Global\Maka.WindowsSandbox."), "{name}");
assert!(name.contains(&sid), "{name}");
}
assert!(lease_name.contains(".AclLease."), "{lease_name}");
// Distinct request ids must never share a lease object.
assert_ne!(
lease_name,
crate::acl_ledger::ledger_lease_name(&sid, "request-2")
);
}
#[test]
fn new_ledgers_serialize_without_backup_path() {
let value = serde_json::to_value(ledger(
"serialization",
vec![LedgerRoot {
path: "C:\\workspace".to_owned(),
read: true,
write: true,
read_recursive: false,
write_recursive: true,
backup_path: None,
}],
))
.expect("serialize ledger");
let root = &value["roots"][0];
assert!(root.get("backupPath").is_none());
assert_eq!(root["path"], "C:\\workspace");
assert_eq!(root["read"], true);
assert_eq!(root["write"], true);
assert_eq!(root["readRecursive"], false);
assert_eq!(root["writeRecursive"], true);
}
#[test]
fn lock_sddl_pins_an_elevation_independent_owner() {
// Without an explicit `O:` clause the object owner comes from the
// creating token's *default* owner — the user SID for a standard token
// but BUILTIN\Administrators for an elevated one — so the same user's
// elevated and non-elevated processes would create locks with different
// owners and each reject the other's legitimate lock. Pinning the owner
// keeps the lock identity stable across elevation states.
let sid = "S-1-5-21-11111111-22222222-33333333-1001";
let sddl = crate::acl_ledger::lock_sddl(sid).expect("lock SDDL");
assert!(
sddl.starts_with(&format!("O:{sid}D:P")),
"owner must be pinned before the protected DACL: {sddl}"
);
assert!(
sddl.contains("(A;;GA;;;SY)"),
"SYSTEM retains access: {sddl}"
);
assert!(
sddl.contains(&format!("(A;;GA;;;{sid})")),
"the owning user retains access: {sddl}"
);
}
}
@@ -0,0 +1,117 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::collections::HashSet;
use crate::protocol::{BrokerLaunchRequest, launch_digest};
const MAX_REMEMBERED_NONCES: usize = 4096;
pub struct BrokerAuthorizer {
allowed_profile_digests: HashSet<String>,
used_nonces: HashSet<String>,
}
impl BrokerAuthorizer {
pub fn new(allowed_profile_digests: impl IntoIterator<Item = String>) -> Self {
Self {
allowed_profile_digests: allowed_profile_digests
.into_iter()
.map(|digest| digest.to_ascii_lowercase())
.collect(),
used_nonces: HashSet::new(),
}
}
pub fn authorize(
&mut self,
request: &BrokerLaunchRequest,
connected_client_pid: u32,
) -> Result<(), BrokerAuthorizationError> {
request
.validate()
.map_err(BrokerAuthorizationError::InvalidRequest)?;
if request.client_pid != connected_client_pid {
return Err(BrokerAuthorizationError::ClientPidMismatch);
}
let computed_digest =
launch_digest(&request.launch).map_err(BrokerAuthorizationError::InvalidRequest)?;
if !request
.profile_digest
.eq_ignore_ascii_case(&computed_digest)
{
return Err(BrokerAuthorizationError::ProfileDigestMismatch);
}
if !self
.allowed_profile_digests
.contains(&request.profile_digest.to_ascii_lowercase())
{
return Err(BrokerAuthorizationError::ProfileNotApproved);
}
let nonce = request.client_nonce.to_ascii_lowercase();
if self.used_nonces.contains(&nonce) {
return Err(BrokerAuthorizationError::NonceReplayed);
}
if self.used_nonces.len() >= MAX_REMEMBERED_NONCES {
return Err(BrokerAuthorizationError::NonceCapacityExceeded);
}
self.used_nonces.insert(nonce);
Ok(())
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum BrokerAuthorizationError {
InvalidRequest(String),
ClientPidMismatch,
ProfileNotApproved,
ProfileDigestMismatch,
NonceReplayed,
NonceCapacityExceeded,
}
impl BrokerAuthorizationError {
pub fn code(&self) -> &'static str {
match self {
Self::InvalidRequest(_) => "invalid_request",
Self::ClientPidMismatch => "client_pid_mismatch",
Self::ProfileNotApproved => "profile_not_approved",
Self::ProfileDigestMismatch => "profile_digest_mismatch",
Self::NonceReplayed => "nonce_replayed",
Self::NonceCapacityExceeded => "nonce_capacity_exceeded",
}
}
pub fn message(&self) -> String {
match self {
Self::InvalidRequest(message) => message.clone(),
Self::ClientPidMismatch => {
"request clientPid does not match the connected pipe client".to_owned()
}
Self::ProfileNotApproved => "profileDigest is not approved by the broker".to_owned(),
Self::ProfileDigestMismatch => {
"profileDigest does not match the canonical launch policy".to_owned()
}
Self::NonceReplayed => "clientNonce has already been used".to_owned(),
Self::NonceCapacityExceeded => {
"broker nonce capacity is exhausted; restart is required".to_owned()
}
}
}
}
@@ -0,0 +1,108 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(test)]
mod tests {
use crate::broker_authorization::{BrokerAuthorizationError, BrokerAuthorizer};
use crate::protocol::{BrokerLaunchRequest, launch_digest};
fn request(nonce: &str) -> BrokerLaunchRequest {
let mut request: BrokerLaunchRequest = serde_json::from_value(serde_json::json!({
"version": 1,
"requestId": "broker-1",
"clientPid": 42,
"clientNonce": nonce,
"profileDigest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"launch": {
"version": 1,
"requestId": "launch-1",
"executable": "C:\\Windows\\System32\\cmd.exe",
"arguments": ["/d", "/c", "exit 0"],
"cwd": "C:\\Windows",
"readRoots": [],
"writeRoots": [],
"network": "enabled",
"environment": {}
}
}))
.expect("valid request");
request.profile_digest = launch_digest(&request.launch).expect("launch digest");
request
}
#[test]
fn binds_authorization_to_connected_client_pid() {
let value = request("0123456789abcdef0123456789abcdef");
let mut authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
assert_eq!(
authorizer.authorize(&value, 43),
Err(BrokerAuthorizationError::ClientPidMismatch)
);
}
#[test]
fn rejects_unapproved_profile_without_consuming_nonce() {
let nonce = "0123456789abcdef0123456789abcdef";
let value = request(nonce);
let mut authorizer = BrokerAuthorizer::new([]);
assert_eq!(
authorizer.authorize(&value, 42),
Err(BrokerAuthorizationError::ProfileNotApproved)
);
authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
assert_eq!(authorizer.authorize(&value, 42), Ok(()));
}
#[test]
fn rejects_replayed_nonce_after_successful_authorization() {
let nonce = "0123456789abcdef0123456789abcdef";
let value = request(nonce);
let mut authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
assert_eq!(authorizer.authorize(&value, 42), Ok(()));
assert_eq!(
authorizer.authorize(&value, 42),
Err(BrokerAuthorizationError::NonceReplayed)
);
}
#[test]
fn rejects_replayed_nonce_with_different_hex_case() {
let value = request("0123456789abcdef0123456789abcdef");
let mut variant = value.clone();
variant.client_nonce = "0123456789ABCDEF0123456789ABCDEF".to_owned();
let mut authorizer = BrokerAuthorizer::new([value.profile_digest.clone()]);
assert_eq!(authorizer.authorize(&value, 42), Ok(()));
assert_eq!(
authorizer.authorize(&variant, 42),
Err(BrokerAuthorizationError::NonceReplayed)
);
}
#[test]
fn rejects_a_digest_that_is_not_bound_to_the_launch_policy() {
let mut value = request("0123456789abcdef0123456789abcdef");
let approved = value.profile_digest.clone();
value.launch.arguments.push("tampered".to_owned());
let mut authorizer = BrokerAuthorizer::new([approved]);
assert_eq!(
authorizer.authorize(&value, 42),
Err(BrokerAuthorizationError::ProfileDigestMismatch)
);
}
}
@@ -0,0 +1,319 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::ffi::OsStr;
use std::fs;
use std::iter;
use std::os::windows::ffi::OsStrExt;
use std::ptr::{null, null_mut};
use std::thread;
use std::time::{Duration, Instant};
use windows_sys::Win32::Foundation::{
CloseHandle, FILETIME, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, OPEN_EXISTING, ReadFile, SYNCHRONIZE, WriteFile,
};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::Threading::{
GetCurrentProcess, GetCurrentProcessId, GetProcessId, GetProcessTimes, OpenProcess,
PROCESS_QUERY_LIMITED_INFORMATION,
};
use crate::broker_authorization::BrokerAuthorizer;
use crate::broker_framing::{MAX_BROKER_MESSAGE_BYTES, decode_frame, encode_frame};
use crate::broker_pipe_security::validate_pipe_name;
use crate::protocol::{BrokerLaunchOutcome, BrokerLaunchRequest, BrokerLaunchResponse};
use crate::windows_launcher;
pub fn run_local(manifest_path: &str) -> Result<u8, String> {
let source = fs::read_to_string(manifest_path)
.map_err(|error| format!("read local broker manifest failed: {error}"))?;
fs::remove_file(manifest_path)
.map_err(|error| format!("remove local broker manifest failed: {error}"))?;
let mut request: BrokerLaunchRequest = serde_json::from_str(&source)
.map_err(|error| format!("invalid local broker manifest: {error}"))?;
// The packaged broker is a direct child of Runtime Host. Bind the
// manifest to that kernel-observed parent and keep a wait handle open for
// the whole launch. The process creation-time check rejects a PID that was
// reused after this broker was created. If Runtime Host dies, the broker
// terminates and drains the AppContainer Job instead of leaving the worker
// alive until timeout.
let owner_pid = parent_process_id()?;
request.client_pid = owner_pid;
request.validate()?;
let owner = unsafe {
OpenProcess(
SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION,
0,
owner_pid,
)
};
if owner.is_null() {
return Err(last_error("OpenProcess(local broker owner)"));
}
if let Err(error) = validate_owner_process(owner, owner_pid) {
unsafe { CloseHandle(owner) };
return Err(error);
}
// The packaged path is a same-process one-shot launch. Authorize and hand
// off directly instead of routing through a synchronous named-pipe thread;
// the standalone pipe modes remain only as transport experiments.
let result = (|| {
let mut authorizer = BrokerAuthorizer::new([request.profile_digest.clone()]);
authorizer
.authorize(&request, request.client_pid)
.map_err(|error| {
format!(
"broker rejected request ({}): {}",
error.code(),
error.message()
)
})?;
windows_launcher::launch_appcontainer_owned(&request.launch, owner)
})();
unsafe { CloseHandle(owner) };
result
}
fn validate_owner_process(owner: HANDLE, owner_pid: u32) -> Result<(), String> {
let opened_pid = unsafe { GetProcessId(owner) };
if opened_pid != owner_pid {
return Err(format!(
"local broker owner identity changed while opening process: expected pid {owner_pid}, got {opened_pid}"
));
}
let owner_started = process_creation_time(owner, "local broker owner")?;
let broker_started = process_creation_time(unsafe { GetCurrentProcess() }, "local broker")?;
if owner_started >= broker_started {
return Err(
"local broker parent process identity was reused after broker creation".to_owned(),
);
}
Ok(())
}
fn process_creation_time(process: HANDLE, label: &str) -> Result<u64, String> {
let mut creation = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
let mut exit = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
let mut kernel = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
let mut user = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
if unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) } == 0 {
return Err(last_error(&format!("GetProcessTimes({label})")));
}
Ok((u64::from(creation.dwHighDateTime) << 32) | u64::from(creation.dwLowDateTime))
}
fn parent_process_id() -> Result<u32, String> {
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Err(last_error("CreateToolhelp32Snapshot(local broker owner)"));
}
let current = unsafe { GetCurrentProcessId() };
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
let mut found = None;
let mut next = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
while next {
if entry.th32ProcessID == current {
found = Some(entry.th32ParentProcessID);
break;
}
next = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
}
unsafe { CloseHandle(snapshot) };
found
.filter(|pid| *pid > 0)
.ok_or_else(|| "local broker parent process was not found".to_owned())
}
pub fn run(pipe_name: &str, manifest_path: &str) -> Result<u8, String> {
validate_pipe_name(pipe_name).map_err(|error| format!("invalid broker pipe: {error:?}"))?;
let source = fs::read_to_string(manifest_path)
.map_err(|error| format!("read broker manifest failed: {error}"))?;
fs::remove_file(manifest_path)
.map_err(|error| format!("remove broker manifest failed: {error}"))?;
let mut request: BrokerLaunchRequest = serde_json::from_str(&source)
.map_err(|error| format!("invalid broker manifest: {error}"))?;
request.client_pid = unsafe { GetCurrentProcessId() };
request.validate()?;
let payload = serde_json::to_vec(&request).map_err(|error| error.to_string())?;
let frame =
encode_frame(&payload).map_err(|error| format!("invalid request frame: {error:?}"))?;
let pipe = connect(pipe_name)?;
let result = unsafe { exchange(pipe, &frame) };
unsafe { CloseHandle(pipe) };
let response = result?;
if response.request_id != request.request_id || response.version != 1 {
return Err("broker response identity does not match the request".to_owned());
}
match response.outcome {
BrokerLaunchOutcome::Completed { exit_code } => Ok(exit_code),
BrokerLaunchOutcome::Rejected { code, message } => {
Err(format!("broker rejected request ({code}): {message}"))
}
BrokerLaunchOutcome::Started { .. } => {
Err("broker returned unsupported asynchronous outcome".to_owned())
}
}
}
#[cfg(test)]
mod tests {
use super::{parent_process_id, validate_owner_process};
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE;
use windows_sys::Win32::System::Threading::{
GetCurrentProcessId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
#[test]
fn resolves_a_real_parent_process_without_self_binding() {
let parent = parent_process_id().expect("test harness parent process");
let current = unsafe { GetCurrentProcessId() };
assert_ne!(parent, 0);
assert_ne!(parent, current);
}
#[test]
fn validates_a_real_parent_process_before_waiting_on_it() {
let parent = parent_process_id().expect("test harness parent process");
let handle =
unsafe { OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, parent) };
assert!(!handle.is_null(), "OpenProcess(test harness parent)");
let result = validate_owner_process(handle, parent);
unsafe { CloseHandle(handle) };
result.expect("parent process identity must be pinned before waiting");
}
}
fn connect(pipe_name: &str) -> Result<HANDLE, String> {
let name = wide(pipe_name);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let pipe = unsafe {
CreateFileW(
name.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
0,
null(),
OPEN_EXISTING,
0,
null_mut(),
)
};
if pipe != INVALID_HANDLE_VALUE {
return Ok(pipe);
}
if Instant::now() >= deadline {
return Err(last_error("connect broker pipe"));
}
thread::sleep(Duration::from_millis(50));
}
}
unsafe fn exchange(pipe: HANDLE, frame: &[u8]) -> Result<BrokerLaunchResponse, String> {
unsafe { write_all(pipe, frame)? };
let mut prefix = [0u8; 4];
unsafe { read_exact(pipe, &mut prefix)? };
let payload_length = u32::from_le_bytes(prefix) as usize;
if payload_length == 0 || payload_length > MAX_BROKER_MESSAGE_BYTES {
return Err("invalid broker response length".to_owned());
}
let mut response_frame = Vec::with_capacity(4 + payload_length);
response_frame.extend_from_slice(&prefix);
response_frame.resize(4 + payload_length, 0);
unsafe { read_exact(pipe, &mut response_frame[4..])? };
let payload = decode_frame(&response_frame)
.map_err(|error| format!("invalid response frame: {error:?}"))?;
serde_json::from_slice(payload).map_err(|error| format!("invalid broker response: {error}"))
}
unsafe fn read_exact(pipe: HANDLE, mut buffer: &mut [u8]) -> Result<(), String> {
while !buffer.is_empty() {
let mut read = 0;
if unsafe {
ReadFile(
pipe,
buffer.as_mut_ptr(),
buffer.len() as u32,
&mut read,
null_mut(),
)
} == 0
{
return Err(last_error("read broker response"));
}
if read == 0 {
return Err("broker closed before completing the response".to_owned());
}
buffer = &mut buffer[read as usize..];
}
Ok(())
}
unsafe fn write_all(pipe: HANDLE, mut buffer: &[u8]) -> Result<(), String> {
while !buffer.is_empty() {
let mut written = 0;
if unsafe {
WriteFile(
pipe,
buffer.as_ptr(),
buffer.len() as u32,
&mut written,
null_mut(),
)
} == 0
{
return Err(last_error("write broker request"));
}
if written == 0 {
return Err("broker request write made no progress".to_owned());
}
buffer = &buffer[written as usize..];
}
Ok(())
}
fn wide(value: &str) -> Vec<u16> {
OsStr::new(value)
.encode_wide()
.chain(iter::once(0))
.collect()
}
fn last_error(operation: &str) -> String {
format!("{operation} failed: {}", std::io::Error::last_os_error())
}
@@ -0,0 +1,69 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
pub const MAX_BROKER_MESSAGE_BYTES: usize = 64 * 1024;
const LENGTH_PREFIX_BYTES: usize = size_of::<u32>();
pub fn encode_frame(payload: &[u8]) -> Result<Vec<u8>, BrokerFrameError> {
if payload.is_empty() {
return Err(BrokerFrameError::EmptyPayload);
}
if payload.len() > MAX_BROKER_MESSAGE_BYTES {
return Err(BrokerFrameError::PayloadTooLarge);
}
let length = u32::try_from(payload.len()).map_err(|_| BrokerFrameError::PayloadTooLarge)?;
let mut frame = Vec::with_capacity(LENGTH_PREFIX_BYTES + payload.len());
frame.extend_from_slice(&length.to_le_bytes());
frame.extend_from_slice(payload);
Ok(frame)
}
pub fn decode_frame(frame: &[u8]) -> Result<&[u8], BrokerFrameError> {
if frame.len() < LENGTH_PREFIX_BYTES {
return Err(BrokerFrameError::TruncatedPrefix);
}
let length = u32::from_le_bytes(
frame[..LENGTH_PREFIX_BYTES]
.try_into()
.expect("length prefix has fixed width"),
) as usize;
if length == 0 {
return Err(BrokerFrameError::EmptyPayload);
}
if length > MAX_BROKER_MESSAGE_BYTES {
return Err(BrokerFrameError::PayloadTooLarge);
}
let expected = LENGTH_PREFIX_BYTES + length;
if frame.len() < expected {
return Err(BrokerFrameError::TruncatedPayload);
}
if frame.len() > expected {
return Err(BrokerFrameError::TrailingBytes);
}
Ok(&frame[LENGTH_PREFIX_BYTES..expected])
}
#[derive(Debug, Eq, PartialEq)]
pub enum BrokerFrameError {
EmptyPayload,
PayloadTooLarge,
TruncatedPrefix,
TruncatedPayload,
TrailingBytes,
}
@@ -0,0 +1,62 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(test)]
mod tests {
use crate::broker_framing::{
BrokerFrameError, MAX_BROKER_MESSAGE_BYTES, decode_frame, encode_frame,
};
#[test]
fn round_trips_bounded_payload() {
let frame = encode_frame(br#"{"version":1}"#).expect("encode frame");
assert_eq!(decode_frame(&frame), Ok(br#"{"version":1}"#.as_slice()));
}
#[test]
fn rejects_oversized_payload_before_allocation() {
let payload = vec![0; MAX_BROKER_MESSAGE_BYTES + 1];
assert_eq!(
encode_frame(&payload),
Err(BrokerFrameError::PayloadTooLarge)
);
let declared = (MAX_BROKER_MESSAGE_BYTES as u32 + 1).to_le_bytes();
assert_eq!(
decode_frame(&declared),
Err(BrokerFrameError::PayloadTooLarge)
);
}
#[test]
fn rejects_truncated_and_ambiguous_frames() {
assert_eq!(
decode_frame(&[1, 0]),
Err(BrokerFrameError::TruncatedPrefix)
);
assert_eq!(
decode_frame(&[4, 0, 0, 0, 1, 2]),
Err(BrokerFrameError::TruncatedPayload)
);
assert_eq!(
decode_frame(&[1, 0, 0, 0, 1, 2]),
Err(BrokerFrameError::TrailingBytes)
);
}
}
@@ -0,0 +1,213 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::ffi::{OsStr, c_void};
use std::iter;
use std::mem::{size_of, zeroed};
use std::os::windows::ffi::OsStrExt;
use std::ptr::null_mut;
use windows_sys::Win32::Foundation::{
CloseHandle, ERROR_PIPE_CONNECTED, GetLastError, INVALID_HANDLE_VALUE, LocalFree,
};
use windows_sys::Win32::Security::Authorization::{
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
};
use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES};
use windows_sys::Win32::Storage::FileSystem::{
FILE_FLAG_FIRST_PIPE_INSTANCE, FlushFileBuffers, PIPE_ACCESS_DUPLEX, ReadFile, WriteFile,
};
use windows_sys::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, GetNamedPipeClientProcessId,
PIPE_REJECT_REMOTE_CLIENTS, PIPE_WAIT,
};
use crate::broker_authorization::BrokerAuthorizer;
use crate::broker_framing::{MAX_BROKER_MESSAGE_BYTES, decode_frame, encode_frame};
use crate::broker_pipe_security::{pipe_security_sddl, validate_pipe_name};
use crate::protocol::{BrokerLaunchOutcome, BrokerLaunchRequest, BrokerLaunchResponse};
use crate::windows_launcher;
pub fn serve_once(pipe_name: &str, account_sid: &str, profile_digest: &str) -> Result<(), String> {
validate_pipe_name(pipe_name).map_err(|error| format!("invalid broker pipe: {error:?}"))?;
let sddl = pipe_security_sddl(account_sid)
.map_err(|error| format!("invalid broker account SID: {error:?}"))?;
unsafe { serve_once_with_security(pipe_name, &sddl, profile_digest) }
}
unsafe fn serve_once_with_security(
pipe_name: &str,
sddl: &str,
profile_digest: &str,
) -> Result<(), String> {
let mut descriptor: PSECURITY_DESCRIPTOR = null_mut();
let sddl_wide = wide(sddl);
if unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
sddl_wide.as_ptr(),
SDDL_REVISION_1,
&mut descriptor,
null_mut(),
)
} == 0
{
return Err(last_error(
"ConvertStringSecurityDescriptorToSecurityDescriptorW",
));
}
let mut attributes: SECURITY_ATTRIBUTES = unsafe { zeroed() };
attributes.nLength = size_of::<SECURITY_ATTRIBUTES>() as u32;
attributes.lpSecurityDescriptor = descriptor as *mut c_void;
attributes.bInheritHandle = 0;
let pipe_name_wide = wide(pipe_name);
let pipe = unsafe {
CreateNamedPipeW(
pipe_name_wide.as_ptr(),
// FIRST_PIPE_INSTANCE makes creation fail closed when the name is
// already taken: without it a same-user process could pre-create
// the pipe and impersonate the broker toward the client.
PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE,
PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
1,
(MAX_BROKER_MESSAGE_BYTES + 4) as u32,
(MAX_BROKER_MESSAGE_BYTES + 4) as u32,
5_000,
&attributes,
)
};
unsafe { LocalFree(descriptor as *mut c_void) };
if pipe == INVALID_HANDLE_VALUE {
return Err(last_error("CreateNamedPipeW"));
}
let result = (|| -> Result<(), String> {
if unsafe { ConnectNamedPipe(pipe, null_mut()) } == 0
&& unsafe { GetLastError() } != ERROR_PIPE_CONNECTED
{
return Err(last_error("ConnectNamedPipe"));
}
let mut client_pid = 0;
if unsafe { GetNamedPipeClientProcessId(pipe, &mut client_pid) } == 0 {
return Err(last_error("GetNamedPipeClientProcessId"));
}
let mut prefix = [0u8; 4];
unsafe { read_exact(pipe, &mut prefix)? };
let payload_length = u32::from_le_bytes(prefix) as usize;
if payload_length == 0 || payload_length > MAX_BROKER_MESSAGE_BYTES {
return Err("invalid broker payload length".to_owned());
}
let mut buffer = Vec::with_capacity(4 + payload_length);
buffer.extend_from_slice(&prefix);
buffer.resize(4 + payload_length, 0);
unsafe { read_exact(pipe, &mut buffer[4..])? };
let payload = decode_frame(&buffer).map_err(|error| format!("invalid frame: {error:?}"))?;
let request: BrokerLaunchRequest = serde_json::from_slice(payload)
.map_err(|error| format!("invalid broker request: {error}"))?;
let request_id = request.request_id.clone();
let mut authorizer = BrokerAuthorizer::new([profile_digest.to_owned()]);
let outcome = match authorizer.authorize(&request, client_pid) {
Ok(()) => match windows_launcher::launch_appcontainer(&request.launch) {
Ok(exit_code) => BrokerLaunchOutcome::Completed { exit_code },
Err(message) => BrokerLaunchOutcome::Rejected {
code: "appcontainer_launch_failed".to_owned(),
message,
},
},
Err(error) => BrokerLaunchOutcome::Rejected {
code: error.code().to_owned(),
message: error.message(),
},
};
let response = BrokerLaunchResponse {
version: 1,
request_id,
outcome,
};
let response_payload = serde_json::to_vec(&response).map_err(|error| error.to_string())?;
let response_frame = encode_frame(&response_payload)
.map_err(|error| format!("response frame rejected: {error:?}"))?;
unsafe { write_all(pipe, &response_frame)? };
if unsafe { FlushFileBuffers(pipe) } == 0 {
return Err(last_error("FlushFileBuffers(broker response)"));
}
Ok(())
})();
unsafe {
DisconnectNamedPipe(pipe);
CloseHandle(pipe);
}
result
}
unsafe fn read_exact(pipe: *mut c_void, mut buffer: &mut [u8]) -> Result<(), String> {
while !buffer.is_empty() {
let mut bytes_read = 0;
if unsafe {
ReadFile(
pipe,
buffer.as_mut_ptr(),
buffer.len() as u32,
&mut bytes_read,
null_mut(),
)
} == 0
{
return Err(last_error("ReadFile(broker request)"));
}
if bytes_read == 0 {
return Err("broker client closed before completing the frame".to_owned());
}
buffer = &mut buffer[bytes_read as usize..];
}
Ok(())
}
unsafe fn write_all(pipe: *mut c_void, mut buffer: &[u8]) -> Result<(), String> {
while !buffer.is_empty() {
let mut bytes_written = 0;
if unsafe {
WriteFile(
pipe,
buffer.as_ptr(),
buffer.len() as u32,
&mut bytes_written,
null_mut(),
)
} == 0
{
return Err(last_error("WriteFile(broker response)"));
}
if bytes_written == 0 {
return Err("broker response write made no progress".to_owned());
}
buffer = &buffer[bytes_written as usize..];
}
Ok(())
}
fn wide(value: &str) -> Vec<u16> {
OsStr::new(value)
.encode_wide()
.chain(iter::once(0))
.collect()
}
fn last_error(operation: &str) -> String {
format!("{operation} failed: {}", std::io::Error::last_os_error())
}
@@ -0,0 +1,70 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const PIPE_PREFIX: &str = r"\\.\pipe\maka-sandbox-";
pub fn validate_pipe_name(name: &str) -> Result<(), PipeSecurityError> {
let suffix = name
.strip_prefix(PIPE_PREFIX)
.ok_or(PipeSecurityError::InvalidPipeName)?;
if suffix.is_empty()
|| suffix.len() > 64
|| !suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(PipeSecurityError::InvalidPipeName);
}
Ok(())
}
pub fn validate_account_sid(sid: &str) -> Result<(), PipeSecurityError> {
let components = sid
.strip_prefix("S-1-")
.ok_or(PipeSecurityError::InvalidAccountSid)?
.split('-')
.collect::<Vec<_>>();
let user_sid_shape = matches!(components.as_slice(), ["5", "21", _, _, _, _])
|| matches!(components.as_slice(), ["12", "1", _, _, _, _]);
if !user_sid_shape
|| components.iter().any(|component| {
component.is_empty()
|| component.len() > 10
|| !component.bytes().all(|byte| byte.is_ascii_digit())
})
|| components
.last()
.and_then(|component| component.parse::<u32>().ok())
== Some(0)
{
return Err(PipeSecurityError::InvalidAccountSid);
}
Ok(())
}
pub fn pipe_security_sddl(account_sid: &str) -> Result<String, PipeSecurityError> {
validate_account_sid(account_sid)?;
Ok(format!("D:P(A;;GA;;;SY)(A;;GA;;;{account_sid})"))
}
#[derive(Debug, Eq, PartialEq)]
pub enum PipeSecurityError {
InvalidPipeName,
InvalidAccountSid,
}
@@ -0,0 +1,71 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(test)]
mod tests {
use crate::broker_pipe_security::{
PipeSecurityError, pipe_security_sddl, validate_account_sid, validate_pipe_name,
};
#[test]
fn accepts_only_private_maka_pipe_namespace() {
assert_eq!(
validate_pipe_name(r"\\.\pipe\maka-sandbox-session_42"),
Ok(())
);
assert_eq!(
validate_pipe_name(r"\\.\pipe\other-session"),
Err(PipeSecurityError::InvalidPipeName)
);
assert_eq!(
validate_pipe_name(r"\\server\pipe\maka-sandbox-session"),
Err(PipeSecurityError::InvalidPipeName)
);
assert_eq!(
validate_pipe_name(r"\\.\pipe\maka-sandbox-..\escape"),
Err(PipeSecurityError::InvalidPipeName)
);
}
#[test]
fn accepts_canonical_numeric_account_sid() {
assert_eq!(validate_account_sid("S-1-5-21-1-2-3-1001"), Ok(()));
assert_eq!(
validate_account_sid("BA"),
Err(PipeSecurityError::InvalidAccountSid)
);
assert_eq!(
validate_account_sid("S-1-5-21-user"),
Err(PipeSecurityError::InvalidAccountSid)
);
assert_eq!(
validate_account_sid("S-1-1-0"),
Err(PipeSecurityError::InvalidAccountSid)
);
assert_eq!(validate_account_sid("S-1-12-1-1-2-3-4"), Ok(()));
}
#[test]
fn creates_protected_system_and_user_only_dacl() {
assert_eq!(
pipe_security_sddl("S-1-5-21-1-2-3-1001"),
Ok("D:P(A;;GA;;;SY)(A;;GA;;;S-1-5-21-1-2-3-1001)".to_owned())
);
}
}
@@ -0,0 +1,72 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use std::iter;
use std::os::windows::ffi::OsStrExt;
use std::ptr::null;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
use windows_sys::Win32::System::Pipes::{CreateNamedPipeW, PIPE_WAIT};
use crate::broker_pipe::serve_once;
use crate::windows_launcher::current_user_sid_string;
#[test]
fn refuses_to_serve_on_a_squatted_pipe_name() {
let name = format!(r"\\.\pipe\maka-sandbox-squat-{}", std::process::id());
let name_wide: Vec<u16> = OsStr::new(&name)
.encode_wide()
.chain(iter::once(0))
.collect();
// A same-user squatter that allows additional instances: without
// FILE_FLAG_FIRST_PIPE_INSTANCE the broker's own create would succeed
// as a second instance and the squatter could race the client.
let squatter = unsafe {
CreateNamedPipeW(
name_wide.as_ptr(),
PIPE_ACCESS_DUPLEX,
PIPE_WAIT,
2,
1024,
1024,
5_000,
null(),
)
};
assert!(
squatter != INVALID_HANDLE_VALUE,
"squatter pipe create failed"
);
let sid = current_user_sid_string().expect("current user SID");
let digest = "0".repeat(64);
let result = serve_once(&name, &sid, &digest);
unsafe { CloseHandle(squatter) };
let error = result.expect_err("a squatted pipe name must not be served");
assert!(
error.contains("CreateNamedPipeW"),
"unexpected error: {error}"
);
}
}
@@ -0,0 +1,587 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(not(windows))]
compile_error!("maka-windows-sandbox is Windows-only");
mod acl_ledger;
#[cfg(test)]
mod acl_ledger_tests;
mod broker_authorization;
#[cfg(test)]
mod broker_authorization_tests;
mod broker_client;
mod broker_framing;
#[cfg(test)]
mod broker_framing_tests;
mod broker_pipe;
mod broker_pipe_security;
#[cfg(test)]
mod broker_pipe_security_tests;
#[cfg(test)]
mod broker_pipe_tests;
mod protocol;
#[cfg(test)]
mod protocol_tests;
mod windows_launcher;
#[cfg(test)]
mod windows_launcher_desktop_tests;
#[cfg(test)]
mod windows_launcher_tests;
use std::env;
use std::fs;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
use std::process::{Command, ExitCode};
use std::time::Duration;
use serde::Deserialize;
use broker_authorization::BrokerAuthorizer;
use broker_framing::{decode_frame, encode_frame};
use broker_pipe::serve_once;
use protocol::{
BrokerLaunchOutcome, BrokerLaunchRequest, BrokerLaunchResponse, LaunchRequest, launch_digest,
};
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(code),
Err(error) => {
eprintln!("maka-windows-sandbox: {error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<u8, String> {
let mut args = env::args_os();
let _program = args.next();
let first = args.next().ok_or_else(|| {
"usage: maka-windows-sandbox [--atomic|--broker-validate] <request.json>".to_owned()
})?;
if first == "--self-probe" {
if args.next().is_some() {
return Err("--self-probe does not accept arguments".to_owned());
}
return windows_launcher::self_probe();
}
if first == "--readiness-probe" {
if args.next().is_some() {
return Err("--readiness-probe does not accept arguments".to_owned());
}
return windows_launcher::readiness_probe();
}
if first == "--stdio-probe" {
let sleep_seconds = match args.next() {
None => 0,
Some(flag) if flag == "--sleep" => {
let seconds = args
.next()
.ok_or_else(|| "--sleep requires a seconds value".to_owned())?
.to_string_lossy()
.parse::<u64>()
.map_err(|error| format!("invalid --sleep seconds: {error}"))?;
if args.next().is_some() {
return Err("--stdio-probe accepts only --sleep <seconds>".to_owned());
}
seconds
}
Some(_) => return Err("--stdio-probe accepts only --sleep <seconds>".to_owned()),
};
return stdio_probe(sleep_seconds);
}
if first == "--appcontainer-sid" {
let request_id = args
.next()
.ok_or_else(|| "--appcontainer-sid requires a request id".to_owned())?;
if args.next().is_some() {
return Err("--appcontainer-sid accepts exactly one request id".to_owned());
}
println!(
"{}",
windows_launcher::appcontainer_sid_string(&request_id.to_string_lossy())?
);
return Ok(0);
}
if first == "--boundary-probe" {
let denied_path = args.next().ok_or_else(|| {
"--boundary-probe requires resource paths and loopback port".to_owned()
})?;
let allowed_read_path = args.next().ok_or_else(|| {
"--boundary-probe requires resource paths and loopback port".to_owned()
})?;
let allowed_write_path = args.next().ok_or_else(|| {
"--boundary-probe requires resource paths and loopback port".to_owned()
})?;
let port = args
.next()
.ok_or_else(|| "--boundary-probe requires resource paths and loopback port".to_owned())?
.to_string_lossy()
.parse::<u16>()
.map_err(|error| format!("invalid boundary-probe port: {error}"))?;
if args.next().is_some() {
return Err("--boundary-probe accepts exactly four arguments".to_owned());
}
return boundary_probe(
&denied_path.to_string_lossy(),
&allowed_read_path.to_string_lossy(),
&allowed_write_path.to_string_lossy(),
port,
);
}
if first == "--adversarial-probe" {
let input_path = args
.next()
.ok_or_else(|| "--adversarial-probe requires an input path".to_owned())?;
if args.next().is_some() {
return Err("--adversarial-probe accepts exactly one input path".to_owned());
}
let source = fs::read_to_string(&input_path)
.map_err(|error| format!("read adversarial probe input failed: {error}"))?;
// Windows PowerShell 5.1 may emit a UTF-8 BOM for `Set-Content
// -Encoding utf8`; accepting it here keeps the probe input parser
// deterministic across the supported PowerShell implementations.
let source = source.strip_prefix('\u{feff}').unwrap_or(&source);
let input: AdversarialProbeInput = serde_json::from_str(source)
.map_err(|error| format!("decode adversarial probe input failed: {error}"))?;
return adversarial_probe(&input);
}
if first == "--launch-digest" {
let request_path = args
.next()
.ok_or_else(|| "--launch-digest requires a request path".to_owned())?;
if args.next().is_some() {
return Err("--launch-digest accepts exactly one request path".to_owned());
}
let source = fs::read_to_string(request_path).map_err(|error| error.to_string())?;
let request: LaunchRequest =
serde_json::from_str(&source).map_err(|error| error.to_string())?;
request.validate()?;
println!("{}", launch_digest(&request)?);
return Ok(0);
}
if first == "--broker-serve-once" {
let pipe_name = args.next().ok_or_else(|| {
"--broker-serve-once requires pipe name, account SID, and profile digest".to_owned()
})?;
let account_sid = args.next().ok_or_else(|| {
"--broker-serve-once requires pipe name, account SID, and profile digest".to_owned()
})?;
let profile_digest = args.next().ok_or_else(|| {
"--broker-serve-once requires pipe name, account SID, and profile digest".to_owned()
})?;
if args.next().is_some() {
return Err("--broker-serve-once accepts exactly three arguments".to_owned());
}
serve_once(
&pipe_name.to_string_lossy(),
&account_sid.to_string_lossy(),
&profile_digest.to_string_lossy(),
)?;
return Ok(0);
}
if first == "--broker-client" {
let pipe_name = args
.next()
.ok_or_else(|| "--broker-client requires pipe name and manifest path".to_owned())?;
let manifest_path = args
.next()
.ok_or_else(|| "--broker-client requires pipe name and manifest path".to_owned())?;
if args.next().is_some() {
return Err("--broker-client accepts exactly two arguments".to_owned());
}
return broker_client::run(
&pipe_name.to_string_lossy(),
&manifest_path.to_string_lossy(),
);
}
if first == "--broker-local" {
let manifest_path = args
.next()
.ok_or_else(|| "--broker-local requires a manifest path".to_owned())?;
if args.next().is_some() {
return Err("--broker-local accepts exactly one argument".to_owned());
}
return broker_client::run_local(&manifest_path.to_string_lossy());
}
if first == "--appcontainer" {
let request_path = args
.next()
.ok_or_else(|| "--appcontainer requires a request path".to_owned())?;
if args.next().is_some() {
return Err("--appcontainer accepts exactly one request path".to_owned());
}
let source = fs::read_to_string(request_path).map_err(|error| error.to_string())?;
let request: LaunchRequest =
serde_json::from_str(&source).map_err(|error| error.to_string())?;
request.validate()?;
return windows_launcher::launch_appcontainer(&request);
}
let (mode, request_path) = if first == "--atomic" {
let path = args
.next()
.ok_or_else(|| "--atomic requires exactly one request path".to_owned())?;
("atomic", path)
} else if first == "--broker-validate" {
let path = args
.next()
.ok_or_else(|| "--broker-validate requires exactly one request path".to_owned())?;
("broker-validate", path)
} else {
("legacy", first)
};
if args.next().is_some() {
return Err("expected exactly one request path".to_owned());
}
let source = fs::read_to_string(request_path).map_err(|error| error.to_string())?;
match mode {
"broker-validate" => validate_broker_request(&source),
"atomic" | "legacy" => {
let request: LaunchRequest =
serde_json::from_str(&source).map_err(|error| error.to_string())?;
request.validate()?;
if mode == "atomic" {
windows_launcher::launch_atomic(&request)
} else {
windows_launcher::launch(&request)
}
}
_ => unreachable!(),
}
}
/// Test child for the stdio relay: consumes all of stdin, reports what it saw
/// on stdout as a single JSON document, and keeps diagnostics on stderr —
/// mirroring the filesystem-worker contract without needing Node inside the
/// AppContainer.
fn stdio_probe(sleep_seconds: u64) -> Result<u8, String> {
use std::io::{Read, Write};
use sha2::{Digest, Sha256};
let mut payload = Vec::new();
std::io::stdin()
.read_to_end(&mut payload)
.map_err(|error| format!("stdio-probe read failed: {error}"))?;
if sleep_seconds > 0 {
std::thread::sleep(Duration::from_secs(sleep_seconds));
}
eprintln!("stdio-probe: diagnostics stay on stderr");
println!(
"{{\"echoBytes\":{},\"sha256\":\"{:x}\"}}",
payload.len(),
Sha256::digest(&payload)
);
std::io::stdout()
.flush()
.map_err(|error| format!("stdio-probe flush failed: {error}"))?;
Ok(0)
}
fn boundary_probe(
denied_path: &str,
allowed_read_path: &str,
allowed_write_path: &str,
port: u16,
) -> Result<u8, String> {
let file_denied = fs::read(denied_path).is_err();
let allowed_read =
matches!(fs::read_to_string(allowed_read_path), Ok(value) if value == "allowed-read");
let allowed_write = fs::write(allowed_write_path, b"allowed-write").is_ok();
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
let network_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err();
// Self-attest the desktop the confined child was *initially placed* on
// (RFC §6.3): the per-launch private desktop, never the interactive
// `Default`. Querying its own desktop name is allowed because the desktop
// DACL grants this AppContainer SID DESKTOP_READOBJECTS. This proves initial
// `STARTUPINFOW.lpDesktop` placement only — it is NOT proof of escape-proof
// confinement: absent a no-Win32k mitigation or a window-station/token
// boundary, in-process code could still `OpenDesktopW("Default")` +
// `SetThreadDesktop`. The escape-proof boundary is a deferred gate (§6.5).
let desktop = current_desktop_name()?;
let desktop_private_placement = desktop_is_private_placement(&desktop);
println!(
"{{\"fileDenied\":{file_denied},\"allowedRead\":{allowed_read},\"allowedWrite\":{allowed_write},\"networkDenied\":{network_denied},\"desktop\":{desktop_json},\"desktopPrivatePlacement\":{desktop_private_placement}}}",
desktop_json = json_string(&desktop)
);
if !file_denied || !allowed_read || !allowed_write || !network_denied {
return Err("AppContainer boundary did not enforce the requested resources".to_owned());
}
if !desktop_private_placement {
return Err(format!(
"confined child was placed on the interactive desktop {desktop:?} instead of a private one"
));
}
Ok(0)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AdversarialProbeInput {
denied_path: String,
allowed_read_path: String,
allowed_write_path: String,
loopback_port: u16,
pipe_name: String,
environment_secret_name: String,
registry_subkey: String,
registry_value_name: String,
parent_pid: u32,
}
/// Malicious-child probe for the packaged Phase 4 matrix. Every check runs
/// from inside the production AppContainer identity. A false field is a hard
/// failure: the release verifier must never turn an unavailable probe into a
/// vacuous pass.
fn adversarial_probe(input: &AdversarialProbeInput) -> Result<u8, String> {
let file_denied = fs::read(&input.denied_path).is_err();
let allowed_read = matches!(fs::read_to_string(&input.allowed_read_path), Ok(value) if value == "allowed-read");
let allowed_write = fs::write(&input.allowed_write_path, b"allowed-write").is_ok();
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), input.loopback_port);
let tcp_denied = TcpStream::connect_timeout(&address, Duration::from_secs(2)).is_err();
let named_pipe_denied = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&input.pipe_name)
.is_err();
let environment_denied = env::var_os(&input.environment_secret_name).is_none();
let registry_denied = registry_value_denied(&input.registry_subkey, &input.registry_value_name);
let parent_token_denied = parent_token_denied(input.parent_pid);
let (descendant_app_container, descendant_in_job, descendant_spawn_denied) =
descendant_boundary();
let evidence = serde_json::json!({
"fileDenied": file_denied,
"allowedRead": allowed_read,
"allowedWrite": allowed_write,
"tcpDenied": tcp_denied,
"namedPipeDenied": named_pipe_denied,
"environmentDenied": environment_denied,
"registryDenied": registry_denied,
"parentTokenDenied": parent_token_denied,
"descendantAppContainer": descendant_app_container,
"descendantInJob": descendant_in_job,
"descendantSpawnDenied": descendant_spawn_denied,
});
println!("{evidence}");
let passed = file_denied
&& allowed_read
&& allowed_write
&& tcp_denied
&& named_pipe_denied
&& environment_denied
&& registry_denied
&& parent_token_denied
&& (descendant_spawn_denied || (descendant_app_container && descendant_in_job));
if !passed {
return Err(format!(
"AppContainer adversarial matrix did not hold: {evidence}"
));
}
Ok(0)
}
fn registry_value_denied(subkey: &str, value_name: &str) -> bool {
use std::ptr::{null, null_mut};
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegOpenKeyExW, RegQueryValueExW,
};
let subkey = wide(subkey);
let value_name = wide(value_name);
let mut key = null_mut();
let open = unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut key) };
if open != ERROR_SUCCESS {
return true;
}
let mut value_type: u32 = 0;
let mut value_bytes: u32 = 0;
let query = unsafe {
RegQueryValueExW(
key,
value_name.as_ptr(),
null(),
&mut value_type,
null_mut(),
&mut value_bytes,
)
};
unsafe { RegCloseKey(key) };
query != ERROR_SUCCESS
}
fn parent_token_denied(parent_pid: u32) -> bool {
use std::ptr::null_mut;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::Security::TOKEN_QUERY;
use windows_sys::Win32::System::Threading::{
OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
};
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, parent_pid) };
if process.is_null() {
return true;
}
let mut token = null_mut();
let denied = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0;
if !token.is_null() {
unsafe { CloseHandle(token) };
}
unsafe { CloseHandle(process) };
denied
}
fn descendant_boundary() -> (bool, bool, bool) {
let executable = match env::current_exe() {
Ok(executable) => executable,
Err(_) => return (false, false, true),
};
let output = match Command::new(executable).arg("--self-probe").output() {
Ok(output) if output.status.success() => output,
Err(_) => return (false, false, true),
Ok(_) => return (false, false, false),
};
let evidence: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(evidence) => evidence,
Err(_) => return (false, false, false),
};
(
evidence
.get("appContainer")
.and_then(|value| value.as_bool())
== Some(true),
evidence.get("inJob").and_then(|value| value.as_bool()) == Some(true),
false,
)
}
fn wide(value: &str) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt;
std::ffi::OsStr::new(value)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
/// Prefix every launcher-created private desktop name carries
/// (`maka-sandbox-desktop.<pid>.<nonce>`). Attesting against the prefix rather
/// than merely "not Default" means a child that somehow started on *any other*
/// pre-existing desktop (`Winlogon`, a screensaver desktop, another product's
/// alternate desktop) still fails the placement check.
const PRIVATE_DESKTOP_PREFIX: &str = "maka-sandbox-desktop.";
/// A confined worker must be *initially placed* on the per-launch private
/// desktop this launcher created — never the shared interactive `Default`
/// desktop, never an empty/unnamed one, and never some other desktop that
/// merely isn't `Default`. This is a placement check, not an escape-proof
/// confinement check: it only attests the initial `lpDesktop` name
/// (RFC §6.3, §6.5).
fn desktop_is_private_placement(name: &str) -> bool {
name.len() > PRIVATE_DESKTOP_PREFIX.len()
&& name
.get(..PRIVATE_DESKTOP_PREFIX.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(PRIVATE_DESKTOP_PREFIX))
}
/// JSON string encoding for names embedded in probe output. Delegates to
/// `serde_json` (already a dependency) instead of hand-rolled escaping; string
/// serialization is infallible.
fn json_string(value: &str) -> String {
serde_json::to_string(value).expect("JSON string encoding cannot fail")
}
/// Name of the desktop the current thread is attached to. Fails closed: a
/// worker that cannot even confirm its desktop is treated as unconfined.
fn current_desktop_name() -> Result<String, String> {
use std::ffi::c_void;
use windows_sys::Win32::System::StationsAndDesktops::{
GetThreadDesktop, GetUserObjectInformationW, UOI_NAME,
};
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
unsafe {
let desktop = GetThreadDesktop(GetCurrentThreadId());
if desktop.is_null() {
return Err("GetThreadDesktop returned null".to_owned());
}
let mut needed = 0u32;
// First call sizes the name buffer (bytes needed, including the
// terminating wide NUL).
GetUserObjectInformationW(desktop, UOI_NAME, std::ptr::null_mut(), 0, &mut needed);
if needed == 0 {
return Err("GetUserObjectInformationW(UOI_NAME size) returned zero".to_owned());
}
let words = (needed as usize).div_ceil(std::mem::size_of::<u16>());
let mut buffer = vec![0u16; words];
if GetUserObjectInformationW(
desktop,
UOI_NAME,
buffer.as_mut_ptr() as *mut c_void,
needed,
&mut needed,
) == 0
{
return Err("GetUserObjectInformationW(UOI_NAME) failed".to_owned());
}
let length = buffer.iter().position(|&code| code == 0).unwrap_or(words);
Ok(String::from_utf16_lossy(&buffer[..length]))
}
}
fn validate_broker_request(source: &str) -> Result<u8, String> {
let request: BrokerLaunchRequest = serde_json::from_str(source)
.map_err(|error| format!("broker request rejected: {error}"))?;
// Contract validation has no pipe peer yet. The service path will replace
// these claimed values with the connected process PID and approved policy.
let mut authorizer = BrokerAuthorizer::new([request.profile_digest.clone()]);
let connected_client_pid = request.client_pid;
let response = match authorizer.authorize(&request, connected_client_pid) {
Ok(()) => BrokerLaunchResponse {
version: 1,
request_id: request.request_id,
outcome: BrokerLaunchOutcome::Rejected {
code: "broker_not_connected".to_owned(),
message: "request is valid but no broker service is connected".to_owned(),
},
},
Err(error) => BrokerLaunchResponse {
version: 1,
request_id: request.request_id,
outcome: BrokerLaunchOutcome::Rejected {
code: error.code().to_owned(),
message: error.message(),
},
},
};
let payload = serde_json::to_vec(&response).map_err(|error| error.to_string())?;
let frame =
encode_frame(&payload).map_err(|error| format!("broker frame rejected: {error:?}"))?;
let decoded =
decode_frame(&frame).map_err(|error| format!("broker frame rejected: {error:?}"))?;
println!("{}", String::from_utf8_lossy(decoded));
Ok(0)
}
@@ -0,0 +1,227 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::collections::{BTreeMap, HashSet};
use std::path::{Component, Path};
use serde::Deserialize;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "camelCase")]
pub struct LaunchRequest {
pub version: u32,
pub request_id: String,
pub executable: String,
pub arguments: Vec<String>,
pub cwd: String,
pub read_roots: Vec<String>,
pub write_roots: Vec<String>,
#[serde(default)]
pub exact_read_roots: Vec<String>,
#[serde(default)]
pub exact_write_roots: Vec<String>,
pub network: NetworkMode,
pub environment: BTreeMap<String, String>,
/// Optional child-wait deadline. Appended last and skipped when absent so
/// manifests written before this field keep an identical launch digest.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
pub const MIN_LAUNCH_TIMEOUT_MS: u64 = 1_000;
pub const MAX_LAUNCH_TIMEOUT_MS: u64 = 600_000;
pub const DEFAULT_LAUNCH_TIMEOUT_MS: u64 = 30_000;
/// `request_id` reserved for the internal Windows readiness probe. Its own
/// AppContainer profile/SID is derived from this exact identity, and
/// `create_readiness` best-effort *deletes* that profile before recreating it.
/// A production launch is therefore forbidden from carrying it: otherwise an
/// ordinary launch would resolve to the same profile the probe reclaims,
/// letting the two identities collide. `validate` rejects it so the readiness
/// namespace is one production requests can never enter — enforced, not merely
/// documented.
pub const RESERVED_READINESS_REQUEST_ID: &str = "readiness-probe";
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct BrokerLaunchRequest {
pub version: u32,
pub request_id: String,
pub client_pid: u32,
pub client_nonce: String,
pub profile_digest: String,
pub launch: LaunchRequest,
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct BrokerLaunchResponse {
pub version: u32,
pub request_id: String,
pub outcome: BrokerLaunchOutcome,
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
#[allow(dead_code)]
pub enum BrokerLaunchOutcome {
Started {
process_id: u32,
},
Completed {
#[serde(rename = "exitCode")]
exit_code: u8,
},
Rejected {
code: String,
message: String,
},
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum NetworkMode {
Restricted,
Enabled,
}
impl LaunchRequest {
pub fn validate(&self) -> Result<(), String> {
if self.version != 1 {
return Err("unsupported protocol version".to_owned());
}
if self.request_id.is_empty()
|| self.request_id.len() > 128
|| !self
.request_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err("requestId must use 1-128 safe ASCII characters".to_owned());
}
if self.request_id == RESERVED_READINESS_REQUEST_ID {
return Err(format!(
"requestId '{RESERVED_READINESS_REQUEST_ID}' is reserved for the internal readiness probe"
));
}
validate_path(&self.executable, "executable")?;
validate_path(&self.cwd, "cwd")?;
validate_roots(&self.read_roots, "readRoots")?;
validate_roots(&self.write_roots, "writeRoots")?;
validate_roots(&self.exact_read_roots, "exactReadRoots")?;
validate_roots(&self.exact_write_roots, "exactWriteRoots")?;
if let Some(timeout_ms) = self.timeout_ms {
if !(MIN_LAUNCH_TIMEOUT_MS..=MAX_LAUNCH_TIMEOUT_MS).contains(&timeout_ms) {
return Err(format!(
"timeoutMs must be between {MIN_LAUNCH_TIMEOUT_MS} and {MAX_LAUNCH_TIMEOUT_MS}"
));
}
}
for (name, value) in &self.environment {
// The CreateProcess environment block is `name=value\0...\0\0`, so
// a name may not be empty, may not contain '=' or a control
// character (NUL is < 0x20 and would truncate the block), and may
// not begin with '=' — that leading form is Windows' hidden
// per-drive cwd variable (`=C:`). Everything else, including the
// parentheses in real names like `CommonProgramFiles(x86)`, is a
// legitimate Windows environment name.
if name.is_empty()
|| name.starts_with('=')
|| name.chars().any(|c| c == '=' || (c as u32) < 0x20)
{
return Err(format!("invalid environment name: {name}"));
}
if value.contains('\0') {
return Err(format!("invalid environment value for {name}"));
}
}
Ok(())
}
}
impl BrokerLaunchRequest {
#[allow(dead_code)]
pub fn validate(&self) -> Result<(), String> {
if self.version != 1 {
return Err("unsupported broker protocol version".to_owned());
}
if self.request_id.is_empty() {
return Err("requestId must be non-empty".to_owned());
}
if self.client_pid == 0 {
return Err("clientPid must be non-zero".to_owned());
}
if !is_fixed_hex(&self.client_nonce, 32) {
return Err("clientNonce must be 32 hexadecimal characters".to_owned());
}
if !is_fixed_hex(&self.profile_digest, 64) {
return Err("profileDigest must be 64 hexadecimal characters".to_owned());
}
self.launch.validate()
}
}
pub fn launch_digest(request: &LaunchRequest) -> Result<String, String> {
let canonical = serde_json::to_vec(request)
.map_err(|error| format!("serialize canonical launch policy failed: {error}"))?;
Ok(format!("{:x}", Sha256::digest(canonical)))
}
#[allow(dead_code)]
fn is_fixed_hex(value: &str, length: usize) -> bool {
value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn validate_roots(roots: &[String], field: &str) -> Result<(), String> {
let mut unique = HashSet::new();
for (index, root) in roots.iter().enumerate() {
validate_path(root, &format!("{field}[{index}]"))?;
if !unique.insert(root.to_lowercase()) {
return Err(format!("{field} must not contain duplicate paths"));
}
}
Ok(())
}
fn validate_path(value: &str, field: &str) -> Result<(), String> {
let path = Path::new(value);
if !path.is_absolute() {
return Err(format!("{field} must be absolute"));
}
if value.contains('/') || (value.ends_with('\\') && !is_windows_root(value)) {
return Err(format!("{field} must use canonical Windows separators"));
}
if path
.components()
.any(|component| matches!(component, Component::ParentDir | Component::CurDir))
{
return Err(format!("{field} must be lexically canonical"));
}
Ok(())
}
fn is_windows_root(value: &str) -> bool {
let bytes = value.as_bytes();
bytes.len() == 3 && bytes[1] == b':' && bytes[2] == b'\\' && bytes[0].is_ascii_alphabetic()
}
@@ -0,0 +1,149 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(test)]
mod tests {
use super::super::protocol::{BrokerLaunchRequest, launch_digest};
fn request() -> BrokerLaunchRequest {
serde_json::from_str(
r#"{
"version": 1,
"requestId": "broker-1",
"clientPid": 42,
"clientNonce": "0123456789abcdef0123456789abcdef",
"profileDigest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"launch": {
"version": 1,
"requestId": "launch-1",
"executable": "C:\\Windows\\System32\\cmd.exe",
"arguments": ["/d", "/c", "exit 0"],
"cwd": "C:\\Windows",
"readRoots": [],
"writeRoots": [],
"network": "enabled",
"environment": {}
}
}"#,
)
.expect("valid broker request")
}
#[test]
fn accepts_valid_authorized_request_shape() {
assert!(request().validate().is_ok());
}
#[test]
fn rejects_invalid_nonce_before_launch() {
let mut value = request();
value.client_nonce = "short".to_owned();
assert_eq!(
value.validate().unwrap_err(),
"clientNonce must be 32 hexadecimal characters"
);
}
#[test]
fn rejects_invalid_profile_digest_before_launch() {
let mut value = request();
value.profile_digest = "not-a-digest".to_owned();
assert_eq!(
value.validate().unwrap_err(),
"profileDigest must be 64 hexadecimal characters"
);
}
#[test]
fn rejects_request_ids_that_cannot_safely_name_lifecycle_artifacts() {
for request_id in ["contains:stream", "contains/slash", "contains space"] {
let mut value = request();
value.launch.request_id = request_id.to_owned();
assert_eq!(
value.validate().unwrap_err(),
"requestId must use 1-128 safe ASCII characters"
);
}
}
#[test]
fn launch_digest_is_unchanged_for_manifests_without_timeout() {
// timeoutMs is optional and skipped when absent, so a pre-timeout
// manifest must produce the exact digest it produced before the field
// existed — otherwise old manifests hit profile_digest_mismatch.
let value = request();
assert!(value.launch.timeout_ms.is_none());
let serialized = serde_json::to_string(&value.launch).expect("serialize launch");
assert!(!serialized.contains("timeoutMs"));
let reparsed = serde_json::from_str::<super::super::protocol::LaunchRequest>(&serialized)
.expect("reparse launch");
assert_eq!(
launch_digest(&value.launch).expect("digest"),
launch_digest(&reparsed).expect("digest"),
);
}
#[test]
fn accepts_real_windows_env_names_and_rejects_block_breaking_ones() {
// `CommonProgramFiles(x86)` is a standard Windows variable; the
// launcher must not reject legitimate names that contain parentheses.
let mut value = request();
value.launch.environment.insert(
"CommonProgramFiles(x86)".to_owned(),
"C:\\Program Files (x86)\\Common Files".to_owned(),
);
assert!(value.validate().is_ok());
for bad_name in ["A=B", "=C:", "BAD\u{0}NAME", ""] {
let mut invalid = request();
invalid
.launch
.environment
.insert(bad_name.to_owned(), "value".to_owned());
assert!(
invalid.validate().is_err(),
"expected env name {bad_name:?} to be rejected"
);
}
let mut nul_value = request();
nul_value
.launch
.environment
.insert("GOOD_NAME".to_owned(), "has\u{0}nul".to_owned());
assert_eq!(
nul_value.validate().unwrap_err(),
"invalid environment value for GOOD_NAME"
);
}
#[test]
fn accepts_timeout_within_bounds_and_rejects_outside() {
let mut value = request();
value.launch.timeout_ms = Some(130_000);
assert!(value.validate().is_ok());
value.launch.timeout_ms = Some(999);
assert_eq!(
value.validate().unwrap_err(),
"timeoutMs must be between 1000 and 600000"
);
value.launch.timeout_ms = Some(600_001);
assert!(value.validate().is_err());
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,163 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Coverage for the private-desktop *placement* (RFC §6.3): the security
//! descriptor handed to `CreateDesktopExW`, the child-side attestation that
//! rejects everything but the launcher-owned desktop, and a real-OS check that
//! the supported launch concurrency fits the bounded desktop-heap budget.
//! These attest initial `lpDesktop` placement plus the desktop security
//! descriptor, not escape-proof confinement (§6.5).
use std::ffi::c_void;
use std::ptr::null_mut;
use windows_sys::Win32::Security::FreeSid;
use windows_sys::Win32::Security::Isolation::DeriveAppContainerSidFromAppContainerName;
use crate::desktop_is_private_placement;
use crate::windows_launcher::{create_confined_desktop, desktop_sddl};
const APP_SID: &str =
"S-1-15-2-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890";
const OWNER_SID: &str = "S-1-5-21-11111111-22222222-33333333-1001";
#[test]
fn desktop_dacl_grants_owner_and_system_full_control() {
let sddl = desktop_sddl(OWNER_SID, APP_SID);
// Protected DACL: no inherited ACEs bleed onto the private desktop.
assert!(sddl.starts_with("D:P"), "DACL must be protected: {sddl}");
assert!(
sddl.contains(&format!("(A;;GA;;;{OWNER_SID})")),
"owner keeps full control for cleanup: {sddl}"
);
assert!(
sddl.contains("(A;;GA;;;SY)"),
"Local System keeps full control: {sddl}"
);
}
#[test]
fn desktop_dacl_denies_interactive_control_to_the_effective_user_sid() {
let sddl = desktop_sddl(OWNER_SID, APP_SID);
// The AppContainer child's token carries the launching-user SID as an
// effective SID, so the owner GA allow would otherwise name the child as a
// grantee of SWITCHDESKTOP(0x100) | HOOKCONTROL(0x8) | JOURNALRECORD(0x10)
// | JOURNALPLAYBACK(0x20) = 0x138. A deny ACE strips those bits in the
// DACL itself instead of relying on lowbox access-check intersection.
let deny = sddl
.find(&format!("(D;;0x138;;;{OWNER_SID})"))
.expect("deny ACE for interactive-control rights must be present");
// Deny ACEs must precede allow ACEs to be effective in canonical order.
let first_allow = sddl.find("(A;;").expect("allow ACEs present");
assert!(
deny < first_allow,
"deny ACE must precede every allow ACE: {sddl}"
);
}
#[test]
fn desktop_dacl_grants_app_container_only_minimal_rights() {
let sddl = desktop_sddl(OWNER_SID, APP_SID);
// READOBJECTS(0x1) | CREATEWINDOW(0x2) | CREATEMENU(0x4) | ENUMERATE(0x40)
// | WRITEOBJECTS(0x80) = 0xc7. Never SWITCHDESKTOP(0x100), HOOKCONTROL(0x8),
// or JOURNAL*(0x10/0x20).
assert!(
sddl.contains(&format!("(A;;0xc7;;;{APP_SID})")),
"AppContainer SID gets exactly the minimal non-interactive mask: {sddl}"
);
assert!(
!sddl.contains(&format!("GA;;;{APP_SID}")),
"AppContainer SID must never get generic-all on the desktop: {sddl}"
);
}
#[test]
fn private_placement_requires_the_launcher_owned_desktop_prefix() {
assert!(desktop_is_private_placement(
"maka-sandbox-desktop.4321.1699999999999999999"
));
// Desktop names are case-insensitive on Windows.
assert!(desktop_is_private_placement("MAKA-SANDBOX-DESKTOP.1.2"));
assert!(!desktop_is_private_placement("Default"));
assert!(!desktop_is_private_placement("default"));
assert!(!desktop_is_private_placement(""));
// Not-Default is not enough: a child that lands on any other pre-existing
// desktop was not placed on the launcher's per-launch private desktop.
assert!(!desktop_is_private_placement("Winlogon"));
assert!(!desktop_is_private_placement("Screen-saver"));
// The bare prefix with no pid/nonce suffix is not a launcher-created name.
assert!(!desktop_is_private_placement("maka-sandbox-desktop."));
}
#[test]
fn desktop_sddl_pins_a_low_integrity_no_write_up_label() {
let sddl = desktop_sddl(OWNER_SID, APP_SID);
// Without an explicit label the desktop inherits the creator's Medium
// integrity, and MIC — evaluated before the DACL — would make the Low-IL
// AppContainer child's granted create-window/write rights unusable.
assert!(
sddl.ends_with("S:(ML;;NW;;;LW)"),
"desktop must carry a Low no-write-up mandatory label: {sddl}"
);
}
#[test]
fn ten_confined_desktops_can_be_held_live() {
// Real-OS budget check for `CONFINED_DESKTOP_HEAP_KB` (RFC §6.3): the
// repository supports ten-way launch concurrency, so ten private desktops
// must be able to exist *simultaneously* without exhausting the system
// desktop heap. The default 3,072 KiB per-desktop allocation would put ten
// live desktops at ~30 MiB of the documented 48 MiB limit; the bounded
// 512 KiB budget keeps them at ~5 MiB. Children are not launched — the
// desktops themselves own the heap allocation this bounds.
let mut sid: *mut c_void = null_mut();
let name: Vec<u16> = "maka-desktop-heap-test"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let derived =
unsafe { DeriveAppContainerSidFromAppContainerName(name.as_ptr(), &mut sid) } == 0;
assert!(derived, "derive test AppContainer SID");
let desktops: Vec<_> = (0..10)
.map(|index| {
unsafe { create_confined_desktop(sid) }
.unwrap_or_else(|error| panic!("desktop {index} must fit the heap budget: {error}"))
})
.collect();
assert_eq!(desktops.len(), 10);
// Ten *distinct* objects, not ten handles to a reopened one:
// `CreateDesktopExW` opens an existing desktop on a name collision (and
// then silently ignores the supplied DACL and heap size), so the CSPRNG
// nonce must have produced ten different names while all handles are held
// live simultaneously.
let names: std::collections::HashSet<String> = desktops
.iter()
.map(|desktop| desktop.name_string())
.collect();
assert_eq!(names.len(), 10, "desktop names must be unique: {names:?}");
for name in &names {
assert!(
desktop_is_private_placement(name),
"every created desktop must pass the placement attestation: {name}"
);
}
// Drop order releases all ten handles; the kernel reclaims the desktops.
drop(desktops);
unsafe { FreeSid(sid) };
}
@@ -0,0 +1,214 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use crate::acl_ledger::readiness_mutex_name;
use crate::protocol::{LaunchRequest, NetworkMode, RESERVED_READINESS_REQUEST_ID};
use crate::windows_launcher::{
appcontainer_profile_name, appcontainer_readiness_profile_name, environment_block,
readiness_probe_command_line,
};
fn valid_launch_request(request_id: &str) -> LaunchRequest {
LaunchRequest {
version: 1,
request_id: request_id.to_owned(),
executable: "C:\\Windows\\System32\\cmd.exe".to_owned(),
arguments: Vec::new(),
cwd: "C:\\Windows\\System32".to_owned(),
read_roots: Vec::new(),
write_roots: Vec::new(),
exact_read_roots: Vec::new(),
exact_write_roots: Vec::new(),
network: NetworkMode::Restricted,
environment: BTreeMap::new(),
timeout_ms: None,
}
}
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().collect()
}
fn block_strings(block: &[u16]) -> Vec<String> {
block
.split(|value| *value == 0)
.filter(|chunk| !chunk.is_empty())
.map(|chunk| String::from_utf16(chunk).expect("utf16 environment entry"))
.collect()
}
#[test]
fn builds_a_sorted_double_terminated_environment_block() {
// Providing every substrate name keeps the block fully deterministic:
// nothing is filled in from the test host's environment.
let mut environment = BTreeMap::new();
environment.insert("B".to_owned(), "2".to_owned());
environment.insert("A".to_owned(), "1".to_owned());
environment.insert("SystemRoot".to_owned(), "C:\\Windows".to_owned());
environment.insert("SystemDrive".to_owned(), "C:".to_owned());
environment.insert(
"LOCALAPPDATA".to_owned(),
"C:\\Users\\u\\AppData\\Local".to_owned(),
);
let mut expected = Vec::new();
for entry in [
"A=1",
"B=2",
"LOCALAPPDATA=C:\\Users\\u\\AppData\\Local",
"SystemDrive=C:",
"SystemRoot=C:\\Windows",
] {
expected.extend(wide(entry));
expected.push(0);
}
expected.push(0);
assert_eq!(environment_block(&environment), expected);
}
#[test]
fn empty_allowlist_fills_substrate_variables_but_stays_explicit() {
// A null environment pointer would make CreateProcess inherit the
// broker's ambient environment, bypassing the allowlist boundary.
// AppContainer creation itself needs LOCALAPPDATA (else CreateProcessW
// fails with os error 203), so those substrate paths are filled from
// the broker; nothing else may leak in.
let block = environment_block(&BTreeMap::new());
let entries = block_strings(&block);
assert!(
entries
.iter()
.any(|entry| entry.to_ascii_uppercase().starts_with("LOCALAPPDATA=")),
"expected LOCALAPPDATA in {entries:?}"
);
for entry in &entries {
let name = entry.split('=').next().unwrap_or_default();
assert!(
["SystemRoot", "SystemDrive", "LOCALAPPDATA"]
.iter()
.any(|substrate| substrate.eq_ignore_ascii_case(name)),
"unexpected non-substrate entry {entry:?}"
);
}
assert_eq!(&block[block.len() - 2..], &[0, 0]);
}
#[test]
fn manifest_provided_substrate_values_win_case_insensitively() {
let mut environment = BTreeMap::new();
environment.insert("localappdata".to_owned(), "D:\\CustomLocal".to_owned());
let entries = block_strings(&environment_block(&environment));
assert!(entries.contains(&"localappdata=D:\\CustomLocal".to_owned()));
assert_eq!(
entries
.iter()
.filter(|entry| entry.to_ascii_uppercase().starts_with("LOCALAPPDATA="))
.count(),
1,
"manifest value must not be duplicated by broker injection: {entries:?}"
);
}
#[test]
fn appcontainer_profile_identity_is_unique_and_bounded_per_request() {
let first = appcontainer_profile_name("request-one");
let second = appcontainer_profile_name("request-two");
assert_ne!(first, second);
let rendered = String::from_utf16(&first[..first.len() - 1]).expect("profile name");
assert!(rendered.starts_with("maka.sandbox."));
assert_eq!(rendered.len(), "maka.sandbox.".len() + 32);
}
#[test]
fn readiness_profile_name_is_fixed_and_self_reconciling() {
// The readiness profile uses one stable name so a profile leaked by an
// externally-killed probe is reclaimed by the next probe rather than
// accumulating under a unique per-invocation name.
let first = appcontainer_readiness_profile_name();
let second = appcontainer_readiness_profile_name();
assert_eq!(first, second);
let rendered = String::from_utf16(&first[..first.len() - 1]).expect("readiness name");
assert!(rendered.starts_with("maka.readiness."));
assert_eq!(rendered.len(), "maka.readiness.".len() + 32);
}
#[test]
fn readiness_namespace_is_disjoint_from_production() {
// Structural separation, not just a different hash input: the readiness
// profile lives under `maka.readiness.` while every production profile
// lives under `maka.sandbox.`, so the two lifecycles can never resolve to
// the same registration even if `validate` were bypassed. Feeding the
// reserved id through the production deriver must still land in the
// production namespace, disjoint from the readiness one.
let readiness = appcontainer_readiness_profile_name();
let production_reserved = appcontainer_profile_name(RESERVED_READINESS_REQUEST_ID);
assert_ne!(readiness, production_reserved);
let readiness_str = String::from_utf16(&readiness[..readiness.len() - 1]).expect("name");
let production_str =
String::from_utf16(&production_reserved[..production_reserved.len() - 1])
.expect("name");
assert!(readiness_str.starts_with("maka.readiness."));
assert!(production_str.starts_with("maka.sandbox."));
}
#[test]
fn validate_rejects_reserved_readiness_request_id() {
// A production launch may never carry the readiness identity: otherwise
// it would resolve to a profile the readiness probe deletes and recreates.
let reserved = valid_launch_request(RESERVED_READINESS_REQUEST_ID);
let error = reserved
.validate()
.expect_err("reserved id must be rejected");
assert!(
error.contains(RESERVED_READINESS_REQUEST_ID) && error.contains("reserved"),
"unexpected error: {error}"
);
// The same request with an ordinary id is otherwise valid, proving the id
// is the sole reason for rejection.
assert!(valid_launch_request("request-one").validate().is_ok());
}
#[test]
fn readiness_lease_name_is_scoped_and_distinct() {
// The readiness lease is a machine-wide (`Global\`) named mutex scoped by
// the owning user's SID, and carries a distinct object name from the ACL
// ledger mutex so the two never contend.
let sid = "S-1-5-21-1111111111-2222222222-3333333333-1001";
let name = readiness_mutex_name(sid);
assert!(name.starts_with(r"Global\Maka.WindowsSandbox.ReadinessProfile."));
assert!(name.ends_with(sid));
assert!(!name.contains("AclLedger"));
}
#[test]
fn readiness_command_line_disables_autorun_before_exit() {
// `/d` must precede `/c` so cmd.exe skips the machine-constant
// `Command Processor\AutoRun` value. Without it, a host whose AutoRun
// exits non-zero (or blocks) would make the readiness probe fail closed
// on every startup, disabling the sandbox regardless of the boundary.
let line = readiness_probe_command_line("C:\\Windows\\System32\\cmd.exe");
assert_eq!(line, "\"C:\\Windows\\System32\\cmd.exe\" /d /c exit 0");
let d = line.find("/d").expect("/d present");
let c = line.find("/c").expect("/c present");
assert!(d < c, "/d must come before /c: {line}");
}
}
@@ -0,0 +1,76 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
param(
[string]$LauncherPath
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcher = if ($LauncherPath) {
$LauncherPath
} else {
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
}
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
# The production-identity readiness probe (RFC §6.4) stands up the real
# AppContainer identity, token, and kill-on-close Job and launches a throwaway
# confined child. Exit 0 alone is not evidence: the probe emits a machine-
# readable attestation of what it verified, and this gate asserts those exact
# fields so removing the exact-SID match, the specific-Job membership check,
# the settlement drain, or the private-desktop placement turns this red rather
# than leaving a hollow exit-0 gate green.
function Assert-ReadinessAttestation {
param([string]$Rendered, [string]$Phase)
if ($Rendered -notmatch '"appContainerSidVerified":true' -or
$Rendered -notmatch '"jobVerified":true' -or
$Rendered -notmatch '"settled":true' -or
$Rendered -notmatch '"appContainerSid":"S-1-15-2-' -or
$Rendered -notmatch '"desktop":"maka-sandbox-desktop\.' -or
$Rendered -notmatch '"desktopPrivatePlacement":true') {
throw "Readiness probe ($Phase) did not attest the verified boundary: $Rendered"
}
}
$firstOutput = & $launcher --readiness-probe 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Readiness probe did not succeed: exit=$LASTEXITCODE output=$($firstOutput -join "`n")"
}
Assert-ReadinessAttestation -Rendered ($firstOutput -join "`n") -Phase 'first run'
# Availability is memoized per host, but the probe itself must be repeatable:
# a second run proves the confined child and Job teardown leave no residue that
# blocks the next probe.
$secondOutput = & $launcher --readiness-probe 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Readiness probe was not repeatable: exit=$LASTEXITCODE output=$($secondOutput -join "`n")"
}
Assert-ReadinessAttestation -Rendered ($secondOutput -join "`n") -Phase 'second run'
# The probe takes no arguments; passing one must fail closed rather than silently
# ignore unexpected input.
$rejected = & $launcher --readiness-probe unexpected 2>&1
if ($LASTEXITCODE -eq 0) {
throw "Readiness probe accepted an unexpected argument: $($rejected -join "`n")"
}
$global:LASTEXITCODE = 0
Write-Host "Production-identity readiness probe verified: attested SID/Job/settlement/desktop, repeatable, argument-rejecting: $($secondOutput -join ' ')"
@@ -0,0 +1,132 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
param(
[string]$LauncherPath
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
$launcher = if ($LauncherPath) {
$LauncherPath
} else {
Join-Path $PSScriptRoot 'launcher\target\debug\maka-windows-sandbox.exe'
}
if (-not (Test-Path -LiteralPath $launcher)) {
throw "Missing launcher binary: $launcher"
}
$tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$workRoot = Join-Path $tempRoot "maka-stdio-relay-$PID"
New-Item -ItemType Directory -Path $workRoot -Force | Out-Null
function Invoke-RelayLaunch {
param(
[string[]]$ChildArguments,
[byte[]]$StdinBytes,
[Nullable[int]]$TimeoutMs
)
$requestPath = Join-Path $workRoot "request-$([guid]::NewGuid().ToString('N')).json"
$stdinPath = Join-Path $workRoot 'stdin.bin'
$stdoutPath = Join-Path $workRoot 'stdout.bin'
$stderrPath = Join-Path $workRoot 'stderr.txt'
[IO.File]::WriteAllBytes($stdinPath, $StdinBytes)
$request = [ordered]@{
version = 1
requestId = "stdio-relay-$PID"
executable = $launcher
arguments = $ChildArguments
cwd = Split-Path -Parent $launcher
readRoots = @()
writeRoots = @()
network = 'restricted'
environment = @{}
}
if ($null -ne $TimeoutMs) { $request.timeoutMs = $TimeoutMs }
$request | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $requestPath -Encoding utf8
$process = Start-Process -FilePath $launcher `
-ArgumentList @('--appcontainer', $requestPath) `
-RedirectStandardInput $stdinPath `
-RedirectStandardOutput $stdoutPath `
-RedirectStandardError $stderrPath `
-NoNewWindow -PassThru -Wait
Remove-Item -LiteralPath $requestPath -Force -ErrorAction SilentlyContinue
[pscustomobject]@{
ExitCode = $process.ExitCode
Stdout = [IO.File]::ReadAllText($stdoutPath)
Stderr = [IO.File]::ReadAllText($stderrPath)
}
}
function Assert-Probe {
param($Result, [byte[]]$Payload, [string]$Case)
if ($Result.ExitCode -ne 0) {
throw "${Case}: relay launch failed: exit=$($Result.ExitCode) stderr=$($Result.Stderr)"
}
# stdout must be exactly the child's single JSON response: the relay owns
# the response channel and nothing else may leak into it.
$document = $null
try {
$document = $Result.Stdout.Trim() | ConvertFrom-Json
} catch {
throw "${Case}: stdout is not a single JSON document: $($Result.Stdout)"
}
$sha = [BitConverter]::ToString(
[Security.Cryptography.SHA256]::Create().ComputeHash($Payload)
).Replace('-', '').ToLowerInvariant()
if ($document.echoBytes -ne $Payload.Length -or $document.sha256 -ne $sha) {
throw "${Case}: child did not receive the exact stdin payload: $($Result.Stdout)"
}
if ($Result.Stderr -notmatch '"appContainer":true' -or
$Result.Stderr -notmatch 'stdio-probe: diagnostics stay on stderr') {
throw "${Case}: diagnostics did not stay on stderr: $($Result.Stderr)"
}
}
try {
# Small payload roundtrip.
$small = [Text.Encoding]::UTF8.GetBytes('stdio-relay-small-payload')
Assert-Probe -Result (Invoke-RelayLaunch -ChildArguments @('--stdio-probe') -StdinBytes $small) `
-Payload $small -Case 'small'
# Empty stdin must still reach the child as immediate EOF.
Assert-Probe -Result (Invoke-RelayLaunch -ChildArguments @('--stdio-probe') -StdinBytes @()) `
-Payload @() -Case 'eof'
# Large payload (5 MiB, below the runtime's 8 MiB response cap).
$large = [byte[]]::new(5MB)
[Random]::new(20260815).NextBytes($large)
Assert-Probe -Result (Invoke-RelayLaunch -ChildArguments @('--stdio-probe') -StdinBytes $large) `
-Payload $large -Case 'large'
# Timeout: a child sleeping past timeoutMs must be killed and reported.
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
$timedOut = Invoke-RelayLaunch -ChildArguments @('--stdio-probe', '--sleep', '60') `
-StdinBytes @() -TimeoutMs 2000
$stopwatch.Stop()
if ($timedOut.ExitCode -eq 0 -or $timedOut.Stderr -notmatch 'timeout') {
throw "timeout: broker did not enforce timeoutMs: exit=$($timedOut.ExitCode) stderr=$($timedOut.Stderr)"
}
if ($stopwatch.Elapsed.TotalSeconds -gt 30) {
throw "timeout: broker took $($stopwatch.Elapsed.TotalSeconds)s to enforce a 2s timeout"
}
Write-Host 'Filesystem-worker stdio relay verified: roundtrip, EOF, large payload, stdout purity, timeout kill'
} finally {
Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue
}