Skip to content

feat(gatekeeper): runtime fail-closed enforcement middleware (M0–M5) - #76

Merged
joslat merged 22 commits into
mainfrom
feat/gatekeeper
Jul 5, 2026
Merged

joslat merged 22 commits into
mainfrom
feat/gatekeeper

Conversation

@joslat

@joslat joslat commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

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

Layer Seam Blocks Cost budget
Tool gate each tool call, pre-execution a forbidden / poisoned / out-of-sequence tool call pure-code / bounded
Run gate the run's input & output text an incoming attack (run-pre) or a leaking response (run-post) pure-code / bounded
Session gates before a run unauthorized operator, rate-limit breach, quarantined session pure-code
The moat a tool call anything your red-team probes/canaries catch pure-code / bounded
Shadow judge after the run, async arms quarantine for a later run anything — LLM, network

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 QuarantineGate via the session StateBag.

Two hard design positions

  1. Cannot-inspect ⇒ deny. A gate that throws, times out, or can't read its context blocks — never fails open.
  2. Honest evidence. Every decision is recorded into the same AgentTrace the evaluators read, under gate.*
    keys. A gate that only warns records action="Warn" — never counted as a block, so the evidence can never
    claim it stopped a call that actually ran.

The moat (the demonstrable closed loop)

ProbeEvaluatorGate runs the same deterministic red-team oracle that scores an attack offline as a runtime
gate that blocks it. CanaryToolGate + CanaryLure graduate a red-team canary into a production honeypot.
Both live in a new thin AgentEval.RedTeam.Gatekeeper bridge project so the core MAF package never takes a
dependency on the red-team assembly.

What's included (M0–M5)

  • M0/M1 — tool gate: ForbiddenToolGate / ArgumentPatternGate / SequenceGate, ToolGatePolicy
    (WarnOnly/ReplaceResult/Terminate), GateCost rejection, MinimumPolicy enforcement floor.
  • M2 — run gate + AgentRunScope (stable across streaming segments), session gates (auth / rate limit),
    doctor double-gating check + StageFromKey.
  • M3 — the moat: ProbeEvaluatorGate (fail-closed Inconclusive⇒Block; reachability-aware LLM rejection),
    CanaryToolGate + CanaryLure.
  • M4 — the shadow judge: IShadowJudge + owned ShadowJudgePump (bounded queue, bounded drain, never
    races the live trace) + QuarantineGate.
  • M5 — 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 MAF
workflow 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 ArgumentPatternGate JSON-escape fail-open, a streaming
SequenceGate bypass, and composed-LLM-grader / shadow-pump-lifecycle defects. Each fix landed with a
load-bearing regression test.

  • Full suite green: 6824 pass (net8.0), 7027 pass / 1 intentional skip (net10.0), 0 failures.
  • ~60 new Gatekeeper tests across net8/9/10.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KyCZ6Em2Rc5aYdaMHuwtuy

joslat and others added 16 commits July 5, 2026 12:03
… 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
Copilot AI review requested due to automatic review settings July 5, 2026 14:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + doctor diagnostics (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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

…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 AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

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 AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 1 comment.

…(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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 2 comments.

…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated 3 comments.

Comment thread docs/gatekeeper.md Resolved
Comment thread docs/gatekeeper.md Resolved
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 40 out of 40 changed files in this pull request and generated no new comments.

@joslat
joslat merged commit 7d86307 into main Jul 5, 2026
16 checks passed
@joslat
joslat deleted the feat/gatekeeper branch July 5, 2026 17:45
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants