Repository navigation
feat(gatekeeper): runtime fail-closed enforcement middleware (M0–M5) - #76
Merged
Merged
Conversation
… fail-closed)
The Gatekeeper beachhead (Stage 2, M0): the SAME policy-object idea that grades in CI now blocks at
runtime, at the MAF function-invocation seam, writing to one AgentTrace. New AgentEval.MAF.Gatekeeper:
- IToolGate / GatedToolCall / ToolGateVerdict (reuses shipped GateAction) / ToolGatePolicy (WarnOnly default)
- ForbiddenToolGate (deny-list, case-insensitive)
- UseAgentEvalToolGate(AIAgentBuilder, gates, policy, trace?) — wraps the FunctionInvocationDelegatingAgent-
BuilderExtensions.Use seam; ReplaceResult returns a non-null refusal at the return-to-FICC boundary
(structural fail-closed vs HAZARD-1 "Success: Function completed." fabrication); records gate.tool.{seq}
evidence in the EXACT shape the shipped GlassBoxEvidence reader already counts (zero read-path change).
+3 spike tests (net8/9/10): (1) ReplaceResult blocks a live delete_file call — tool never runs, refusal
surfaced as a FunctionResultContent (not TextContent), evidence recorded, GlassBoxEvidence.GateBlockCount==1;
(2) WarnOnly records but lets the tool run; (3) non-FICC agent FAILS CLOSED — throws, asserted
throw-timing-tolerant (Build() OR first RunAsync) per the re-grounded MAF 1.12.0 XML.
Also fixes an incidental pre-existing CS1734 XML-doc warning (class-level paramref → <c>).
Plan: strategy/FutureFeatures/Middleware/Gatekeeper-M0-M1-ImplementationPlan.md (7 review findings fixed).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
Extends M0's slice (nothing thrown away):
- GateCost{PureCode,Bounded,Network,Llm} + IToolGate.Cost; UseAgentEvalToolGate REJECTS Network/Llm gates
at construction (those belong in shadow mode).
- ToolGateAction{Allow,Block,Mutate} + verdict Mutate factory; ToolGatePolicy{WarnOnly,ReplaceResult,Terminate}.
- Terminate: sets FunctionInvocationContext.Terminate + records action="Block"/terminate=true (counted by
the shipped GlassBoxEvidence).
- MutateArgs: rewrites context.Arguments in place (AIFunctionArguments) + before/after evidence — CONFIRMED
empirically to reach the function.
- ArgumentPatternGate (reuses the ReDoS-safe, fail-closed ForbiddenPatternScanner; rejects unbounded regex);
SequenceGate (single-run ordered A-then-B block).
Empirical seam findings (baked into the plan §4b): ThrowOnFail DROPPED — a throw at the function-invocation
seam is caught by FICC and never reaches the caller (hard-fail is an M2 run/chat-gate concern); the Terminate
sibling-hole-fill and ToolExecution-timing dual-benefit remain out of scope (not reachable / deferred to M2).
+7 M1 tests (Terminate, MutateArgs-echo, cost-rejection, ArgumentPattern block + unbounded-throw, SequenceGate,
MCP-shaped custom AIFunction gated by name). net8/9/10 green; 286 MAF+Guardrails regression-clean.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…ail; streaming scope) The run-level gate over AIAgentBuilder.Use(runFunc, runStreamingFunc): - AgentRunScope (AsyncLocal, Begin()-factory, carries session/agent/trace) — the bridge that lets inner tool/session gates read the run context. - UseAgentEvalGate(pre, post, EvalGatePolicy=WarnOnly, trace?) — reuses the shipped IChatGate/GateVerdict/ EvalGatePolicy/EvalGateRefusalException over the input (run-pre = incoming-attack detection) and response (run-post) TEXT; records gate.run-pre.*/gate.run-post.* evidence (GlassBoxEvidence counts it). Wires BOTH branches; register outermost. Block enforcement: WarnOnly->record; ThrowOnFail->throw (PROPAGATES at the run boundary, unlike the FICC-swallowed tool seam); Redact->short-circuit a refusal/redacted AgentResponse. - Streaming: run-pre only; a run-post gate under Redact/ThrowOnFail throws NotSupportedException at stream start (never buffer the stream). PERF-01: the AgentRunScope is re-established PER MoveNextAsync segment (an AsyncLocal set once atop an async iterator does not survive the first yield) - proven by a streaming text-then-toolcall test where a post-yield tool call still sees the scope. Types re-grounded to 1.12.0: AgentResponse / AgentResponseUpdate (not AgentRun*), AgentSession (not AgentThread), session.StateBag. +6 tests net8/9/10; 292 MAF+Guardrails regression-clean. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
- SessionContextGate: base for run-level gates needing the session; reads AgentRunScope.Current and FAILS CLOSED (Block) when no scope/session is available (SEC-02 — a control that can't read its context must deny). Registered as run-pre gates. - OperatorAuthGate: allow-list auth over a caller-set session StateBag operator identity (gatekeeper.operator); missing identity => Block (MAF surfaces no framework identity). - RateLimitGate: N runs per rolling window per session. SEC-04: authoritative counter is in-process (ConditionalWeakTable keyed by session identity, per-session lock) NOT the StateBag, which has no atomic increment and would let a read-modify-write counter be raced/bypassed. Injectable TimeProvider for deterministic tests. +4 tests net8/9/10 (auth fail-closed / allowed / unauthorized; rate within-over-reset with a fake clock). 296 MAF+Guardrails regression-clean. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…n + 9 major) Adversarial review (4 finders -> verify) of M0-M2c; all confirmed findings fixed: - CRITICAL fail-open: ArgumentPatternGate serialized args with DEFAULT JSON escaping, so injection metacharacters (< > & ' +) were escaped and a pattern like "<script"/"' OR"/"&&" NEVER matched -> gate returned Allow -> payload executed. Now serializes with UnsafeRelaxedJsonEscaping. +theory test. - Tool gate: a gate THROWING in InspectAsync was swallowed by FICC -> tool ran (fail-OPEN). Now fail-CLOSED (blocks + records) regardless of policy. +test. - Honest evidence: a WarnOnly block recorded action="Block" so GateBlockCount counted a call that RAN. Now records action="Warn" (not counted); enforced blocks stay "Block". (tool + run gate) - SequenceGate: enumeration race (reason built outside the lock) + cross-run state leak (never reset). Now per-run state keyed by AgentRunScope (weak table) with all access under the per-run lock. - Run gate streaming: run-pre gates ran BEFORE any AgentRunScope existed -> SessionContextGate pre-gates failed closed on every stream. Now the scope wraps the streaming pre-gate evaluation too. - RateLimitGate: documented the fixed/tumbling window (was "rolling") and the object-identity keying bypass on persisted-session reload. +8 tests (injection metachars x3, throwing-gate fail-closed, mutate-then-block, streaming auth allow + fail-closed). net8/9/10: 303 MAF+Guardrails green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…e bypass (self-introduced) Round-2 convergence review found 3 issues (2 = same critical regression from pass 1): - CRITICAL: SequenceGate keyed per-run state by AgentRunScope OBJECT identity, but the streaming run gate minted a NEW scope per MoveNextAsync segment (the PERF-01 workaround) — so an ordered attack (read_secrets then send_email in different stream pumps) got a fresh empty set each segment and was NEVER blocked. Fix: AgentRunScope.Enter() re-asserts ONE scope instance across all segments (stable identity); StreamCore now begins one scope and re-enters it per pump. +streaming SequenceGate regression test (reads=1, sends=0). - MAJOR: the run gate's RunGatesAsync did not guard gate InspectAsync against exceptions (the tool gate got that in pass 1) — a throwing run-pre gate now fails CLOSED (records + refuses) regardless of policy. +test. net8/9/10: 29 gatekeeper tests, 305 MAF+Guardrails green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…aring test, doc) Round-3 convergence review (0 critical/major; verifying r2 fixes) confirmed 3 minors, all fixed: - RateLimitGate: validate window > TimeSpan.Zero (a non-positive window reset the counter every call, silently disabling the limit — fail-open). Mirrors the maxRuns guard. - Streaming SequenceGate test was NOT load-bearing (passed even without the r2 fix, because both tools fell back to the shared key). Replaced with a cross-run isolation test: run 1 fires the trigger, run 2 (fresh run/scope) fires only the guarded tool and must be ALLOWED — fails without the stable per-run scope. - SequenceGate: documented that it orders calls across SEPARATE invocations, not a same-iteration concurrent batch (the seam exposes one call at a time, no sibling names) — use ForbiddenToolGate for strict same-batch protection. net8/9/10: 29 gatekeeper, 305 MAF+Guardrails green. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…mpletes M2)
- GateMetadataReader.StageFromKey (Core, additive): extracts the {stage} token from a
gate.{stage}.{seq}.{policy} key (mirrors PolicyFromKey; returns parts[1]).
- DoctorCommand.CheckDoubleGatingAsync (CLI): warns when the SAME gate policy recorded verdicts under
BOTH a chat-seam stage (pre/post) and an agent-seam stage (tool/run-*) — the policy is likely gating
the same run twice. Wired into the doctor flow beside CheckDoubleWrappingAsync. (The ToolExecution
double-record half is moot: the tool-gate timing dual-benefit was deferred to a later milestone.)
+3 tests (StageFromKey extraction, double-gating warns at both seams, no-warn when seams differ).
net8/9/10: 725 Cli+Tracing+Gatekeeper green, 0 regressions.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…gates) New bridge project AgentEval.RedTeam.Gatekeeper (references BOTH AgentEval.MAF gate seams AND AgentEval.RedTeam probes — no cycle; keeps the core MAF package free of the red-team dep). Closes the eval -> red-team -> enforcement loop: the SAME oracle that scores an offline attack now blocks a live call. - ProbeEvaluatorGate : IToolGate — adapts a DETERMINISTIC IProbeEvaluator; attack-succeeded => Block, Resisted/Inconclusive => Allow. Caller declares GateCost (IProbeEvaluator carries none); Network/Llm rejected at construction + a known-LLM-type deny-list (fixes SafetyMetricGate's unguarded-inline-LLM shape — no judge/registry call on the hot tool path). Scans a synthesized surface (tool name + args) with UnsafeRelaxedJsonEscaping so injection metachars aren't escaped away. - CanaryToolGate : IToolGate + CanaryLure — graduates a red-team CanaryTool into a production honeypot: CanaryLure.Tools advertises it as a visible schema-only lure the model can be tempted by; the gate blocks the emit (wire with Terminate => Block+Terminate+evidence). The stub throws if ever executed. +7 tests incl the moat proof: an advertised destructive canary is lured, the model calls it, the gate blocks the emit and the body never runs. (Note: the CLI has no --sut/in-process-agent on-ramp yet; the attack-the-gate scan runs via MAFAgentAdapter in code — a CLI seam is future work.) net8/9/10: 3702 MAF+RedTeam+Gatekeeper green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…d-team bridge (8 findings) Adversarial review of M3 found the "adapt ANY IProbeEvaluator" promise silently failed OPEN for evaluators whose detection isn't on the string overload. All confirmed findings fixed: - FAIL CLOSED on the enforcement path: ProbeEvaluatorGate now maps Resisted=>Allow, Succeeded+Inconclusive =>Block (cannot-determine => deny). This converts every silent no-op — a behavioral oracle (detection on the AgentResponse overload) or a probe-dependent one (reads probe.ExpectedTokens, blanked by the sentinel) — into a LOUD block instead of a leak. Inverts the grading convention deliberately: a runtime gate that can't prove a call safe must not run it. +test (Inconclusive => blocked). - Generic LLM rejection: replaced the brittle 2-name deny-list with reflection for an IChatClient field, so ANY LLM-backed evaluator (JudgeBacked/Decomposed/... not just LLMJudge/Likert) is rejected even if declared PureCode. +test. - Honeypot cannot be observe-only: added IToolGate.MinimumPolicy (default WarnOnly); CanaryToolGate overrides it to ReplaceResult; UseAgentEvalToolGate throws if a gate is registered below its enforcement floor (a canary under WarnOnly would silently breach). +test. - CanaryLure doc corrected: the stub throw is SWALLOWED by FICC, so the lure alone gives NO protection — the paired CanaryToolGate is mandatory. - Made the moat proof LOAD-BEARING: register the REAL destructive body and assert it runs ungated but is blocked gated (was a tautology — only the lure stub was wired). net8/9/10: 3443 Gatekeeper+RedTeam green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…torGate enforcement floor Round-2 review (3 majors) found the round-1 LLM guard under-covered its own claim: - HoldsChatClient only checked DIRECT IChatClient fields, but DecomposedGraders composites hold their LLM leaves as IProbeEvaluator CHILDREN (CompositeEvaluator/OutcomeFilter/ConjunctionGate) — so a composed LLM grader declared PureCode slipped through and would run a judge inline on the hot path. Made the guard REACHABILITY-aware: it now reads field VALUES and recurses through IProbeEvaluator children (arrays / collections too) with a reference-visited cycle guard. +test (LLM leaf wrapped two composites deep). - ProbeEvaluatorGate forgot to override IToolGate.MinimumPolicy, so its "only Resisted allows" promise could be silently downgraded to WarnOnly (flagged call runs). Now MinimumPolicy=ReplaceResult. +test. net8/9/10: 13 M3 tests, 3445 Gatekeeper+RedTeam green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…uarantine) The off-hot-path judge that runs the LLM/network checks the inline gates reject, then arms a fail-closed gate for a LATER run instead of blocking the one it observed: - IShadowJudge / ShadowVerdict / ShadowJudgeContext — a judge over an IMMUTABLE snapshot (response text + input + session; never the live AgentTrace, whose read paths assume writes are done — avoids the PERF-05 serialization race entirely by not touching it). - ShadowJudgePump — an OWNED (IAsyncDisposable, not fire-and-forget) background pump: a BOUNDED channel + single consumer. Enqueue is non-blocking; a full queue drops + reports via onError (the returning run is never slowed). A compromised verdict SetValue's the quarantine reason into the session StateBag; every verdict goes to the onVerdict sink (the output store), never the live trace. A throwing judge is reported, not fatal. - UseAgentEvalShadowJudge — wraps the run seam (runFunc + a streaming variant that accumulates then enqueues after the full response); returns the response unchanged and immediately. - QuarantineGate : SessionContextGate — the arming CONSUMER: fail-closed run-pre gate that blocks a run whose session was quarantined by an earlier shadow verdict. +4 tests incl the closed loop (compromised => armed => next run fails closed), clean-verdict-no-quarantine, snapshot+sink, throwing-judge-reported. net8/9/10: 322 MAF+Guardrails+Gatekeeper green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…ngs) Adversarial review of the shadow-judge pump found lifecycle/concurrency defects; all fixed: - MAJOR: DisposeAsync/CompleteAndDrainAsync awaited the consumer UNBOUNDED, and _cts.Cancel() sat AFTER that await — so a hung network judge (the documented use case) hung dispose forever and the cancel token was dead. Now a shared DrainAsync does a BOUNDED WaitAsync(drainTimeout); on timeout it cancels in-flight judgements (Cancel is reachable WHILE a judge is in flight) and stops waiting. +drainTimeout ctor param. +test (hanging judge => Dispose completes within the timeout). - MINOR: a throwing onError callback propagated out of the catch and killed the consumer (pump silently dead => quarantine never armed again). Wrapped every onError in SafeReport (try/catch). +test (throwing onError + throwing judge on item 1 => item 2 still judged + armed). - MINOR: Enqueue reported "queue full" even when the real cause was enqueue-after-completion. Now tracks a _completed flag and reports the accurate cause. - MINOR: the streaming seam had zero coverage. +tests (streamed response judged+quarantined; a stream abandoned early enqueues nothing). net8/9/10: 8 M4 tests, 326 MAF+Guardrails+Gatekeeper green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…ath ObjectDisposedException) Round-2 review found a second-order regression from the r1 bounded-drain: when the drain abandons a hung consumer and DisposeAsync disposes _cts, the consumer's per-item re-read of _cts.Token throws ObjectDisposedException (later buffered items then silently dropped as confusing errors instead of judged). Fix: capture `var token = _cts.Token` ONCE before the consume loop — the CancellationToken struct stays usable after the source is disposed. +load-bearing test (a judge that ignores cancellation blocks item 1, Dispose abandons + disposes _cts, item 1 unblocks, item 2 still judges with no ObjectDisposedException). net8/9/10: 9 M4 tests, 315 MAF+Gatekeeper green, 0 regressions. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…ment sample - docs/gatekeeper.md: the developer-facing walkthrough of the whole stack (tool gate / run gate / session gates / the moat / shadow judge), the fail-closed + honest-evidence principle, the cost model (inline rejects LLM/network; shadow judge is the release valve), the gate catalog, MinimumPolicy enforcement floor, and the doctor double-gating check. Wired into docs/toc.yml beside Guardrails. - samples SafetyAndSecurity/04_GatekeeperEnforcement: a ★ no-credentials sample (scripted model, fully deterministic) demonstrating all four layers failing closed — forbidden tool blocked, a real ContainsToken red-team evaluator blocking a poisoned call, a canary honeypot held, and a shadow verdict quarantining the next run. Wired into the Safety & Security menu group; references AgentEval.RedTeam.Gatekeeper. Verified end-to-end: every scenario reports blocked/held/refused. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
The MAF streaming-run delegate types session/options nullable; make StreamCore + StreamAndEnqueue accept AgentSession?/AgentRunOptions? to match (both are optional on a run) — clears the CS8604 warnings. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces Gatekeeper: a runtime, fail-closed enforcement layer for Microsoft Agent Framework (MAF) agents that blocks unsafe tool calls / runs pre-execution, records “honest evidence” into AgentTrace under gate.*, and adds an async shadow judge path that can quarantine a session for later runs without stalling the hot path.
Changes:
- Adds Gatekeeper runtime middleware for tool gates, run gates, session gates, and shadow-judge quarantine (plus supporting primitives like
AgentRunScope,GateCost,ToolGateVerdict). - Adds a thin AgentEval.RedTeam.Gatekeeper bridge to run deterministic red-team oracles/canaries as runtime gates (“the moat”).
- Adds comprehensive tests, a new credential-free sample, and documentation +
doctordiagnostics (double-gating check).
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/AgentEval.Tests/MAF/Gatekeeper/ToolGateTests.cs | End-to-end tests for tool-gate policies (warn/replace/terminate), mutation, cost rejection, and gate behaviors. |
| tests/AgentEval.Tests/MAF/Gatekeeper/ToolGateSpikeTests.cs | “Seam spike” tests proving MAF function-invocation middleware enforcement + evidence recording. |
| tests/AgentEval.Tests/MAF/Gatekeeper/ShadowJudgeTests.cs | Tests for shadow-judge pump behavior, quarantine arming, streaming behavior, and drain/dispose resilience. |
| tests/AgentEval.Tests/MAF/Gatekeeper/SessionGateTests.cs | Tests for fail-closed session gates (auth/rate-limit) including streaming path coverage. |
| tests/AgentEval.Tests/MAF/Gatekeeper/RunGateTests.cs | Tests for run-pre/run-post gating, refusal behavior, streaming constraints, and scope correctness. |
| tests/AgentEval.Tests/MAF/Gatekeeper/RedTeamGateBridgeTests.cs | Tests for the RedTeam↔Gatekeeper bridge: probe evaluator gate + canary honeypot enforcement. |
| tests/AgentEval.Tests/Cli/GlassBoxCliTests.cs | Adds coverage for StageFromKey and doctor double-gating detection. |
| tests/AgentEval.Tests/AgentEval.Tests.csproj | Adds test project reference to AgentEval.RedTeam.Gatekeeper. |
| src/AgentEval.RedTeam.Gatekeeper/ProbeEvaluatorGate.cs | Implements “moat” gate: adapts deterministic IProbeEvaluator to tool-gate enforcement with cost/LLM-reachability rejection. |
| src/AgentEval.RedTeam.Gatekeeper/CanaryToolGate.cs | Implements canary honeypot gate + lure tool builder (CanaryLure). |
| src/AgentEval.RedTeam.Gatekeeper/AgentEval.RedTeam.Gatekeeper.csproj | New bridge project referencing both AgentEval.MAF and AgentEval.RedTeam without creating cycles. |
| src/AgentEval.MAF/Gatekeeper/ToolGateVerdict.cs | Defines tool-gate verdict model (Allow/Block/Mutate) with optional argument rewrite payload. |
| src/AgentEval.MAF/Gatekeeper/ToolGatePolicy.cs | Defines enforcement policy for tool-gate blocks (WarnOnly/ReplaceResult/Terminate). |
| src/AgentEval.MAF/Gatekeeper/ToolGateAction.cs | Defines tool-gate finding types and ensures "Block" token alignment with Glass Box counting. |
| src/AgentEval.MAF/Gatekeeper/Shadow/ShadowJudgePump.cs | Owned, bounded background pump for async judging + quarantine arming + bounded drain semantics. |
| src/AgentEval.MAF/Gatekeeper/Shadow/IShadowJudge.cs | Defines IShadowJudge, ShadowVerdict, and ShadowJudgeContext snapshot contract. |
| src/AgentEval.MAF/Gatekeeper/Shadow/AgentEvalShadowJudgeExtensions.cs | Wires ShadowJudgePump into agent run + streaming seams and enqueues response snapshots. |
| src/AgentEval.MAF/Gatekeeper/SessionContextGate.cs | Base class for session-dependent gates that fail closed if scope/session is missing. |
| src/AgentEval.MAF/Gatekeeper/IToolGate.cs | Tool gate interface with inline cost and minimum enforcement floor support. |
| src/AgentEval.MAF/Gatekeeper/Gates/SequenceGate.cs | Implements per-run sequence gate (trigger→guarded) using run scope keying and thread-safe tracking. |
| src/AgentEval.MAF/Gatekeeper/Gates/RateLimitGate.cs | Implements fail-closed per-session fixed-window rate limiting with in-process, race-safe counters. |
| src/AgentEval.MAF/Gatekeeper/Gates/QuarantineGate.cs | Blocks runs on sessions armed as quarantined by the shadow judge. |
| src/AgentEval.MAF/Gatekeeper/Gates/OperatorAuthGate.cs | Implements fail-closed operator allow-list auth based on session StateBag metadata. |
| src/AgentEval.MAF/Gatekeeper/Gates/ForbiddenToolGate.cs | Deny-list tool gate by tool name. |
| src/AgentEval.MAF/Gatekeeper/Gates/ArgumentPatternGate.cs | Blocks calls whose (relaxed-escaped) serialized args match a bounded-timeout regex pattern. |
| src/AgentEval.MAF/Gatekeeper/GatedToolCall.cs | Defines the tool-call inspection subject passed to tool gates (name/args/iteration/etc). |
| src/AgentEval.MAF/Gatekeeper/GateCost.cs | Defines gate cost tiers and inline eligibility constraints. |
| src/AgentEval.MAF/Gatekeeper/AgentRunScope.cs | Ambient run scope for session/agent/trace propagation across non-streaming and streaming segments. |
| src/AgentEval.MAF/Gatekeeper/AgentEvalToolGateExtensions.cs | Registers tool-gate middleware at the MAF function invocation seam and records gate.tool.* evidence. |
| src/AgentEval.MAF/Gatekeeper/AgentEvalRunGateExtensions.cs | Registers run-pre/run-post gates at the agent run seam and establishes AgentRunScope. |
| src/AgentEval.Core/Tracing/GateMetadataReader.cs | Adds StageFromKey helper for gate metadata key parsing. |
| src/AgentEval.Core/Benchmarks/TraceFidelity/WorkflowTraceFidelityReconciler.cs | Fixes an XML-doc tag usage in trace fidelity reconciler comments. |
| src/AgentEval.Cli/Commands/DoctorCommand.cs | Adds doctor check for potential “double-gating” (same policy recorded at chat + agent seams). |
| samples/AgentEval.Samples/SafetyAndSecurity/04_GatekeeperEnforcement.cs | Adds a credential-free sample demonstrating tool/moat/canary/shadow layers. |
| samples/AgentEval.Samples/Program.cs | Registers the new Gatekeeper sample in the samples menu. |
| samples/AgentEval.Samples/AgentEval.Samples.csproj | Adds samples project reference to AgentEval.RedTeam.Gatekeeper. |
| docs/toc.yml | Adds Gatekeeper doc to the documentation TOC. |
| docs/gatekeeper.md | New Gatekeeper documentation describing layers, policies, moat bridge, shadow judge, and doctor check. |
| AgentEval.sln | Adds the new AgentEval.RedTeam.Gatekeeper project to the solution. |
…opilot #76) Copilot review: on the streaming run path, post-gates were never evaluated, and the guard only threw for Redact/ThrowOnFail — so WarnOnly post-gates on a streaming run silently did nothing (no evidence), a false sense of output monitoring. Fix: accumulate the streamed response (only when post-gates exist, so TTFT and the gate-less path are untouched) and run the post-gates AFTER the stream to record evidence — consistent with non-streaming WarnOnly (observe-only, never blocks the already-delivered response). Guard message clarified (blocking policies fail closed; WarnOnly is recorded after the stream). +regression test. net8/9/10: 316 MAF+Gatekeeper green. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…lot #76) Copilot review: SerializeArgs used JsonSerializer.Serialize with DEFAULT options, which JSON-escapes < > & ' and non-ASCII to \uXXXX — so the Mutate before/after audit did not match the values the tool actually receives, making "serialized verbatim" misleading. Fix: serialize with UnsafeRelaxedJsonEscaping (same lesson as ArgumentPatternGate/ProbeEvaluatorGate) so the audit is faithful. +regression test (a mutation value with < > & ' is recorded raw, not escaped). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
Copilot review: StageFromKey/PolicyFromKey only checked parts.Length >= 4, so they returned a stage/policy for a NON-gate key (e.g. "notgate.pre.1.Policy") or one with empty stage/seq/policy segments — contradicting the "null when malformed" doc and risking misclassification when called without a prior IsGateKey guard. Fix: a shared TrySplitGateKey requires the "gate." prefix plus non-empty stage, seq, and policy (policy may still contain dots). +tests (non-gate prefix, empty stage/seq/policy => null; dotted policy still parses). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…(Copilot #76) Follow-up to 968ae09: TrySplitGateKey required ANY non-empty policy segment, so "gate.pre.1..Policy" passed and PolicyFromKey produced a leading-dot policy ".Policy". Require ALL policy segments non-empty (Any => All) so leading/trailing/double-dot policy keys return null. +tests (leading-dot, trailing-dot => null). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…ds on gate lists (Copilot #76) Two Copilot review findings: - IsGateKey only checked the "gate." prefix, so GlassBoxEvidence.CountGateBlocks could mis-count unrelated/ malformed gate.* metadata. It now delegates to TrySplitGateKey (the well-formed gate.{stage}.{seq}.{policy} definition), matching its doc. (All real verdict keys are well-formed, so counting is unchanged.) - UseAgentEvalToolGate validated the gates list but not its elements — a null gate NRE'd on g.Cost. Added an explicit, descriptive null-element guard; applied the same guard to UseAgentEvalGate's pre/post lists. +tests (null tool-gate element => ArgumentException(paramName=gates); null pre gate => ArgumentNullException). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
…essage fixes (Copilot #76) Three Copilot review findings: - PACKAGING: the umbrella AgentEval package embeds all ProjectReference DLLs but did NOT reference AgentEval.RedTeam.Gatekeeper, so ProbeEvaluatorGate/CanaryToolGate would not ship to package consumers. Added the ProjectReference (PrivateAssets=all) so the moat gates are in the published package. - docs/gatekeeper.md: the ProbeEvaluatorGate snippet used GateCost without importing AgentEval.MAF.Gatekeeper (where GateCost lives) — added the using so it compiles when copy-pasted. - CanaryLure throw message said "Terminate policy" required, but the gate's MinimumPolicy is ReplaceResult (and UseAgentEvalToolGate accepts it) — message now says "ReplaceResult or Terminate". Umbrella package builds with the bridge included. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
joslat
added a commit
that referenced
this pull request
Jul 5, 2026
Docs for the merged Gatekeeper (#76): CHANGELOG [Unreleased] section, README feature-table row + coverage line, and ADR-025 (fail-closed / honest-evidence / cost-model / moat-inverts-grading / no-cycle bridge). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Gatekeeper — Glass Box tells you what your agent did; Gatekeeper stops it from doing the wrong thing
Evaluation and red-teaming find problems after the fact. Gatekeeper is the other half: it puts the same
checks in the request path so a forbidden tool call, a poisoned argument, or a compromised conversation is
blocked before it happens. It plugs into the MAF agent pipeline (no rewrite), and every gate is
fail-closed — a gate that cannot prove an action safe does not run it.
The layers
Inline gates reject network/LLM work at construction (an LLM judge on every tool call would stall the agent and
risk a fabricated verdict); the shadow judge is where that expensive judgment goes — off the hot path,
arming a
QuarantineGatevia the sessionStateBag.Two hard design positions
AgentTracethe evaluators read, undergate.*keys. A gate that only warns records
action="Warn"— never counted as a block, so the evidence can neverclaim it stopped a call that actually ran.
The moat (the demonstrable closed loop)
ProbeEvaluatorGateruns the same deterministic red-team oracle that scores an attack offline as a runtimegate that blocks it.
CanaryToolGate+CanaryLuregraduate a red-team canary into a production honeypot.Both live in a new thin
AgentEval.RedTeam.Gatekeeperbridge project so the core MAF package never takes adependency on the red-team assembly.
What's included (M0–M5)
ForbiddenToolGate/ArgumentPatternGate/SequenceGate,ToolGatePolicy(WarnOnly/ReplaceResult/Terminate),
GateCostrejection,MinimumPolicyenforcement floor.AgentRunScope(stable across streaming segments), session gates (auth / rate limit),doctordouble-gating check +StageFromKey.ProbeEvaluatorGate(fail-closedInconclusive⇒Block; reachability-aware LLM rejection),CanaryToolGate+CanaryLure.IShadowJudge+ ownedShadowJudgePump(bounded queue, bounded drain, neverraces the live trace) +
QuarantineGate.docs/gatekeeper.md+ a credential-free sample (SafetyAndSecurity/04_GatekeeperEnforcement,deterministic, all four layers fail closed).
Not included: M6 (workflow middleware) is upstream-blocked on
microsoft/agent-framework#3075— the MAFworkflow seam doesn't exist yet, so it's tracked, not built.
Review & testing
Every milestone was put through an adversarial find→verify→fix review loop, iterated to zero — which caught
real security bugs before this PR: a critical
ArgumentPatternGateJSON-escape fail-open, a streamingSequenceGatebypass, and composed-LLM-grader / shadow-pump-lifecycle defects. Each fix landed with aload-bearing regression test.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy