summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 07:37:51 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 07:37:51 +0800
commit56800fbbd4488db20abd4f44f0d39e48599be0ab (patch)
treef32c815543000daf4c2ad33e534bb806e0555c3f
parente8eebe49407de8947bf509d5cf04d9080ee4edf7 (diff)
downloadsomhairles-dream-fsharp-56800fbbd4488db20abd4f44f0d39e48599be0ab.tar.gz
Secure artifact serving: manifest/checkpoint membership, hash validation, link rejection
-rw-r--r--README.md10
-rw-r--r--src/SomhairlesDream.Server/ArtifactRunApi.fs14
-rw-r--r--src/SomhairlesDream.Server/ArtifactRunCoordinator.fs217
-rw-r--r--tests/SomhairlesDream.Server.Tests/ApiTests.fs236
4 files changed, 442 insertions, 35 deletions
diff --git a/README.md b/README.md
index 977c984..0f126db 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,15 @@ Routes used by the frontend: `POST /api/runs/start`,
`GET /api/artifacts/manifest`, `GET /api/artifacts/file`, and
`GET /api/artifacts/steps?projectId=...&runId=...&path=steps/<step>.glb`
(serve a step GLB while a run is still active; `.glb` files under `steps/`
-only, traversal-guarded). The server binds to the ASP.NET default (add
+only, traversal-guarded). Artifact serving is integrity-checked: `file`
+serves only paths published in the verified run manifest (logs, the
+manifest itself, and stray files are never exposed), `steps` serves only
+checkpointed GLBs (manifest members for completed runs), and every served
+stream is hashed against its recorded SHA-256 and byte count at request
+time. Filesystem links inside a run directory (file or intermediate
+directory symlinks/junctions) are rejected with `400`; hash or byte-count
+deviations return `409`, and manifest failures surface as `500`. The
+server binds to the ASP.NET default (add
`--urls http://127.0.0.1:8099` to match the legacy port).
## CLI
diff --git a/src/SomhairlesDream.Server/ArtifactRunApi.fs b/src/SomhairlesDream.Server/ArtifactRunApi.fs
index 8f63b99..48ecc08 100644
--- a/src/SomhairlesDream.Server/ArtifactRunApi.fs
+++ b/src/SomhairlesDream.Server/ArtifactRunApi.fs
@@ -93,6 +93,7 @@ module ArtifactRunApi =
| Error(InvalidManifest message) -> error options 500 message
| Error(InvalidArtifactPath message) -> error options 500 message
| Error ArtifactNotFound -> error options 500 "manifest not found"
+ | Error(ArtifactMismatch message) -> error options 500 message
let private artifactContentType (path: string) =
match Path.GetExtension(path).ToLowerInvariant() with
@@ -102,18 +103,26 @@ module ArtifactRunApi =
| ".log" -> "text/plain"
| _ -> "application/octet-stream"
+ let private streamArtifact (context: HttpContext) (file: ArtifactFile) =
+ context.Response.OnCompleted(Func<Task>(fun () ->
+ file.Stream.Dispose()
+ Task.CompletedTask))
+
+ Results.Stream(file.Stream, artifactContentType file.RelativePath)
+
let private artifact options (context: HttpContext) =
match selector context, queryValue context "path" with
| Error message, _ -> error options 400 message
| _, None -> error options 400 "missing query parameter 'path'"
| Ok(projectId, runId), Some relativePath ->
match options.Coordinator.Artifact(projectId, runId, relativePath) with
- | Ok path -> Results.File(path, artifactContentType path)
+ | Ok file -> streamArtifact context file
| Error RunNotFound -> error options 404 "run not found"
| Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot
| Error(InvalidArtifactPath message) -> error options 400 message
| Error ArtifactNotFound -> error options 404 "artifact not found"
| Error(InvalidManifest message) -> error options 500 message
+ | Error(ArtifactMismatch message) -> error options 409 message
let private stepArtifact options (context: HttpContext) =
match selector context, queryValue context "path" with
@@ -121,12 +130,13 @@ module ArtifactRunApi =
| _, None -> error options 400 "missing query parameter 'path'"
| Ok(projectId, runId), Some relativePath ->
match options.Coordinator.StepArtifact(projectId, runId, relativePath) with
- | Ok path -> Results.File(path, artifactContentType path)
+ | Ok file -> streamArtifact context file
| Error RunNotFound -> error options 404 "run not found"
| Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot
| Error(InvalidArtifactPath message) -> error options 400 message
| Error ArtifactNotFound -> error options 404 "artifact not found"
| Error(InvalidManifest message) -> error options 500 message
+ | Error(ArtifactMismatch message) -> error options 409 message
let private writeError options (context: HttpContext) statusCode message =
task {
diff --git a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs
index dbf87df..ae222d5 100644
--- a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs
+++ b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs
@@ -1,7 +1,10 @@
namespace SomhairlesDream.Server
open System
+open System.Collections.Concurrent
+open System.Collections.Generic
open System.IO
+open System.Security.Cryptography
open System.Threading.Tasks
open SomhairlesDream.Modeling
open SomhairlesDream.Shared
@@ -16,6 +19,11 @@ type ArtifactLookupError =
| InvalidArtifactPath of string
| ArtifactNotFound
| InvalidManifest of string
+ | ArtifactMismatch of string
+
+type ArtifactFile =
+ { Stream: Stream
+ RelativePath: string }
type ArtifactRunCoordinator(
artifactRoot: string,
@@ -49,6 +57,149 @@ type ArtifactRunCoordinator(
)
|> ignore
+ let safeArtifactPath (runDirectory: string) (relativePath: string) =
+ if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then
+ Error "artifact path must be relative"
+ else
+ let normalized = relativePath.Replace('\\', '/')
+ let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries)
+
+ if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then
+ Error "artifact path contains traversal"
+ else
+ let fullPath = Path.GetFullPath(Path.Combine(runDirectory, normalized.Replace('/', Path.DirectorySeparatorChar)))
+ let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar
+
+ if fullPath.StartsWith(prefix, StringComparison.Ordinal) then
+ Ok(fullPath, String.Join("/", segments))
+ else
+ Error "artifact path escapes run directory"
+
+ let checkpointDigests =
+ ConcurrentDictionary<string * string, ConcurrentDictionary<string, string * int64>>()
+
+ let recordCheckpointDigest (ids: RunIds) (relativePath: string) =
+ try
+ match safeArtifactPath (runDirectory ids.ProjectId ids.RunId) relativePath with
+ | Ok(fullPath, normalized) when File.Exists(fullPath) ->
+ use stream = File.OpenRead(fullPath)
+ use sha = SHA256.Create()
+
+ let digest =
+ (Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant(), stream.Length)
+
+ let digests =
+ checkpointDigests.GetOrAdd(
+ (ids.ProjectId, ids.RunId),
+ fun _ -> ConcurrentDictionary<string, string * int64>()
+ )
+
+ digests[normalized] <- digest
+ | _ -> ()
+ with _ ->
+ ()
+
+ let rejectLinks (runDirectory: string) (fullPath: string) =
+ let root = Path.GetFullPath(runDirectory)
+ let relative = fullPath.Substring(root.Length + 1)
+ let mutable current = root
+ let mutable outcome = Ok ()
+
+ for segment in
+ relative.Split(
+ [| Path.DirectorySeparatorChar; Path.AltDirectorySeparatorChar |],
+ StringSplitOptions.RemoveEmptyEntries
+ ) do
+ match outcome with
+ | Error _ -> ()
+ | Ok () ->
+ current <- Path.Combine(current, segment)
+
+ let info: FileSystemInfo =
+ if Directory.Exists(current) && not (File.Exists(current)) then
+ DirectoryInfo(current) :> FileSystemInfo
+ else
+ FileInfo(current) :> FileSystemInfo
+
+ if not (isNull info.LinkTarget) then
+ outcome <- Error "artifact path crosses a filesystem link"
+
+ outcome
+
+ let openValidated
+ (runDirectory: string)
+ (fullPath: string)
+ (normalized: string)
+ (expectedSha256: string)
+ (expectedBytes: int64)
+ : Result<ArtifactFile, ArtifactLookupError> =
+ match rejectLinks runDirectory fullPath with
+ | Error message -> Error(InvalidArtifactPath message)
+ | Ok () ->
+ try
+ if not (File.Exists(fullPath)) then
+ Error ArtifactNotFound
+ else
+ let stream =
+ File.Open(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read)
+
+ try
+ if stream.Length <> expectedBytes then
+ Error(ArtifactMismatch $"artifact byte count mismatch: {normalized}")
+ else
+ use sha = SHA256.Create()
+
+ let sha256 =
+ Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant()
+
+ if sha256 <> expectedSha256 then
+ Error(ArtifactMismatch $"artifact hash mismatch: {normalized}")
+ else
+ stream.Seek(0L, SeekOrigin.Begin) |> ignore
+ Ok { Stream = stream :> Stream; RelativePath = normalized }
+ with _ ->
+ stream.Dispose()
+ reraise ()
+ with
+ | :? FileNotFoundException
+ | :? DirectoryNotFoundException -> Error ArtifactNotFound
+ | :? IOException as ex -> Error(InvalidArtifactPath $"artifact unavailable: {ex.Message}")
+
+ let verifiedManifest (projectId: string) (runId: string) =
+ let path = Path.Combine(runDirectory projectId runId, "manifest.json")
+
+ match ArtifactVerifier.verify path with
+ | Ok manifest -> Ok manifest
+ | Error message -> Error(InvalidManifest message)
+
+ let memberDigests (manifest: ArtifactManifest) =
+ let map = Dictionary<string, string * int64>()
+
+ let add (relativePath: string) (sha256: string) (bytes: int64) =
+ map[relativePath] <- (sha256, bytes)
+
+ for step in manifest.Steps do
+ add step.ArtifactPath step.Sha256 step.Bytes
+ step.Render |> Option.iter (fun render -> add render.ArtifactPath render.Sha256 render.Bytes)
+
+ map
+
+ let completedDigest (projectId: string) (runId: string) (normalized: string) =
+ match verifiedManifest projectId runId with
+ | Error lookupError -> Error lookupError
+ | Ok manifest ->
+ match (memberDigests manifest).TryGetValue(normalized) with
+ | true, digest -> Ok digest
+ | false, _ -> Error ArtifactNotFound
+
+ let checkpointDigest (projectId: string) (runId: string) (normalized: string) =
+ match checkpointDigests.TryGetValue((projectId, runId)) with
+ | true, digests ->
+ match digests.TryGetValue(normalized) with
+ | true, digest -> Ok digest
+ | false, _ -> Error ArtifactNotFound
+ | false, _ -> Error ArtifactNotFound
+
let runPipeline (store: ArtifactRunStore) (ids: RunIds) render =
try
let options : PipelineOptions =
@@ -59,6 +210,10 @@ type ArtifactRunCoordinator(
Clock = clock
OnEvent =
fun event ->
+ (match event with
+ | Checkpoint value -> recordCheckpointDigest value.Ids value.ArtifactPath
+ | _ -> ())
+
match store.Apply event with
| Ok _ -> ()
| Error message -> invalidOp message }
@@ -69,24 +224,6 @@ type ArtifactRunCoordinator(
with ex ->
failIfNeeded store ids ex.Message
- let safeArtifactPath (runDirectory: string) (relativePath: string) =
- if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then
- Error "artifact path must be relative"
- else
- let normalized = relativePath.Replace('\\', '/')
- let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries)
-
- if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then
- Error "artifact path contains traversal"
- else
- let fullPath = Path.GetFullPath(Path.Combine(runDirectory, normalized.Replace('/', Path.DirectorySeparatorChar)))
- let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar
-
- if fullPath.StartsWith(prefix, StringComparison.Ordinal) then
- Ok fullPath
- else
- Error "artifact path escapes run directory"
-
member _.ArtifactRoot = root
member _.Start(ids: RunIds, render: bool) : Result<RunStartOutcome, string> =
@@ -118,13 +255,9 @@ type ArtifactRunCoordinator(
if snapshot.Status <> "complete" then
Error(RunNotComplete snapshot)
else
- let path = Path.Combine(runDirectory projectId runId, "manifest.json")
+ verifiedManifest projectId runId
- match ArtifactVerifier.verify path with
- | Ok manifest -> Ok manifest
- | Error message -> Error(InvalidManifest message)
-
- member _.Artifact(projectId: string, runId: string, relativePath: string) : Result<string, ArtifactLookupError> =
+ member _.Artifact(projectId: string, runId: string, relativePath: string) : Result<ArtifactFile, ArtifactLookupError> =
match registry.TryFind(projectId, runId) with
| None -> Error RunNotFound
| Some store ->
@@ -137,20 +270,40 @@ type ArtifactRunCoordinator(
match safeArtifactPath directory relativePath with
| Error message -> Error(InvalidArtifactPath message)
- | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound
- | Ok path -> Ok path
+ | Ok(fullPath, normalized) ->
+ match rejectLinks directory fullPath with
+ | Error message -> Error(InvalidArtifactPath message)
+ | Ok () ->
+ match completedDigest projectId runId normalized with
+ | Error lookupError -> Error lookupError
+ | Ok(sha256, bytes) -> openValidated directory fullPath normalized sha256 bytes
- member _.StepArtifact(projectId: string, runId: string, relativePath: string) : Result<string, ArtifactLookupError> =
+ member _.StepArtifact(projectId: string, runId: string, relativePath: string) : Result<ArtifactFile, ArtifactLookupError> =
match registry.TryFind(projectId, runId) with
| None -> Error RunNotFound
- | Some _ ->
+ | Some store ->
let directory = runDirectory projectId runId
match safeArtifactPath directory relativePath with
| Error message -> Error(InvalidArtifactPath message)
- | Ok path when not (path.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)) ->
+ | Ok(_, normalized) when not (normalized.EndsWith(".glb", StringComparison.OrdinalIgnoreCase)) ->
Error(InvalidArtifactPath "step artifacts must be .glb files")
- | Ok path when not (path.StartsWith(Path.Combine(directory, "steps") + string Path.DirectorySeparatorChar, StringComparison.Ordinal)) ->
+ | Ok(_, normalized) when not (normalized.StartsWith("steps/", StringComparison.Ordinal)) ->
Error(InvalidArtifactPath "step artifacts must live under steps/")
- | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound
- | Ok path -> Ok path
+ | Ok(fullPath, normalized) ->
+ let snapshot = store.Observe(clock ())
+
+ let digest =
+ match rejectLinks directory fullPath with
+ | Error message -> Error(InvalidArtifactPath message)
+ | Ok () ->
+ if snapshot.Status = "complete" then
+ completedDigest projectId runId normalized
+ elif snapshot.CompletedSteps |> Array.exists (fun recorded -> recorded = normalized) then
+ checkpointDigest projectId runId normalized
+ else
+ Error ArtifactNotFound
+
+ match digest with
+ | Error lookupError -> Error lookupError
+ | Ok(sha256, bytes) -> openValidated directory fullPath normalized sha256 bytes
diff --git a/tests/SomhairlesDream.Server.Tests/ApiTests.fs b/tests/SomhairlesDream.Server.Tests/ApiTests.fs
index f51ca3d..be6a6cb 100644
--- a/tests/SomhairlesDream.Server.Tests/ApiTests.fs
+++ b/tests/SomhairlesDream.Server.Tests/ApiTests.fs
@@ -266,6 +266,242 @@ let ``step artifacts stream while a run is still active`` () =
gate.Set()
gate.Dispose()
+let private glbBytes (index: byte) = [| 0x67uy; 0x6Cuy; 0x54uy; index |]
+let private runDirectory (coordinator: ArtifactRunCoordinator) (runId: string) =
+ Path.Combine(coordinator.ArtifactRoot, ids.ProjectId, runId)
+
+let private getArtifact (client: HttpClient) endpoint runId relativePath =
+ let encoded = Uri.EscapeDataString(relativePath)
+
+ client
+ .GetAsync($"/api/artifacts/{endpoint}?projectId={ids.ProjectId}&runId={runId}&path={encoded}")
+ .GetAwaiter()
+ .GetResult()
+
+let private waitForTerminal (coordinator: ArtifactRunCoordinator) runId =
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store ->
+ let status = store.Snapshot().Status
+ status = "complete" || status = "failed"))
+ )
+
+[<Fact>]
+let ``file endpoint serves only published manifest members`` () =
+ withApp (fun client coordinator ->
+ let runId = "run-api-scope"
+
+ use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json")
+ let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult()
+ Assert.Equal(HttpStatusCode.Accepted, start.StatusCode)
+
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store -> store.Snapshot().Status = "complete"))
+ )
+
+ let glbResponse = getArtifact client "file" runId "steps/01-foundation.glb"
+
+ Assert.Equal(HttpStatusCode.OK, glbResponse.StatusCode)
+ Assert.Equal("model/gltf-binary", glbResponse.Content.Headers.ContentType.MediaType)
+
+ let bytes = glbResponse.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
+ Assert.True((glbBytes 0uy = bytes), "file endpoint should serve the published glb bytes")
+
+ let stepResponse = getArtifact client "steps" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.OK, stepResponse.StatusCode)
+
+ let logResponse = getArtifact client "file" runId "logs/01-foundation.log"
+ Assert.Equal(HttpStatusCode.NotFound, logResponse.StatusCode)
+
+ let manifestResponse = getArtifact client "file" runId "manifest.json"
+ Assert.Equal(HttpStatusCode.NotFound, manifestResponse.StatusCode)
+
+ File.WriteAllText(Path.Combine(runDirectory coordinator runId, "extra-secret.txt"), "hidden")
+ let extraResponse = getArtifact client "file" runId "extra-secret.txt"
+ Assert.Equal(HttpStatusCode.NotFound, extraResponse.StatusCode))
+
+[<Fact>]
+let ``file endpoint rejects tampered published artifacts`` () =
+ withApp (fun client coordinator ->
+ let runId = "run-api-tampered"
+
+ use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json")
+ let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult()
+ Assert.Equal(HttpStatusCode.Accepted, start.StatusCode)
+
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store -> store.Snapshot().Status = "complete"))
+ )
+
+ let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb")
+ File.WriteAllBytes(glbPath, glbBytes 0x09uy)
+
+ let response = getArtifact client "file" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode))
+
+[<Fact>]
+let ``file endpoint rejects file symlink escapes`` () =
+ withApp (fun client coordinator ->
+ let runId = "run-api-filelink"
+
+ use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json")
+ let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult()
+ Assert.Equal(HttpStatusCode.Accepted, start.StatusCode)
+
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store -> store.Snapshot().Status = "complete"))
+ )
+
+ let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb")
+ let original = File.ReadAllBytes(glbPath)
+ let escapeTarget = Path.Combine(coordinator.ArtifactRoot, "escape-target.glb")
+ File.WriteAllBytes(escapeTarget, original)
+
+ File.Delete(glbPath)
+ File.CreateSymbolicLink(glbPath, escapeTarget) |> ignore
+
+ let response = getArtifact client "file" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode))
+
+[<Fact>]
+let ``file endpoint rejects intermediate directory symlink escapes`` () =
+ withApp (fun client coordinator ->
+ let runId = "run-api-dirlink"
+
+ use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json")
+ let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult()
+ Assert.Equal(HttpStatusCode.Accepted, start.StatusCode)
+
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store -> store.Snapshot().Status = "complete"))
+ )
+
+ let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb")
+ let original = File.ReadAllBytes(glbPath)
+ let escapeDirectory = Path.Combine(coordinator.ArtifactRoot, "escape-steps")
+ Directory.CreateDirectory(escapeDirectory) |> ignore
+ File.WriteAllBytes(Path.Combine(escapeDirectory, "01-foundation.glb"), original)
+
+ let stepsPath = Path.Combine(runDirectory coordinator runId, "steps")
+ Directory.Delete(stepsPath, true)
+ Directory.CreateSymbolicLink(stepsPath, escapeDirectory) |> ignore
+
+ let response = getArtifact client "file" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode))
+
+[<Fact>]
+let ``step endpoint rejects uncheckpointed glb files while running`` () =
+ let gate = new ManualResetEventSlim(false)
+
+ try
+ withAppUsing
+ (fun () -> StepGatedBridge(gate) :> IArtifactBridge)
+ (fun client coordinator ->
+ let runId = "run-api-rogue"
+ use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json")
+ let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult()
+ Assert.Equal(HttpStatusCode.Accepted, start.StatusCode)
+
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store ->
+ let snapshot = store.Snapshot()
+ snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1))
+ )
+
+ let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb")
+ File.Copy(glbPath, Path.Combine(runDirectory coordinator runId, "steps", "09-rogue.glb"))
+
+ let response = getArtifact client "steps" runId "steps/09-rogue.glb"
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode)
+
+ gate.Set()
+ waitForTerminal coordinator runId)
+ finally
+ gate.Set()
+ gate.Dispose()
+
+[<Fact>]
+let ``step endpoint rejects tampered live checkpoint artifacts`` () =
+ let gate = new ManualResetEventSlim(false)
+
+ try
+ withAppUsing
+ (fun () -> StepGatedBridge(gate) :> IArtifactBridge)
+ (fun client coordinator ->
+ let runId = "run-api-livetamper"
+ use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json")
+ let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult()
+ Assert.Equal(HttpStatusCode.Accepted, start.StatusCode)
+
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store ->
+ let snapshot = store.Snapshot()
+ snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1))
+ )
+
+ let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb")
+ File.WriteAllBytes(glbPath, glbBytes 0x09uy)
+
+ let response = getArtifact client "steps" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.Conflict, response.StatusCode)
+
+ gate.Set()
+ waitForTerminal coordinator runId)
+ finally
+ gate.Set()
+ gate.Dispose()
+
+[<Fact>]
+let ``step endpoint rejects symlink escapes while running`` () =
+ let gate = new ManualResetEventSlim(false)
+
+ try
+ withAppUsing
+ (fun () -> StepGatedBridge(gate) :> IArtifactBridge)
+ (fun client coordinator ->
+ let runId = "run-api-steplink"
+ use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json")
+ let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult()
+ Assert.Equal(HttpStatusCode.Accepted, start.StatusCode)
+
+ Assert.True(
+ waitFor (fun () ->
+ coordinator.TryFind(ids.ProjectId, runId)
+ |> Option.exists (fun store ->
+ let snapshot = store.Snapshot()
+ snapshot.Status = "running" && snapshot.CompletedSteps.Length = 1))
+ )
+
+ let glbPath = Path.Combine(runDirectory coordinator runId, "steps", "01-foundation.glb")
+ let original = File.ReadAllBytes(glbPath)
+ let escapeTarget = Path.Combine(coordinator.ArtifactRoot, "escape-step-target.glb")
+ File.WriteAllBytes(escapeTarget, original)
+
+ File.Delete(glbPath)
+ File.CreateSymbolicLink(glbPath, escapeTarget) |> ignore
+
+ let response = getArtifact client "steps" runId "steps/01-foundation.glb"
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode)
+
+ gate.Set()
+ waitForTerminal coordinator runId)
+ finally
+ gate.Set()
+ gate.Dispose()
+
[<Fact>]
let ``events endpoint streams only the selected run`` () =
withApp (fun client _ ->