diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index d01f83fc..fbddb38d 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -1260,7 +1260,8 @@ The later SDK validator repin must use these exact committed file bytes: | `scripts/phase1-windows-supervisor-build.sh` | 4,646 | `713a9e0282887ade3e243b5ba175794d74cdb02c28c38dcd41491c9505812770` | | `scripts/phase1-windows-supervisor-install.ps1` | 1,743 | `2baab275f0bb6789884cded5f6185d00bfa5348b9e7c3ad1e5575353639101d5` | | `scripts/windows-job-supervisor.cs` | 291,329 | `08c18fa81b16f922b3fac32abec3a2f6369e5f2b9f4caa19a0b48df6302bb110` | -| `scripts/windows-job-supervisor.test.ps1` | 171,179 | `55e9cf065e2dc7cc656c6aa8cc9ea53542259d3d7eee55c368c6cf0fc6356ab9` | +| `scripts/windows-job-supervisor.test.ps1` | 172,760 | `cecc4c4a88ddceff68ab941798a700d2f60e18048be6373044e6c115b08bfcfe` | +| `scripts/windows-status-acl-probe.cs` | 6,559 | `aeb7fec2d8becf63b5e94e93d2f8b56cf761ea76d4a714a33f6457a3c65dabe7` | The table above is the SDK-facing subset; `phase1-conformance.lock.json`'s `harnessAuthority.files` also tracks `.github/workflows/ci.yml`, which does diff --git a/scripts/windows-job-supervisor.test.ps1 b/scripts/windows-job-supervisor.test.ps1 index 1f50a83a..27565726 100644 --- a/scripts/windows-job-supervisor.test.ps1 +++ b/scripts/windows-job-supervisor.test.ps1 @@ -1542,6 +1542,16 @@ public static class ScmDenialProbe '@, [Text.UTF8Encoding]::new($false) ) + $statusAclProbeSource = Join-Path $root 'windows-status-acl-probe.cs' + [IO.File]::Copy( + (Join-Path $PSScriptRoot 'windows-status-acl-probe.cs'), + $statusAclProbeSource + ) + Add-Type -Path $statusAclProbeSource + $statusAclControl = [StatusAclProbe]::RunControl([IO.Path]::GetTempPath()) + if ($statusAclControl -cne "combined:success`nowner-only:success`ndacl-only:success") { + throw "Ordinary-directory status ACL control failed: $statusAclControl" + } $accessProbeScript = Join-Path $root 'job-access-probe.ps1' [IO.File]::WriteAllText( $accessProbeScript, @@ -1554,6 +1564,13 @@ Add-Type -TypeDefinition ([IO.File]::ReadAllText('$($accessProbeSource.Replace(" [long]`$env:OPENCOVEN_WINDOWS_SUPERVISOR_JOB_HANDLE ) [JobAccessProbe]::Run(`$env:OPENCOVEN_ACCESS_PROBE_JOB) +Add-Type -Path '$($statusAclProbeSource.Replace("'", "''"))' +`$statusAclResult = [StatusAclProbe]::Run( + `$env:TEMP, + `$env:OPENCOVEN_STATUS_ACL_SUPERVISOR_SID +) +Write-Output "status-acl-probe:`n`$statusAclResult" + `$root = [IO.Path]::GetFullPath(`$env:OPENCOVEN_WINDOWS_BOOTSTRAP_ROOT) `$profile = [IO.Path]::GetFullPath(`$env:USERPROFILE) `$temp = [IO.Path]::GetFullPath(`$env:TEMP) @@ -1645,6 +1662,13 @@ if (-not `$wmiDenied) { "Local\OpenCoven.Chat.SupervisorTest.$([Guid]::NewGuid().ToString('N'))" $accessEnvironment = $childEnvironment.Clone() $accessEnvironment.OPENCOVEN_ACCESS_PROBE_JOB = $accessJobName + $statusAclIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() + try { + $accessEnvironment.OPENCOVEN_STATUS_ACL_SUPERVISOR_SID = $statusAclIdentity.User.Value + } finally { + $statusAclIdentity.Dispose() + } + $accessEnvironment.OPENCOVEN_DENIAL_SERVICE_NAME = "OpenCovenSupervisorTest$([Guid]::NewGuid().ToString('N'))" $accessEnvironment.OPENCOVEN_DENIAL_WMI_FILTER_NAME = @@ -1664,6 +1688,20 @@ if (-not `$wmiDenied) { if ($accessResult.ExitCode -ne 0) { throw "Protected Job Object DACL runtime probe failed: $($accessResult.Stderr)" } + $statusAclLines = $accessResult.Stdout.Trim() -split '\r?\n' + if ($statusAclLines.Count -ne 4 -or $statusAclLines[0] -cne 'status-acl-probe:') { + throw 'Status ACL probe result shape was invalid.' + } + $statusAclLabels = @('combined', 'owner-only', 'dacl-only') + for ($index = 0; $index -lt 3; $index++) { + $pattern = '^' + $statusAclLabels[$index] + + ':(success|access-denied|invalid-owner|privilege-not-held|unclassified)$' + if ($statusAclLines[$index + 1] -cnotmatch $pattern) { + throw 'Status ACL probe result was outside the bounded vocabulary.' + } + Write-Host "status-acl.$($statusAclLines[$index + 1])" + } + } finally { $accessJob.Dispose() } diff --git a/scripts/windows-status-acl-probe.cs b/scripts/windows-status-acl-probe.cs new file mode 100644 index 00000000..b3ff4124 --- /dev/null +++ b/scripts/windows-status-acl-probe.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Security.AccessControl; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Security.Principal; + +// Run only inside a disposable directory supplied by the existing supervisor +// test context. No ACL or token privilege changes are made to that directory. +public static class StatusAclProbe +{ + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool ConvertStringSecurityDescriptorToSecurityDescriptorW( + string text, uint revision, out IntPtr descriptor, out uint size); + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetSecurityDescriptorDacl(IntPtr descriptor, + out bool present, out IntPtr dacl, out bool defaulted); + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] + private static extern uint SetNamedSecurityInfoW(string name, uint type, + uint information, IntPtr owner, IntPtr group, IntPtr dacl, IntPtr sacl); + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); + + private static string Classify(uint status) + { + if (status == 0) return "success"; + if (status == 5) return "access-denied"; + if (status == 1307) return "invalid-owner"; + if (status == 1314) return "privilege-not-held"; + return "unclassified"; + } + + private static void VerifyInheritedSecurity(string path, string supervisorSid) + { + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) + { + if (identity.User == null) throw new InvalidOperationException("token-user-missing"); + FileSecurity security = new FileInfo(path).GetAccessControl(); + if (!identity.User.Equals(security.GetOwner(typeof(SecurityIdentifier)))) + throw new InvalidOperationException("created-file-owner-mismatch"); + var expected = new Dictionary { + { "S-1-5-18", 0x001f01ff }, + { "S-1-5-32-544", 0x001f01ff }, + { supervisorSid, 0x001f01ff }, + { identity.User.Value, 0x001301bf }, + { "S-1-3-4", 0x00020000 } + }; + var descriptor = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0); + if (descriptor.DiscretionaryAcl == null || descriptor.DiscretionaryAcl.Count != expected.Count) + throw new InvalidOperationException("created-file-acl-count-mismatch"); + foreach (GenericAce entry in descriptor.DiscretionaryAcl) + { + var ace = entry as CommonAce; + int mask; + if (ace == null || ace.IsCallback || ace.AceQualifier != AceQualifier.AccessAllowed + || (ace.AceFlags & AceFlags.Inherited) == 0 + || (ace.AceFlags & AceFlags.InheritOnly) != 0 + || !expected.TryGetValue(ace.SecurityIdentifier.Value, out mask) + || ace.AccessMask != mask) + throw new InvalidOperationException("created-file-acl-mismatch"); + expected.Remove(ace.SecurityIdentifier.Value); + } + if (expected.Count != 0) throw new InvalidOperationException("created-file-acl-missing"); + } + } + + public static string RunControl(string scratchDirectory) + { + return RunInternal(scratchDirectory, null); + } + + public static string Run(string scratchDirectory, string supervisorSid) + { + if (string.IsNullOrEmpty(supervisorSid)) + throw new InvalidOperationException("supervisor-identity-required"); + return RunInternal(scratchDirectory, supervisorSid); + } + + private static string RunInternal(string scratchDirectory, string supervisorSid) + { + if (!OperatingSystem.IsWindows()) throw new InvalidOperationException("windows-required"); + if (!Directory.Exists(scratchDirectory)) throw new InvalidOperationException("scratch-required"); + IntPtr descriptor = IntPtr.Zero; + IntPtr owner = IntPtr.Zero; + string root = Path.Combine(scratchDirectory, "status-acl-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + uint size; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + "D:P(A;;GA;;;OW)", 1, out descriptor, out size)) + throw new InvalidOperationException("descriptor-conversion-failed"); + bool present, defaulted; + IntPtr dacl; + if (!GetSecurityDescriptorDacl(descriptor, out present, out dacl, out defaulted) + || !present || dacl == IntPtr.Zero) + throw new InvalidOperationException("descriptor-dacl-failed"); + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) + { + if (identity.User == null) throw new InvalidOperationException("token-user-missing"); + byte[] sid = new byte[identity.User.BinaryLength]; + identity.User.GetBinaryForm(sid, 0); + owner = Marshal.AllocHGlobal(sid.Length); + Marshal.Copy(sid, 0, owner, sid.Length); + } + string[] labels = { "combined", "owner-only", "dacl-only" }; + uint[] flags = { 0x80000005u, 1u, 0x80000004u }; + string[] results = new string[3]; + for (int i = 0; i < labels.Length; i++) + { + string path = Path.Combine(root, labels[i]); + // Exclusive creation, write, sync, and close mirror the writer. + using (FileStream file = new FileStream(path, FileMode.CreateNew, FileAccess.Write)) + { + file.WriteByte(10); + file.Flush(true); + } + if (supervisorSid != null) VerifyInheritedSecurity(path, supervisorSid); + uint status = SetNamedSecurityInfoW(path, 1, flags[i], + i == 2 ? IntPtr.Zero : owner, IntPtr.Zero, + i == 1 ? IntPtr.Zero : dacl, IntPtr.Zero); + results[i] = labels[i] + ":" + Classify(status); + } + return string.Join("\n", results); + } + finally + { + if (owner != IntPtr.Zero) Marshal.FreeHGlobal(owner); + if (descriptor != IntPtr.Zero) LocalFree(descriptor); + // File deletion uses existing modify rights, never an ACL override. + Directory.Delete(root, true); + } + } +} diff --git a/src/client-v1-conformance-workflow.test.ts b/src/client-v1-conformance-workflow.test.ts index 7c89aa2d..795ceb16 100644 --- a/src/client-v1-conformance-workflow.test.ts +++ b/src/client-v1-conformance-workflow.test.ts @@ -2307,6 +2307,7 @@ ${source.slice(start, end)} 'scripts/phase1-windows-supervisor-install.ps1', 'scripts/windows-job-supervisor.cs', 'scripts/windows-job-supervisor.test.ps1', + 'scripts/windows-status-acl-probe.cs', ]; for (const relativePath of [...new Set(metadataPaths)]) { const bytes = readFileSync(resolve(projectRoot, relativePath));