Harden removal with native filesystem identities - #17
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Critical containment, ReFS identity, replacement handling, and cancellation issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds native, identity-pinned removal backends to harden deletion against concurrent path replacement.
Changes:
- Adds Windows handle-based and Unix descriptor-relative traversal.
- Pins filesystem identities during validation.
- Adds adversarial tests and updates security guidance.
File summaries
| File | Description |
|---|---|
AGENTS.md |
Records native-removal requirements. |
agent_docs/ui-lifecycle-and-resource-bounds.md |
Documents traversal guarantees and bounds. |
docs/reviews/adversarial-concurrency-resource-cancellation-audit-2026-08-28.md |
Updates audit status and security boundary. |
SecureRemovalBackend.cs |
Defines shared identity and backend dispatch. |
UnixSecureRemovalBackend.cs |
Implements descriptor-relative Unix removal. |
WindowsSecureRemovalBackend.cs |
Implements opened-handle Windows removal. |
RemoveValidator.cs |
Captures target identity during validation. |
RemoveService.cs |
Routes deletion through secure backends. |
RemoveServiceTests.cs |
Adds replacement-race tests. |
Review details
Suppressed comments (3)
src/SkillView.Core/Inventory/WindowsSecureRemovalBackend.cs:172
- A cancellation that arrives while the final enumeration call is completing still allows this directory handle to be marked for deletion. Recheck the token immediately before the destructive call so cancellation does not perform another directory mutation.
if (!TryDeleteHandle(frame.Directory, out var deleteError))
src/SkillView.Core/Inventory/UnixSecureRemovalBackend.cs:212
- This directory deletion follows enumeration and
fstatatwithout a final cancellation check. Cancellation during either native operation can therefore still mutate the filesystem. Recheck the token after the identity comparison and immediately beforeunlinkat.
else if (UnixNative.unlinkat(
frame.Parent.FileDescriptor,
frame.Name,
UnixConstants.RemoveDirectoryFlag) != 0)
src/SkillView.Core/Inventory/UnixSecureRemovalBackend.cs:276
- The token is checked before the second stat, but cancellation during that call still permits the link to be unlinked. Add the required final check at the destructive boundary.
if (UnixNative.unlinkat(parent.FileDescriptor, name, flags: 0) != 0)
- Files reviewed: 9/9 changed files
- Comments generated: 9
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
Identity-less cleanup deletion remains vulnerable to replacement, and Linux ARM64 stat parsing uses an incompatible layout.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/SkillView.Core/Inventory/UnixSecureRemovalBackend.cs:488
- This Linux layout assumes x86-64
struct stat, wherest_modeis at offset 24. On Linux ARM64,st_modeis at offset 16 and offset 24 isst_uid; because the backend is enabled for every Linux architecture, directories are misclassified and secure removal is refused. Use architecture-specific native layouts (or explicitly gate unsupported architectures) instead of a single hard-coded Linux layout.
return new NativeStat(
unchecked((ulong)Marshal.ReadInt64(buffer, 0)),
unchecked((ulong)Marshal.ReadInt64(buffer, 8)),
unchecked((uint)Marshal.ReadInt32(buffer, 24)));
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Linux ARM64 stat parsing is incorrect, and Windows opens request unnecessary permissions that can reject valid removals.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/SkillView.Core/Inventory/WindowsSecureRemovalBackend.cs:280
- Every open requests
FILE_WRITE_ATTRIBUTES, although identity reads need read attributes andFileDispositionInfo(Ex)requiresDELETE, not attribute-write access. An ACL can therefore allow the removal while denying attribute writes, causing validation or traversal to fail atCreateFileWunnecessarily. Drop this access bit (and its now-unused constant) so the backend does not impose permissions unrelated to the operation.
var desiredAccess = DeleteAccess
| FileReadAttributes
| FileWriteAttributes
| SynchronizeAccess;
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Windows canonicalization and identityless link cleanup leave path-redirection gaps, while unavailable openat2 can cause partial deletion.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 13/13 changed files
- Comments generated: 3
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Several remaining race windows can authorize or redirect deletion of replacement filesystem entries.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/SkillView.Core/Inventory/RemoveValidator.cs:344
- The brokenness check is not bound to the identity captured afterward. A broken cleanup link can be replaced with a valid symlink after
Resolvereturns null but beforeTryCaptureLinkIdentity; validation then pins and authorizes deletion of the new valid link. Check brokenness through the captured parent/link boundary, or recapture and compare identities around the target-resolution check, soValidateBrokenSymlinkcannot approve a different entry.
else if (requireBroken && PathResolver.Resolve(fullPath) is not null)
{
errors.Add(new Error(ErrorKind.NotASkillDirectory,
$"'{fullPath}' is no longer broken"));
}
- Files reviewed: 16/16 changed files
- Comments generated: 3
- Review effort level: Balanced
There was a problem hiding this comment.
🔵 Needs a closer look
Object-policy and broken-link checks remain vulnerable to concurrent pathname replacement.
Review details
Suppressed comments (2)
src/SkillView.Core/Inventory/RemoveValidator.cs:347
- The brokenness check and identity capture apply to different observations. If the original broken link is replaced with a valid link after
Resolvereturns null but beforeTryCaptureLinkIdentity, validation pins the valid replacement and cleanup later deletes it even though it is no longer a broken-link candidate. Capture first and verify brokenness through the same held parent/link identity (for example, descriptor-relativereadlinkaton Unix and the opened reparse handle on Windows), failing if that object changes.
else if (requireBroken && PathResolver.Resolve(fullPath) is not null)
{
errors.Add(new Error(ErrorKind.NotASkillDirectory,
$"'{fullPath}' is no longer broken"));
}
else if (matchedRootByRawPath is not null)
{
if (SecureRemovalBackend.TryCaptureLinkIdentity(
src/SkillView.Core/Inventory/RemoveValidator.cs:92
- The captured identity does not bind the subsequent skill/
.gitpolicy checks to this object. A same-user process can rename an ancestor after capture, put a clean skill at the same canonical pathname whileLooksLikeSkilland the.gitcheck run, then restore the original ancestor; execution will match the stored target identity (an ancestor rename does not change the target's Unix ctime) and can delete an object whose policy was never evaluated, such as an in-place clone. Keep the opened target handle/descriptor through the object-local policy checks and inspectSKILL.mdand.gitrelative to it; pathname rechecks cannot close this ABA swap.
executionIdentity = capturedIdentity;
resolved = capturedIdentity.CanonicalPath;
- Files reviewed: 17/17 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Addressed the two suppressed “needs a closer look” findings in commit 279a974, with Windows regression-test corrections in d5eefbf and 24abfcf.
Verification is green: 727 local Release tests, formatting and build checks, sequential local Native AOT publishes, and all GitHub test, CodeQL, and AOT jobs on macOS, Linux, and Windows. |
There was a problem hiding this comment.
🔵 Needs a closer look
Native destructive filesystem code warrants human platform review, and Linux canonicalization mishandles valid names ending in (deleted).
Review details
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Root-binding races, batch link invalidation, and platform path edge cases remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
src/SkillView.Core/Ui/CleanupScreen.cs:384
- These identities are captured for the whole cleanup batch before
RemoveManyAsyncstarts. Removing the first selected entry changes its parent directory'sst_ctim/ChangeTime, so a second broken symlink in the same parent will fail with "parent identity changed" even without any external race. Validate each link candidate immediately before its removal, or refresh remaining validations after mutations owned by this batch.
CleanupClassifier.CandidateKind.BrokenSymlink =>
RemoveValidator.ValidateBrokenSymlink(candidate.Path, _scanRoots),
CleanupClassifier.CandidateKind.EmptyDirectory =>
RemoveValidator.ValidateEmptyDirectory(candidate.Path, _scanRoots),
src/SkillView.Core/Inventory/WindowsSecureRemovalBackend.cs:763
- Only DOS drive paths can safely lose the
\\?\prefix.GetFinalPathNameByHandleWcan return\\?\Volume{GUID}\...for a volume without a drive letter; stripping its prefix produces a relativeVolume{GUID}\...path, so containment and execution resolve against the process working directory and valid removals fail. Preserve extended paths that are not drive-letter paths.
return path.StartsWith(extendedPrefix, StringComparison.OrdinalIgnoreCase)
? path[extendedPrefix.Length..]
: path;
src/SkillView.Core/Inventory/UnixSecureRemovalBackend.cs:268
- A filesystem-root scan root cannot be canonicalized here. For
/,RealPathreturns/, thenOpenAbsoluteDirectoryreachesOpenAbsoluteParent, whose empty component list throws "refusing to remove a filesystem root"; consequently every removal under a valid--scan-root /is rejected by the canonical containment check. This non-destructive operation can open the resolved directory directly and still derive the final path from its handle.
var resolvedPath = RealPath(path);
using var handle = OpenAbsoluteDirectory(resolvedPath, out var parent, out _);
using (parent)
{
canonicalPath = ReadFinalPath(handle);
}
- Files reviewed: 17/17 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Windows legacy disposition uses an incorrect native field width and lacks a cancellation check immediately before fallback deletion.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 18/18 changed files
- Comments generated: 2
- Review effort level: Balanced
| if (ShouldFallbackToLegacyDisposition(lastError)) | ||
| { | ||
| var legacy = new FileDispositionInfoData { DeleteFile = true }; | ||
| if (WindowsNative.SetFileInformationByHandle( | ||
| handle, | ||
| FileDispositionInfo, | ||
| ref legacy, | ||
| Marshal.SizeOf<FileDispositionInfoData>())) |
| [StructLayout(LayoutKind.Sequential)] | ||
| private struct FileDispositionInfoData | ||
| { | ||
| [MarshalAs(UnmanagedType.Bool)] | ||
| internal bool DeleteFile; | ||
| } |
Summary
Implements the remaining native removal-security batch from the adversarial concurrency, resource, cancellation, and cross-platform audit.
Skill removal no longer relies on repeated path validation followed by path-based recursive deletion on the three supported release platforms. Validation pins the selected filesystem object, and execution traverses opened directory handles/descriptors while verifying native identities.
Why
The portable removal hardening in PRs #12 through #16 prevented static nested-link escapes, bounded traversal state, added cancellation, and moved filesystem work off the UI thread. One deliberate security follow-up remained: another process running as the same user could replace a path component between a managed validation check and a path-based delete.
Supported .NET 10 deletion APIs cannot close that check/use interval. This change introduces audited platform backends instead of adding another path check.
What changed
Validation-time identity pinning
Windows opened-handle removal
CreateFileWusing backup and open-reparse-point semantics.GetFileInformationByHandleEx(FileIdBothDirectoryInfo).SetFileInformationByHandle(FileDispositionInfoEx), with the legacy disposition class used only when the extended class is unavailable.macOS and Linux descriptor-relative removal
fdopendir/readdir.st_dev,st_ino, and file type before descent and again before directory removal.openatandunlinkatrelative to held parent directory descriptors.openat2withRESOLVE_BENEATH,RESOLVE_NO_SYMLINKS, andRESOLVE_NO_XDEV, rejecting both bind mounts and filesystem mount crossings.Cancellation, memory, and resource bounds
Adversarial coverage
New deterministic tests prove that:
The existing suite continues to cover external directory links, external file links, broken links, ancestor cycles, retargeted links, Windows junctions, cancellation during traversal, partial progress, 2,000-file stress trees, and bounded error reporting.
Precise Unix security boundary
This PR intentionally does not claim a guarantee POSIX does not expose.
unlinkatremoves a final name relative to a held parent descriptor; Unix has no general operation that unlinks an already-open inode directly. A process with the same UID can theoretically replace a non-directory leaf between the finalfstatatidentity comparison andunlinkat.That narrow interval cannot redirect recursive traversal through an ancestor or replacement directory. A replacement symlink is unlinked rather than followed, and a replacement directory is not recursively traversed or removed as a file. Windows deletion is bound directly to the opened object handle.
The full audit and durable agent guidance now record this distinction rather than describing Unix deletion as completely atomic.
User-visible release notes
Validation
dotnet build --no-restore— passed; only the known sandbox-local NuGet vulnerability-cache warning was emitted.dotnet test --no-build— 685 passed, 0 failed, including ANSI-driver integration tests.dotnet format SkillView.sln --no-restore --verify-no-changes— passed; workspace loading emitted its existing non-failing warning.git diff --check— passed.Review focus
FILE_ID_BOTH_DIR_INFOparsing and opened-handle disposition behavior on NTFS/ReFS.openat2availability andRESOLVE_NO_XDEVbehavior in the CI environment.stat/direntlayouts and descriptor ownership.