diff --git a/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs
index bd4f6c11..94755761 100644
--- a/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs
+++ b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs
@@ -134,7 +134,15 @@ public sealed class LaunchPathPolicy
// catch.
private static string ResolveFinalPath(string resolved)
{
- var name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
+ string? name;
+ try
+ {
+ name = System.IO.File.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName;
+ }
+ catch
+ {
+ name = null;
+ }
if (!string.IsNullOrWhiteSpace(name))
{
// ResolveLinkTarget resolves the file itself; run it back through
@@ -155,21 +163,44 @@ public sealed class LaunchPathPolicy
// resolving only the leaf would miss the case this exists to catch.
private static string ResolveDirectoryChain(string directory)
{
- var resolvedDirectory = System.IO.Directory
- .ResolveLinkTarget(directory, returnFinalTarget: true)?.FullName;
- if (!string.IsNullOrWhiteSpace(resolvedDirectory))
- return resolvedDirectory;
-
+ // Stop at the volume root. Walking into it and asking whether "C:\" is
+ // a link throws DirectoryNotFoundException, which the caller turned
+ // into a rejection - so the first version of this rejected every
+ // legitimate path as well as the malicious ones. A fail-closed gate
+ // that also fails the normal case is just an outage. Caught in the VM,
+ // not here.
var parent = Path.GetDirectoryName(directory);
if (string.IsNullOrEmpty(parent) || parent == directory)
return directory;
+ var resolvedDirectory = TryResolveLink(directory);
+ if (resolvedDirectory is not null)
+ return resolvedDirectory;
+
var realParent = ResolveDirectoryChain(parent);
return realParent == parent
? directory
: Path.Combine(realParent, Path.GetFileName(directory));
}
+ // A component's link target, or null when it is not a link OR cannot be
+ // asked. Never throws: "cannot tell" must not be reported as "is a link",
+ // and the caller re-checks the assembled path against the roots regardless,
+ // so an unresolvable component cannot widen what is accepted.
+ private static string? TryResolveLink(string path)
+ {
+ try
+ {
+ var target = System.IO.Directory
+ .ResolveLinkTarget(path, returnFinalTarget: true)?.FullName;
+ return string.IsNullOrWhiteSpace(target) ? null : target;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
private bool IsUnderApprovedRoot(string resolved)
{
foreach (var root in approvedRoots)
diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs
new file mode 100644
index 00000000..bee73158
--- /dev/null
+++ b/agent/ShopDbEndpointAgent.PolicyProbe/Program.cs
@@ -0,0 +1,97 @@
+using ShopDbEndpointAgent.Core;
+
+// Planted-violation suite for LaunchPathPolicy, run on real Windows because the
+// cases that matter (junctions, alternate data streams, 8.3 names) are
+// filesystem behaviour, not string behaviour. Linux would prove nothing.
+//
+// Builds its own fixtures under an approved root, so it exercises the same
+// shape as a real shop-floor install rather than a synthetic path.
+
+int failures = 0;
+
+void Check(string label, bool expectAccept, string candidate, LaunchPathPolicy policy)
+{
+ var ok = policy.TryValidate(candidate, out var full, out var reason);
+ var pass = ok == expectAccept;
+ if (!pass) failures++;
+ var verdict = pass ? "PASS" : "FAIL";
+ var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
+ Console.WriteLine($"{verdict} {label}");
+ Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
+}
+
+var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "LppFixture");
+var outside = Path.Combine(Path.GetTempPath(), "LppOutside");
+Directory.CreateDirectory(root);
+Directory.CreateDirectory(outside);
+
+var policy = new LaunchPathPolicy(new[] { root });
+
+// A real executable inside the approved root.
+var good = Path.Combine(root, "good.exe");
+File.WriteAllBytes(good, new byte[] { 0x4D, 0x5A });
+
+// The payload the attacker wants run, planted OUTSIDE the approved root.
+var payload = Path.Combine(outside, "payload.exe");
+File.WriteAllBytes(payload, new byte[] { 0x4D, 0x5A });
+
+// A text file inside the root, to hang an alternate data stream off.
+var carrier = Path.Combine(root, "readme.txt");
+File.WriteAllText(carrier, "notes");
+
+Console.WriteLine($"approved root : {root}");
+Console.WriteLine($"outside dir : {outside}");
+Console.WriteLine();
+
+Check("a real exe under the approved root", true, good, policy);
+Check("wrong extension", false, carrier, policy);
+Check("file that does not exist", false, Path.Combine(root, "nope.exe"), policy);
+Check("traversal back out of the root", false,
+ Path.Combine(root, "..", "..", "Windows", "System32", "cmd.exe"), policy);
+Check("UNC path", false, @"\\attacker\share\payload.exe", policy);
+Check("sibling directory sharing the root's prefix", false, root + "Evil\\payload.exe", policy);
+Check("empty path", false, "", policy);
+
+// --- alternate data stream --------------------------------------------------
+try
+{
+ File.WriteAllBytes(carrier + ":payload.exe", new byte[] { 0x4D, 0x5A });
+ Check("alternate data stream on a file in the root", false,
+ carrier + ":payload.exe", policy);
+}
+catch (Exception ex)
+{
+ Console.WriteLine($"SKIP alternate data stream fixture: {ex.Message}");
+}
+
+// --- directory junction out of the root ------------------------------------
+var junction = Path.Combine(root, "logs");
+var mk = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+{
+ FileName = "cmd.exe",
+ Arguments = $"/c mklink /J \"{junction}\" \"{outside}\"",
+ UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,
+});
+mk!.WaitForExit();
+if (mk.ExitCode == 0)
+{
+ Check("exe reached through a junction pointing out of the root", false,
+ Path.Combine(junction, "payload.exe"), policy);
+}
+else
+{
+ Console.WriteLine($"SKIP junction fixture: mklink exit {mk.ExitCode} {mk.StandardError.ReadToEnd().Trim()}");
+}
+
+// --- 8.3 short name ---------------------------------------------------------
+// 8.3 short names used to be rejected, because the lexical prefix check could
+// not match "C:\PROGRA~1" against "C:\Program Files" - fail closed, but only by
+// accident of not understanding the path. Resolution now expands it to the real
+// directory, which IS under the approved root, so accepting is correct: the
+// check that matters runs on the resolved path either way.
+var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
+Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
+
+Console.WriteLine();
+Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
+return failures == 0 ? 0 : 1;
diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/README.md b/agent/ShopDbEndpointAgent.PolicyProbe/README.md
new file mode 100644
index 00000000..390a67bb
--- /dev/null
+++ b/agent/ShopDbEndpointAgent.PolicyProbe/README.md
@@ -0,0 +1,33 @@
+# LaunchPathPolicy probe
+
+A planted-violation probe for `LaunchPathPolicy`. It builds real fixtures under
+a real approved root - a junction pointing out of it, an alternate data stream,
+an 8.3 short name - and asserts what the policy does with each.
+
+## Why it is not a unit test in CI
+
+The cases worth testing are filesystem behaviour, not string behaviour:
+junctions, alternate data streams and 8.3 short names do not exist on Linux, and
+CI runs on Linux runners. A suite that skipped every meaningful case there would
+report green and mean nothing, which is the failure shape this repo has already
+been bitten by more than once. So it is a probe you run on Windows, by hand,
+when the policy changes.
+
+## Running it
+
+Publish from the dev box and run it in the win11 VM:
+
+ dotnet publish agent/ShopDbEndpointAgent.PolicyProbe -c Release -r win-x64 \
+ --self-contained true -p:PublishSingleFile=true -o /tmp/lpp
+
+Copy `lpp/lpptest.exe` into the VM and run it as an account that may create a
+junction under Program Files. Exit code 0 means every expectation held; it
+prints one line per case either way.
+
+## What it caught
+
+The first version of the link-resolving hardening recursed to the volume root
+and asked whether `C:\` was a link. That throws, and the caller treated a
+resolve failure as a rejection - so the policy rejected every legitimate launch
+path as well as the malicious ones. It compiled cleanly and read correctly. Only
+running it on Windows showed the agent would have failed closed on everything.
diff --git a/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj
new file mode 100644
index 00000000..9112673f
--- /dev/null
+++ b/agent/ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ lpptest
+ LppTest
+ true
+
+
+
+
+
diff --git a/agent/ShopDbEndpointAgent.slnx b/agent/ShopDbEndpointAgent.slnx
index e58da209..1855a388 100644
--- a/agent/ShopDbEndpointAgent.slnx
+++ b/agent/ShopDbEndpointAgent.slnx
@@ -3,4 +3,6 @@
+
+