// SPDX-License-Identifier: MIT
// Copyright (c) 2026 AgentEval Contributors
using AgentEval.MAF.Gatekeeper;
using AgentEval.MissionControl.GraphQL;
using AgentEval.MissionControl.Services;
using Microsoft.Agents.AI;
namespace AgentEval.Samples;
/// Offline end-to-end graph observation, containment, and enforced-refusal story.
public static class GatekeeperSecurityGraphIncident
{
private const string Tenant = "sample-tenant";
private static readonly DateTimeOffset Now =
new(2026, 8, 4, 10, 0, 0, TimeSpan.Zero);
public static async Task RunAsync()
{
GatekeeperSampleContractRenderer.Print("22");
Console.WriteLine("\n=== Gatekeeper — Security Graph Incident Response (offline) ===\n");
var directory = Path.Combine(
Path.GetTempPath(),
"agenteval-gatekeeper-graph-" + Guid.NewGuid().ToString("N"));
var graphPath = Path.Combine(directory, "graph.json");
var containmentPath = Path.Combine(directory, "containment.json");
var clock = new SampleClock(Now);
var agent = new SecurityGraphNode(SecurityGraphNodeKind.Agent, "support-agent");
var endpoint = new SecurityGraphNode(SecurityGraphNodeKind.AgentEndpoint, "partner-endpoint");
try
{
using var graphStore = new JsonFileSecurityGraphStore(
graphPath,
Tenant,
"sample-key",
Enumerable.Range(1, 32).Select(value => (byte)value).ToArray(),
new JsonFileSecurityGraphStoreOptions
{
BootstrapIfMissing = true,
Retention = TimeSpan.FromDays(1),
MaxObservations = 32,
MaxCoverageGaps = 8,
},
clock);
Console.WriteLine("── An incident, end to end: local finding → durable graph → containment → refusal ──");
await using (var pump = new SecurityGraphIngestionPump(
graphStore,
queueCapacity: 8,
drainTimeout: TimeSpan.FromSeconds(5)))
{
Require(pump.TryEnqueue(Observation("event-1", agent, endpoint, SecurityGraphSignalKind.CallObserved, "session-a")),
"first graph observation must enter the bounded queue");
Require(pump.TryEnqueue(Observation("event-2", agent, endpoint, SecurityGraphSignalKind.CallBlocked, "session-b")),
"second graph observation must enter the bounded queue");
Require(await pump.CompleteAndDrainAsync(), "the bounded graph queue must drain");
Require(pump.AppliedCount == 2 && pump.DroppedCount == 0,
"exactly two content-free observations must be durably applied without drops");
Console.WriteLine($" ▶ ingest two content-free observations (a call, a blocked call) durably applied; dropped: {pump.DroppedCount}");
}
var report = AgenticSecurityGraph.Compute(
graphStore.Read(TimeSpan.FromHours(1)),
[agent, endpoint],
staleAfter: TimeSpan.FromMinutes(30),
clock);
Require(report.Coverage == SecurityGraphCoverageState.Complete,
"the initial graph window must be complete");
Require(report.TotalCallCount == 2 && report.TotalBlockedCallCount == 1,
"the graph must report measured call and block totals");
Require(report.FleetBlockRate is 0.5,
"the complete graph must compute the measured 50% block rate");
Console.WriteLine($" ▶ compute coverage={report.Coverage}; {report.TotalCallCount} calls, {report.TotalBlockedCallCount} blocked → measured fleet block rate {report.FleetBlockRate!.Value:P0}");
var operationsSource = new SecurityGraphStoreReportSource(
Tenant,
graphStore,
[agent, endpoint],
TimeSpan.FromHours(1),
TimeSpan.FromMinutes(30),
clock);
var operationsView = new Query().SecurityGraph(
operationsSource,
nodeLimit: 2,
edgeLimit: 1);
Require(
operationsView is
{
Coverage: SecurityGraphCoverageState.Complete,
TotalCallCount: 2,
TotalBlockedCallCount: 1,
Nodes.Count: 2,
Edges.Count: 1,
NodesTruncated: false,
EdgesTruncated: false,
},
"the real read-only operations projection must expose bounded content-free totals");
Console.WriteLine($" ▶ project Mission Control sees {operationsView!.Nodes.Count} nodes / {operationsView.Edges.Count} edge — read-only, bounded, content-free");
var decision = SecurityGraphContainmentDecision.ForNode(
report,
endpoint,
"campaign_detected",
"sample-incident-22");
using var containmentStore = new JsonFileContainmentStore(
containmentPath,
new SampleReleaseVerifier(),
new JsonFileContainmentStoreOptions { BootstrapIfMissing = true },
clock);
var applied = await new SecurityGraphContainmentBridge(Tenant, containmentStore)
.ApplyAsync(decision);
Require(applied.Snapshot.State == ContainmentSnapshotState.Active,
"the evidence-backed graph decision must activate containment");
Console.WriteLine(" ▶ contain the evidence-backed decision activates durable containment of partner-endpoint");
var sessionTarget = new ContainmentTarget.Session(Tenant, "session-c");
var overrideGate = new ContainmentOverrideGate(
containmentStore,
_ => [sessionTarget],
_ => [decision.Target]);
ToolGateVerdict refusal;
using (AgentRunScope.Begin(new SampleSession(), "security-graph-sample", trace: null))
{
refusal = await overrideGate.InspectAsync(Call("delegate_to_partner"));
}
Require(refusal.Action == ToolGateAction.Block,
"future calls to the contained endpoint must be refused inline");
Console.WriteLine(" ▶ enforce the next delegate_to_partner call → ⛔ BLOCK, inline, from the durable decision");
Console.WriteLine(" ▶ gap test mark a known ingestion gap, then try to mint another containment decision…");
await graphStore.MarkCoverageGapAsync(new SecurityGraphCoverageGap("sample_queue_gap"));
var incomplete = AgenticSecurityGraph.Compute(
graphStore.Read(TimeSpan.FromHours(1)),
[agent, endpoint],
staleAfter: TimeSpan.FromMinutes(30),
clock);
Require(incomplete.Coverage == SecurityGraphCoverageState.Incomplete && incomplete.FleetBlockRate is null,
"a known ingestion gap must remove the aggregate fleet-rate claim");
RequireThrows(
() => SecurityGraphContainmentDecision.ForNode(
incomplete,
endpoint,
"campaign_detected",
"sample-incomplete"),
"an incomplete graph must not mint a new containment decision");
Console.WriteLine(" └ coverage=Incomplete → fleet rate withdrawn → new decision REFUSED (incomplete evidence cannot mint containment)");
Console.WriteLine();
Console.WriteLine($" {"Stage",-14} {"Measured evidence",-50} Security disposition");
Console.WriteLine($" {new string('─', 14)} {new string('─', 50)} {new string('─', 20)}");
PrintStage("1 ingest", $"{report.TotalCallCount} calls / {report.TotalBlockedCallCount} blocked", "DURABLY APPLIED");
PrintStage("2 compute", $"coverage={report.Coverage}; fleet block rate={report.FleetBlockRate!.Value:P0}", "DECISION ELIGIBLE");
PrintStage("3 project", $"{operationsView!.Nodes.Count} nodes / {operationsView.Edges.Count} edge / content-free", "READ ONLY");
PrintStage("4 enforce", "partner-endpoint containment active", refusal.Action.ToString().ToUpperInvariant());
PrintStage("5 gap test", $"coverage={incomplete.Coverage}; fleet rate absent", "NO NEW DECISION");
Console.WriteLine(" ✅ local finding → graph → containment → enforced refusal completed with content-free evidence.");
}
finally
{
DeleteOwnedTemporaryDirectory(directory);
}
}
private static void PrintStage(string stage, string evidence, string disposition) =>
Console.WriteLine($" {stage,-14} {evidence,-50} {disposition}");
private static SecurityGraphObservationRequest Observation(
string eventId,
SecurityGraphNode source,
SecurityGraphNode destination,
SecurityGraphSignalKind signal,
string session) =>
new(eventId, source, destination, signal, session, "sample-evidence");
private static GatedToolCall Call(string name) =>
new(name, Arguments: null, AgentName: "security-graph-sample", Iteration: 0,
FunctionCallIndex: 0, FunctionCount: 1, IsStreaming: false, Messages: null);
private static void RequireThrows(Action action, string message)
where TException : Exception
{
try
{
action();
}
catch (TException)
{
return;
}
throw new InvalidOperationException("Security graph sample failed: " + message + ".");
}
private static void DeleteOwnedTemporaryDirectory(string directory)
{
var full = Path.GetFullPath(directory);
var tempRoot = Path.GetFullPath(Path.GetTempPath());
if (!full.StartsWith(tempRoot, StringComparison.OrdinalIgnoreCase) ||
!Path.GetFileName(full).StartsWith("agenteval-gatekeeper-graph-", StringComparison.Ordinal))
{
throw new InvalidOperationException("Refusing to delete a directory not owned by this sample.");
}
if (Directory.Exists(full))
{
Directory.Delete(full, recursive: true);
}
}
private static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException("Security graph sample failed: " + message + ".");
}
}
private sealed class SampleClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private sealed class SampleReleaseVerifier : IContainmentReleaseAuthorizationVerifier
{
public bool Verify(
ContainmentReleaseAuthorization authorization,
ReadOnlyMemory canonicalPayload) => false;
}
private sealed class SampleSession : AgentSession;
}