diff --git a/Rasp.Core.Tests/Guard/PathTraversalGuardTests.cs b/Rasp.Core.Tests/Guard/PathTraversalGuardTests.cs
new file mode 100644
index 0000000..e6c90c5
--- /dev/null
+++ b/Rasp.Core.Tests/Guard/PathTraversalGuardTests.cs
@@ -0,0 +1,166 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Rasp.Core.Abstractions;
+using Rasp.Core.Configuration;
+using Rasp.Core.Engine;
+using Rasp.Core.Exceptions;
+using Rasp.Core.Guard;
+using Rasp.Core.Infrastructure;
+using Xunit;
+
+namespace Rasp.Core.Tests.Guard;
+
+///
+/// Unit tests for in isolation (no runtime patching).
+/// Construction pattern matches .
+///
+public class PathTraversalGuardTests
+{
+ private sealed class RecordingRaspMetrics : IRaspMetrics
+ {
+ public int InspectionCount { get; private set; }
+ public int ThreatCount { get; private set; }
+ public bool? LastBlocked { get; private set; }
+
+ public void RecordInspection(string layer, double durationMs)
+ {
+ InspectionCount++;
+ }
+
+ public void ReportThreat(string layer, string threatType, bool blocked)
+ {
+ ThreatCount++;
+ LastBlocked = blocked;
+ }
+ }
+
+ private static string AllowedRoot() =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\App\Data" : "/app/data";
+
+ private static string OutsidePath() =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\Windows\System32\sam" : "/etc/passwd";
+
+ private static PathTraversalGuard CreateGuard(
+ RaspAlertBus bus,
+ RecordingRaspMetrics metrics,
+ bool blockOnRuntimePatchingDetection,
+ IReadOnlyList? allowedRoots = null)
+ {
+ var options = Options.Create(new RaspOptions
+ {
+ BlockOnRuntimePatchingDetection = blockOnRuntimePatchingDetection,
+ AllowedFileRoots = new List(allowedRoots ?? new[] { AllowedRoot() })
+ });
+
+ return new PathTraversalGuard(
+ new PathTraversalDetectionEngine(options),
+ bus,
+ metrics,
+ options,
+ NullLogger.Instance);
+ }
+
+ private static bool TryReadAlert(RaspAlertBus bus, out RaspAlertEvent alert, int timeoutMs = 500)
+ {
+ using var cts = new CancellationTokenSource(timeoutMs);
+ var enumerator = bus.ReadAlertsAsync(cts.Token).GetAsyncEnumerator(cts.Token);
+ try
+ {
+ if (enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult())
+ {
+ alert = enumerator.Current;
+ return true;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // No alert within timeout.
+ }
+ finally
+ {
+ enumerator.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+
+ alert = default;
+ return false;
+ }
+
+ [Fact]
+ public void AnalyzePath_ThreatDetected_AuditMode_PushesAlertWithoutThrowing()
+ {
+ var bus = new RaspAlertBus();
+ var metrics = new RecordingRaspMetrics();
+ var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: false);
+
+ var act = () => guard.AnalyzePath(OutsidePath(), "File Access");
+
+ act.Should().NotThrow();
+ TryReadAlert(bus, out var alert).Should().BeTrue();
+ alert.ThreatType.Should().Be("Path Traversal");
+ alert.Context.Should().Contain("File Access");
+ metrics.InspectionCount.Should().Be(1);
+ metrics.ThreatCount.Should().Be(1);
+ metrics.LastBlocked.Should().BeFalse();
+ }
+
+ [Fact]
+ public void AnalyzePath_ThreatDetected_BlockMode_ThrowsAfterPushingAlert()
+ {
+ var bus = new RaspAlertBus();
+ var metrics = new RecordingRaspMetrics();
+ var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: true);
+
+ var act = () => guard.AnalyzePath(OutsidePath(), "File Access");
+
+ act.Should().Throw()
+ .Which.ThreatType.Should().Be("Path Traversal");
+
+ // Alert must still be published before the throw.
+ TryReadAlert(bus, out var alert).Should().BeTrue();
+ alert.ThreatType.Should().Be("Path Traversal");
+ metrics.InspectionCount.Should().Be(1);
+ metrics.ThreatCount.Should().Be(1);
+ metrics.LastBlocked.Should().BeTrue();
+ }
+
+ [Fact]
+ public void AnalyzePath_NoThreat_DoesNotAlertOrThrow()
+ {
+ var bus = new RaspAlertBus();
+ var metrics = new RecordingRaspMetrics();
+ var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: true);
+
+ var safePath = Path.Combine(AllowedRoot(), "file.txt");
+ var act = () => guard.AnalyzePath(safePath, "File Access");
+
+ act.Should().NotThrow();
+ TryReadAlert(bus, out _, timeoutMs: 200).Should().BeFalse();
+ metrics.InspectionCount.Should().Be(1);
+ metrics.ThreatCount.Should().Be(0);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void AnalyzePath_NullOrWhitespace_IsNoOp(string? path)
+ {
+ var bus = new RaspAlertBus();
+ var metrics = new RecordingRaspMetrics();
+ var guard = CreateGuard(bus, metrics, blockOnRuntimePatchingDetection: true);
+
+ var act = () => guard.AnalyzePath(path!, "File Access");
+
+ act.Should().NotThrow();
+ TryReadAlert(bus, out _, timeoutMs: 200).Should().BeFalse();
+ // Guard returns before engine/metrics when path is empty.
+ metrics.InspectionCount.Should().Be(0);
+ metrics.ThreatCount.Should().Be(0);
+ }
+}
\ No newline at end of file