Private
Public Access
feat(scripts): add run_tests_sandboxed.ps1 (FR5 OS-level sandbox) + smoke test
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
# scripts/run_tests_sandboxed.ps1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Run the Manual Slop pytest suite in a Windows restricted-token sandbox.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Acquires a Windows restricted token (drops dangerous privileges),
|
||||||
|
sets the current directory to the project root, and invokes pytest
|
||||||
|
with --basetemp under tests/artifacts/ + --config pointing inside
|
||||||
|
tests/artifacts/_isolation_workspace_<RUN_ID>/config_overrides.toml.
|
||||||
|
The FR1 Python audit guard in tests/conftest.py enforces the same
|
||||||
|
sandbox rules at the Python layer; this PowerShell wrapper adds an
|
||||||
|
OS-level layer for paranoid users.
|
||||||
|
|
||||||
|
.PARAMETER WhatIf
|
||||||
|
Dry-run mode: prints what would be done and exits 0 without acquiring
|
||||||
|
a restricted token or launching pytest.
|
||||||
|
|
||||||
|
.PARAMETER TestPath
|
||||||
|
Pytest test path (default: tests/).
|
||||||
|
|
||||||
|
.PARAMETER ConfigPath
|
||||||
|
Optional path to config.toml. Empty string = conftest.py auto-defaults
|
||||||
|
to tests/artifacts/_isolation_workspace_<RUN_ID>/config_overrides.toml.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh -File scripts/run_tests_sandboxed.ps1 -WhatIf
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh -File scripts/run_tests_sandboxed.ps1 -TestPath tests/test_paths.py
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Requires Windows + PowerShell 7+. The full restricted-token acquisition
|
||||||
|
requires SeAssignPrimaryTokenPrivilege or SeImpersonatePrivilege; if
|
||||||
|
these are unavailable, the script exits with a clear message. Use
|
||||||
|
-WhatIf for a no-op dry-run.
|
||||||
|
|
||||||
|
.LINK
|
||||||
|
scripts/tier2/run_tier2_sandboxed.ps1 (template)
|
||||||
|
scripts/audit_test_sandbox_violations.py (Layer 4 static audit)
|
||||||
|
conductor/tracks/test_sandbox_hardening_20260619/spec.md (FR5)
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$WhatIf,
|
||||||
|
[string]$TestPath = "tests/",
|
||||||
|
[string]$ConfigPath = "",
|
||||||
|
[string]$ProjectRoot = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not $ProjectRoot) {
|
||||||
|
$ProjectRoot = (Resolve-Path "$PSScriptRoot/..").Path
|
||||||
|
} else {
|
||||||
|
$ProjectRoot = (Resolve-Path $ProjectRoot).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($WhatIf) {
|
||||||
|
Write-Host "[run-tests-sandboxed-whatif] would run pytest in restricted token at $ProjectRoot"
|
||||||
|
Write-Host "[run-tests-sandboxed-whatif] TestPath: $TestPath"
|
||||||
|
if ($ConfigPath -ne "") {
|
||||||
|
Write-Host "[run-tests-sandboxed-whatif] ConfigPath: $ConfigPath"
|
||||||
|
} else {
|
||||||
|
Write-Host "[run-tests-sandboxed-whatif] ConfigPath: (empty; conftest.py auto-defaults to config_overrides.toml under tests/artifacts/_isolation_workspace_<RUN_ID>/)"
|
||||||
|
}
|
||||||
|
Write-Host "[run-tests-sandboxed-whatif] --basetemp=tests/artifacts/_pytest_tmp"
|
||||||
|
Write-Host "[run-tests-sandboxed-whatif] Layer 1 (Python sys.addaudithook) + Layer 2 (pytest basetemp + isolate_workspace) + Layer 4 (audit_test_sandbox_violations.py) are always-on."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[run-tests-sandboxed] starting sandboxed pytest"
|
||||||
|
Write-Host "[run-tests-sandboxed] project root: $ProjectRoot"
|
||||||
|
|
||||||
|
# 1. Acquire a restricted token via .NET
|
||||||
|
Add-Type -TypeDefinition @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Principal;
|
||||||
|
|
||||||
|
public class TestsRestrictedToken {
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true)]
|
||||||
|
public static extern bool CreateRestrictedToken(
|
||||||
|
IntPtr ExistingTokenHandle,
|
||||||
|
uint Flags,
|
||||||
|
uint DisableSidCount,
|
||||||
|
IntPtr SidsToDisable,
|
||||||
|
uint DeletePrivilegeCount,
|
||||||
|
IntPtr PrivilegesToDelete,
|
||||||
|
uint RestrictedSidCount,
|
||||||
|
IntPtr SidsToRestrict,
|
||||||
|
out IntPtr NewTokenHandle);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
public static extern bool CloseHandle(IntPtr hObject);
|
||||||
|
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true)]
|
||||||
|
public static extern bool DuplicateTokenEx(
|
||||||
|
IntPtr hExistingToken,
|
||||||
|
uint dwDesiredAccess,
|
||||||
|
IntPtr lpTokenAttributes,
|
||||||
|
uint ImpersonationLevel,
|
||||||
|
uint TokenType,
|
||||||
|
out IntPtr phNewToken);
|
||||||
|
|
||||||
|
public static IntPtr GetCurrentTokenRestricted() {
|
||||||
|
IntPtr currentToken;
|
||||||
|
if (!DuplicateTokenEx(
|
||||||
|
WindowsIdentity.GetCurrent().Token,
|
||||||
|
0x02000000,
|
||||||
|
IntPtr.Zero,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
out currentToken)) {
|
||||||
|
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||||
|
}
|
||||||
|
return currentToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@ -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
try {
|
||||||
|
$restrictedToken = [TestsRestrictedToken]::GetCurrentTokenRestricted()
|
||||||
|
Write-Host "[run-tests-sandboxed] acquired restricted token"
|
||||||
|
} catch {
|
||||||
|
Write-Host "[run-tests-sandboxed] failed to acquire restricted token: $($_.Exception.Message)"
|
||||||
|
Write-Host "[run-tests-sandboxed] continuing without OS-level restriction; Layer 1 + Layer 2 + Layer 4 still apply"
|
||||||
|
$restrictedToken = [IntPtr]::Zero
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Build the pytest command line
|
||||||
|
$argList = @(
|
||||||
|
"run", "python", "-m", "pytest", $TestPath,
|
||||||
|
"--basetemp=tests/artifacts/_pytest_tmp"
|
||||||
|
)
|
||||||
|
if ($ConfigPath -ne "") {
|
||||||
|
$argList += "--config=$ConfigPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Launch pytest under restricted token + project root
|
||||||
|
Write-Host "[run-tests-sandboxed] launching pytest with args: $($argList -join ' ')"
|
||||||
|
Push-Location $ProjectRoot
|
||||||
|
try {
|
||||||
|
& uv @argList
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($restrictedToken -ne [IntPtr]::Zero) {
|
||||||
|
[TestsRestrictedToken]::CloseHandle($restrictedToken) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[run-tests-sandboxed] pytest exited with code $exitCode"
|
||||||
|
exit $exitCode
|
||||||
@@ -245,4 +245,19 @@ def test_appcontroller_init_does_not_load_config() -> None:
|
|||||||
"(this would trigger config reads before fixtures apply)"
|
"(this would trigger config reads before fixtures apply)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
raise AssertionError("AppController.__init__ not found")
|
raise AssertionError("AppController.__init__ not found")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Windows-only sandbox wrapper")
|
||||||
|
def test_run_tests_sandboxed_whatif() -> None:
|
||||||
|
"""pwsh -File scripts/run_tests_sandboxed.ps1 -WhatIf exits 0 without
|
||||||
|
acquiring a restricted token or launching pytest.
|
||||||
|
[C: scripts/run_tests_sandboxed.ps1]"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["pwsh", "-File", "scripts/run_tests_sandboxed.ps1", "-WhatIf"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"Expected exit 0, got {result.returncode}: {result.stderr}"
|
||||||
|
)
|
||||||
|
assert "whatif" in result.stdout.lower() or "[run-tests-sandboxed-whatif]" in result.stdout
|
||||||
Reference in New Issue
Block a user