-
Notifications
You must be signed in to change notification settings - Fork 562
fix(aiguard): scope framework collision avoidance to request and response phases #20461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
avara1986
wants to merge
20
commits into
main
Choose a base branch
from
avara/aiguard-phase-scoped-collision-avoidance
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
3418758
fix(aiguard): scope framework collision avoidance to request and resp…
avara1986 bf3cb54
test(aiguard): cover streamed LangChain response evaluation end to end
avara1986 784ffb9
chore(aiguard): track shifted add_error_log exemption line
avara1986 5282bf5
Merge branch 'main' into avara/aiguard-phase-scoped-collision-avoidance
avara1986 0b1d72b
fix comments
avara1986 2ad6dfa
fix(aiguard): release LangChain stream claim by handle across tasks
avara1986 31b734a
Merge remote-tracking branch 'origin/main' into avara/aiguard-phase-s…
avara1986 84613d6
fix(aiguard): buffer LangChain streams at the LangChain layer and rel…
avara1986 a02a5a4
Merge remote-tracking branch 'origin/main' into avara/aiguard-phase-s…
avara1986 fc48f50
refactor(aiguard): own LangChain claims in AI Guard wrappers, keep th…
avara1986 8c7dc40
fix(aiguard): buffer LangChain streams at each model's _stream, below…
avara1986 d2227d7
fix(aiguard): buffer each model's stream separately and install buffe…
avara1986 04cf299
Merge branch 'main' into avara/aiguard-phase-scoped-collision-avoidance
avara1986 c9af054
Merge branch 'main' into avara/aiguard-phase-scoped-collision-avoidance
avara1986 69ed954
fix(aiguard): use ddtrace.internal.threads.RLock for the LangChain bu…
avara1986 d118016
fix(aiguard): hold LangChain stream-buffer records weakly so model cl…
avara1986 6683e1a
Merge branch 'main' into avara/aiguard-phase-scoped-collision-avoidance
avara1986 f36af78
fix(aiguard): evaluate the request in LangChain stream buffers and cl…
avara1986 6970afd
test(aiguard): cover stream() falling back to invoke(); document buff…
avara1986 0bf5670
fix(aiguard): close sync stream iterators, keep same-model suppressio…
avara1986 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,77 +1,128 @@ | ||
| """Active-flag tracking for AI Guard collision avoidance. | ||
|
|
||
| When a framework integration (e.g. LangChain, Strands) is already evaluating | ||
| messages through AI Guard, provider-level integrations (e.g. OpenAI) must | ||
| skip their own evaluation to avoid double-scanning. The framework calls | ||
| ``set_aiguard_context_active()`` around its dispatch + LLM call block and | ||
| the provider listener calls ``is_aiguard_context_active()`` to decide | ||
| whether to short-circuit | ||
| """Phase-scoped collision avoidance for AI Guard. | ||
|
|
||
| A framework integration (LangChain, Strands) and a provider integration (OpenAI, | ||
| Anthropic) can both fire for the same call. The framework marks the phases it | ||
| evaluates itself, and a provider listener skips only the phase it is asked about | ||
| -- so a framework that covers the request but not the response leaves the | ||
| provider's response protection in place. | ||
|
|
||
| The phases are tracked independently so a framework never suppresses a check it | ||
| does not perform itself. A single all-or-nothing flag once let LangChain | ||
| streaming switch off the provider's buffered-stream evaluation without doing its | ||
| own, and left streamed responses unevaluated (APPSEC-70286). | ||
|
|
||
| Who claims what: | ||
|
|
||
| - LangChain generate / agenerate: REQUEST and RESPONSE for the whole call (it | ||
| evaluates both). | ||
| - LangChain model streams: REQUEST and RESPONSE while the model's own _stream / | ||
| _astream is read. The response is buffered and evaluated there, below | ||
| LangChain's callbacks and above the provider, so the provider's buffered | ||
| stream stays passthrough and cannot trip LangChain's per-chunk timeout. | ||
| - Strands: REQUEST and RESPONSE (before- and after-model-call hooks). | ||
|
|
||
| Claims are shared objects rather than per-context counters: an asyncio task | ||
| works on a copy of its parent's Context, so a counter lowered from another task | ||
| would leave the claiming task covered for good. Every claim is released by the | ||
| handle set_aiguard_context_active returned, never by searching the current | ||
| context, so a release can neither miss its claim nor take someone else's. Prefer | ||
| aiguard_context, which claims and releases in one frame; carry the handle only | ||
| when a framework's hooks split claim and release (Strands). | ||
| """ | ||
|
|
||
| from collections.abc import Iterator | ||
| import contextlib | ||
| import contextvars | ||
| from enum import Enum | ||
| from typing import Optional | ||
|
|
||
|
|
||
| _AI_GUARD_DEPTH: contextvars.ContextVar[int] = contextvars.ContextVar("ai_guard_active_depth", default=0) | ||
| class Phase(Enum): | ||
| """Half of a model call a framework may take responsibility for.""" | ||
|
|
||
| REQUEST = "request" | ||
| RESPONSE = "response" | ||
|
|
||
| def is_aiguard_context_active() -> bool: | ||
| """Return ``True`` if a framework-level AI Guard evaluation is in progress.""" | ||
| return _AI_GUARD_DEPTH.get() > 0 | ||
|
|
||
| ALL_PHASES: tuple[Phase, ...] = (Phase.REQUEST, Phase.RESPONSE) | ||
|
|
||
| def set_aiguard_context_active() -> contextvars.Token[int]: | ||
| """Mark the current execution context as already under AI Guard evaluation. | ||
|
|
||
| Returns an opaque :class:`contextvars.Token` to pair with | ||
| :func:`reset_aiguard_context_active`. Nested set / reset pairs increment | ||
| and decrement the same depth counter, so reads return ``True`` until every | ||
| set is matched by a reset. | ||
| class _Claim: | ||
| """One phase held by one framework call. | ||
|
|
||
| Every Context copied while the claim is held shares this object, so a | ||
| release from any of them -- including a different asyncio task -- is seen by | ||
| all, rather than only lowering the releasing task's own copy (APPSEC-70282). | ||
| """ | ||
| return _AI_GUARD_DEPTH.set(_AI_GUARD_DEPTH.get() + 1) | ||
|
|
||
| __slots__ = ("phase", "released") | ||
|
|
||
| def reset_aiguard_context_active(token: Optional[contextvars.Token[int]]) -> None: | ||
| """Restore the depth counter to its value before the matching ``set``. | ||
| def __init__(self, phase: Phase) -> None: | ||
| self.phase = phase | ||
| self.released = False | ||
|
|
||
|
|
||
| _CLAIMS: contextvars.ContextVar[tuple[_Claim, ...]] = contextvars.ContextVar("ai_guard_claims", default=()) | ||
|
|
||
| # Opaque pairing handle returned by set / consumed by reset. | ||
| PhaseTokens = tuple[_Claim, ...] | ||
|
|
||
| A ``None`` token is a defensive no-op (e.g. cleanup paths that may run | ||
| without a prior ``set``). | ||
| """ | ||
| if token is None: | ||
| return | ||
| _AI_GUARD_DEPTH.reset(token) | ||
|
|
||
| def _prune() -> None: | ||
| """Drop released claims from the current context's stack.""" | ||
| claims = _CLAIMS.get() | ||
| live = tuple(claim for claim in claims if not claim.released) | ||
| if len(live) != len(claims): | ||
| _CLAIMS.set(live) | ||
|
|
||
| def reset_aiguard_context_active_current() -> None: | ||
| """Tokenless companion to :func:`reset_aiguard_context_active`. | ||
|
|
||
| Decrements the depth counter for the current context. Used when the | ||
| original token is not accessible — e.g. a framework's ``.after`` | ||
| listener releasing the counter that the matching ``.before`` listener | ||
| bumped, since the dispatch infrastructure does not thread the token | ||
| through to the after-event. | ||
| def is_aiguard_context_active(phase: Optional[Phase] = None) -> bool: | ||
| """Return whether a framework already covers phase. | ||
|
|
||
| Safe to call when the counter is already zero (no-op): the ``.after`` | ||
| event may fire without a matching ``.before`` if dispatch is | ||
| reconfigured at runtime. | ||
| Omitting phase asks whether any phase is covered. Callers that guard a | ||
| specific evaluation should always name their phase; the phase-less form | ||
| exists for callers that only need to know an evaluation is in flight. | ||
| """ | ||
| depth = _AI_GUARD_DEPTH.get() | ||
| if depth > 0: | ||
| _AI_GUARD_DEPTH.set(depth - 1) | ||
| claims = _CLAIMS.get() | ||
| if not claims: | ||
| return False | ||
| # Runs on every provider call: a plain loop is several times faster than any() over a generator. | ||
| for claim in claims: | ||
| if not claim.released and (phase is None or claim.phase is phase): | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def set_aiguard_context_active(*phases: Phase) -> PhaseTokens: | ||
| """Claim phases for the current execution context. | ||
|
|
||
| No arguments claims every phase. Returns a handle to pair with | ||
| reset_aiguard_context_active; nested claims stack, so reads stay true until | ||
| every claim is released. | ||
| """ | ||
| tokens = tuple(_Claim(phase) for phase in (phases or ALL_PHASES)) | ||
| _CLAIMS.set(tuple(claim for claim in _CLAIMS.get() if not claim.released) + tokens) | ||
| return tokens | ||
|
|
||
|
|
||
| @contextlib.contextmanager | ||
| def aiguard_context() -> Iterator[None]: | ||
| """Mark the current task as under AI Guard evaluation for the block's duration. | ||
| def reset_aiguard_context_active(tokens: Optional[PhaseTokens]) -> None: | ||
| """Release the claims in tokens, from any context. A falsy handle is a no-op. | ||
|
|
||
| Framework integrations (LangChain, Strands) wrap their dispatch + LLM | ||
| call block with this so nested provider-level integrations (e.g. OpenAI) | ||
| skip their own evaluation. | ||
| Only these claims are released, so a release that lands in another task | ||
| can neither leave the claiming task covered nor drop an unrelated claim. | ||
| """ | ||
| token = set_aiguard_context_active() | ||
| if not tokens: | ||
| return | ||
| for claim in tokens: | ||
| claim.released = True | ||
| _prune() | ||
|
|
||
|
|
||
| @contextlib.contextmanager | ||
| def aiguard_context(*phases: Phase) -> Iterator[None]: | ||
| """Claim phases for the duration of the block. No arguments claims every phase.""" | ||
| tokens = set_aiguard_context_active(*phases) | ||
| try: | ||
| yield | ||
| finally: | ||
| reset_aiguard_context_active(token) | ||
| reset_aiguard_context_active(tokens) | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.