Skip to content
Draft
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d2b6469
feat(garak): integrate garak scan
kevinmessiaen Jul 2, 2026
7f50ee2
refactor(scan): extract garak async bridge into _bridge.py
kevinmessiaen Jul 2, 2026
17050fe
feat(scan): add GiskardJudgeGenerator bridging garak judges to Giskard
kevinmessiaen Jul 2, 2026
86dc9ea
feat(scan): wire Giskard generator into garak judges, skip keyless de…
kevinmessiaen Jul 2, 2026
b92daa5
feat(scan): emit skip results for keyless garak detectors
kevinmessiaen Jul 2, 2026
2ad6b5f
fix(scan): set base generator attrs so garak judges can call GiskardJ…
kevinmessiaen Jul 2, 2026
912d9f4
fix(scan): import BaseGenerator from package root; document key handling
kevinmessiaen Jul 2, 2026
d0bbe43
Merge branch 'main' into feat/garak-scan-integration
kevinmessiaen Jul 3, 2026
9a50551
feat(scan): record garak scan duration on SuiteResult
cursoragent Jul 3, 2026
59469d9
style(scan): apply ruff format to garak adapter
cursoragent Jul 3, 2026
866aea1
fix(scan): address Gemini review on garak integration
cursoragent Jul 3, 2026
a688dfb
fix(scan): keep garak optional for CI checks and add garak-test group…
kevinmessiaen Jul 3, 2026
0cd6212
Merge branch 'main' into feat/garak-scan-integration
kevinmessiaen Jul 3, 2026
3a7366d
chore: update THIRD_PARTY_NOTICES.md
kevinmessiaen Jul 3, 2026
362bf0a
fix(scan): copy garak probe tags onto scenario results (#2584)
kevinmessiaen Jul 3, 2026
ca247d3
fix(scan): name garak check results after probe detector plugins (#2585)
kevinmessiaen Jul 6, 2026
3479aa1
feat(lidar): integrate lidar scan into third_party_scan (#2588)
kevinmessiaen Jul 7, 2026
6561af2
Merge branch 'main' into feat/garak-scan-integration
kevinmessiaen Jul 7, 2026
bc94ea6
optimize probe execution in GarakScanAdapter
henchaves Jul 8, 2026
81ef53b
test(scan): cover garak probe thread-pool deadlock; use context-manag…
kevinmessiaen Jul 8, 2026
4a370b4
Merge branch 'main' into feat/garak-scan-integration
kevinmessiaen Jul 8, 2026
52acf2c
Update libs/giskard-scan/src/giskard/scan/integrations/garak/_adapter.py
kevinmessiaen Jul 9, 2026
ff7025c
feat(scan): add PostHog telemetry for scan entry points
cursoragent Jul 9, 2026
a4cf777
refactor(scan): unify telemetry shape and relax anonymisation
cursoragent Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,26 @@ jobs:
GISKARD_CHECKS_DEFAULT_MODEL: "google/gemini-3.5-flash"
GISKARD_CHECKS_DEFAULT_EMBEDDING_MODEL: "google/gemini-embedding-001"
run: make test-functional PACKAGE=giskard-checks PROVIDER=$PROVIDER

test-scan-garak:
needs: authorize
runs-on: ubuntu-latest
permissions:
contents: read # checkout repository
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
python-version: ["3.12"]
name: scan / garak / ${{ matrix.python-version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- run: make install-garak-test
- run: make test-garak
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ endif

test-unit: ## Run unit tests only (excludes functional), optional PACKAGE=<name>
ifdef PACKAGE
uv run pytest libs/$(PACKAGE) -m "not functional"
uv run --directory libs/$(PACKAGE) pytest tests src -m "not functional"
else
$(foreach lib,$(LIBS),uv run pytest libs/$(lib) -m "not functional" &&) true
$(foreach lib,$(LIBS),uv run --directory libs/$(lib) pytest tests src -m "not functional" &&) true
endif

test-functional: ## Run functional tests only (requires API keys), optional PACKAGE=<name> PROVIDER=<name>
Expand All @@ -61,6 +61,15 @@ install-no-providers: ## Install giskard-llm without provider SDKs (for no_provi
install-minimal: ## Install with test group only (no provider SDKs, all packages)
uv sync --only-group test

install-garak-test: ## Install garak optional extra for scan integration tests
uv sync --group garak-test

install-lidar-test: ## Install lidar private dependency for scan integration tests
uv sync --group lidar-test

test-lidar: install-lidar-test ## Run lidar integration tests
uv run pytest libs/giskard-scan/tests/integrations/lidar -v

test-unit-minimal: ## Run unit tests on minimal deps (no provider SDKs), optional PACKAGE=<name>
ifdef PACKAGE
uv run pytest libs/$(PACKAGE) -m "not functional"
Expand All @@ -71,6 +80,9 @@ endif
test-no-providers: ## Run tests that verify behavior when provider SDKs are missing
uv run pytest libs/giskard-llm -m "no_providers"

test-garak: ## Run garak integration tests (requires: make install-garak-test)
uv run pytest libs/giskard-scan/tests/integrations/garak

test-package-conflict: ## Test package conflict with giskard legacy package installed
@echo "Testing package conflict..."
@echo "Creating virtual environment..."
Expand Down
12 changes: 6 additions & 6 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,11 @@ Find a list of packages below
- License: Apache-2.0
- Compatible: True

### fsspec-2026.6.0
### fsspec-2025.3.0

- HomePage:
- Author:
- License: BSD-3-Clause
- License: BSD License
- Compatible: True

### google-auth-2.55.0
Expand Down Expand Up @@ -397,7 +397,7 @@ Find a list of packages below
- License: Apache License 2.0
- Compatible: True

### numpy-2.5.0
### numpy-2.4.6

- HomePage:
- Author: Travis E. Oliphant et al.
Expand Down Expand Up @@ -551,7 +551,7 @@ Find a list of packages below
- License: MIT License
- Compatible: True

### tokenizers-0.23.1
### tokenizers-0.22.2

- HomePage:
- Author: Nicolas Patry
Expand Down Expand Up @@ -586,11 +586,11 @@ Find a list of packages below
- License: MIT
- Compatible: True

### websockets-16.0
### websockets-15.0.1

- HomePage:
- Author: Aymeric Augustin
- License: BSD-3-Clause
- License: BSD License
- Compatible: True

### yarl-1.24.2
Expand Down
2 changes: 2 additions & 0 deletions libs/giskard-checks/src/giskard/checks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
GroupedSuiteResult,
GroupStats,
InputGenerationException,
InputGenerator,
Interact,
Interaction,
InteractionSpec,
Expand Down Expand Up @@ -103,6 +104,7 @@
"Interact",
"Interaction",
"InteractionSpec",
"InputGenerator",
"WithGeneratorMixin",
"WithEmbeddingMixin",
# Builtin and LLM-based checks
Expand Down
2 changes: 2 additions & 0 deletions libs/giskard-checks/src/giskard/checks/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .check import Check
from .exceptions import InputGenerationException
from .extraction import resolve
from .input_generator import InputGenerator
from .interaction import Interact, Interaction, InteractionSpec, Trace
from .result import (
CheckResult,
Expand All @@ -24,6 +25,7 @@
"InteractionSpec",
"Interact",
"Interaction",
"InputGenerator",
"Check",
"CheckResult",
"CheckStatus",
Expand Down
17 changes: 16 additions & 1 deletion libs/giskard-checks/src/giskard/checks/core/interaction/trace.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from typing import Any, Self
from typing import Any, Self, cast

from pydantic import BaseModel, Field, computed_field
from rich.console import Console, ConsoleOptions, RenderResult
from rich.rule import Rule

from ..protocols import InteractionGenerator
from ..types import Target
from .interaction import Interaction


Expand Down Expand Up @@ -135,3 +136,17 @@ def __rich_console__(
for idx, interaction in enumerate(self.interactions):
yield Rule(f"Interaction {idx + 1}", style="bold")
yield from interaction.__rich_console__(console, options)

@classmethod
def for_target[In, Out, Tr: Trace]( # pyright: ignore[reportMissingTypeArgument]
cls, target: Target[In, Out, Tr]
) -> Tr:
# Local import: utils.inference imports Trace, so a module-level import
# here would create a circular import at load time.
from ...utils.inference import _infer_trace_type

target_type = _infer_trace_type(target)
if target_type is None:
return cast(Tr, cls())

return target_type()
4 changes: 4 additions & 0 deletions libs/giskard-checks/src/giskard/checks/core/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def success(
*,
message: str | None = None,
details: dict[str, Any] | None = None,
metrics: list[Metric] | None = None,
) -> "CheckResult":
"""Construct a successful result.

Expand All @@ -165,6 +166,7 @@ def success(
status=CheckStatus.PASS,
message=message,
details={} if details is None else details,
metrics=metrics or [],
)

@classmethod
Expand All @@ -173,12 +175,14 @@ def failure(
*,
message: str | None = None,
details: dict[str, Any] | None = None,
metrics: list[Metric] | None = None,
) -> "CheckResult":
"""Construct a failure result."""
return cls(
status=CheckStatus.FAIL,
message=message,
details={} if details is None else details,
metrics=metrics or [],
)

@classmethod
Expand Down
5 changes: 4 additions & 1 deletion libs/giskard-checks/src/giskard/checks/utils/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import PydanticUserError, TypeAdapter

from ..core.interaction.trace import Trace
from ..core.types import Target


def _get_param_hints(target: object) -> dict[str, Any]:
Expand Down Expand Up @@ -46,7 +47,9 @@ def _infer_input_type(outputs: object) -> type | None:
return first_param_type


def _infer_trace_type(target: object) -> type[Trace] | None: # pyright: ignore[reportMissingTypeArgument]
def _infer_trace_type[InputType, OutputType, TraceType: Trace]( # pyright: ignore[reportMissingTypeArgument]
target: Target[InputType, OutputType, TraceType],
) -> type[TraceType] | None:
"""Return second parameter's type if it is a Trace subclass, otherwise None."""
param_hints = _get_param_hints(target)
if len(param_hints) < 2:
Expand Down
52 changes: 52 additions & 0 deletions libs/giskard-scan/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,55 @@
# giskard-scan

Agent vulnerability scanner — red teaming, prompt injection, adversarial scenario generation.

## Third-party scanners (experimental)

`third_party_scan` runs an external security scanner against a Giskard target and
returns a `SuiteResult`. Only [garak](https://github.com/NVIDIA/garak) is supported
today, and it ships as an optional extra:

```bash
pip install giskard-scan[garak]
```

```python
import asyncio

from giskard.scan import third_party_scan


def target(inputs: str) -> str:
# Your agent / model call. Structured (BaseModel) inputs also work.
return call_my_agent(inputs)


result = asyncio.run(
third_party_scan(
target,
tool="garak",
description="A helpful assistant", # required; lidar builds its target profile from this, garak ignores it
probes=["probes.goodside.ThreatenJSON"], # omit to run all active probes
target_mode="multiturn", # "singleturn" skips garak's iterative probes
)
)

print(result)
```

Probes run in parallel; the target is invoked concurrently, so it must be safe to
call from multiple threads (per-conversation state is tracked in the `Trace`, not on
the target).

### API keys and LLM-judge detectors

Some garak detectors need an LLM or a third-party API to score a probe:

- **LLM-judge detectors** (garak's `judge.*`, e.g. refusal detection) normally require
their own OpenAI key. Instead, they are automatically backed by Giskard's default
generator (`giskard.checks.get_default_generator()`), so they run with the same
credentials as the rest of Giskard — no separate OpenAI key needed.
- **Detectors that need a third-party API key** you have not set (for example
`perspective.*`, which needs `PERSPECTIVE_API_KEY`) are **skipped** rather than
silently dropping the whole probe. Each skipped detector surfaces as a skip result
(`CheckResult.skip`) in the returned `SuiteResult`, with the missing key named in the
message, so the rest of the probe's detectors still run.
9 changes: 7 additions & 2 deletions libs/giskard-scan/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ dependencies = [
"huggingface-hub>=1.11.0,<2",
]

[project.optional-dependencies]
garak = ["garak>=0.15,<1"]


[build-system]
requires = ["hatchling>=1.25.0"]
build-backend = "hatchling.build"
Expand All @@ -28,12 +32,13 @@ include = [
]

[tool.pytest.ini_options]
testpaths = ["tests", "src"]
addopts = "--doctest-modules"
testpaths = ["tests"]
addopts = "--doctest-modules --ignore=src/giskard/scan/integrations/garak --ignore=src/giskard/scan/integrations/lidar"
asyncio_mode = "auto"
pythonpath = ["src"]
markers = [
"integration: integration tests that are skipped unless --run-integration is provided",
"functional: functional tests that exercise a real external tool (skipped when it is not installed)",
]

[tool.ruff.lint]
Expand Down
2 changes: 2 additions & 0 deletions libs/giskard-scan/src/giskard/scan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
SycophancyScenarioGenerator,
)
from .generators.prompt_injection import PromptInjectionScenarioGenerator
from .integrations import third_party_scan
from .quality import quality_scan, quality_suite_generator_registry
from .registry import SuiteGeneratorRegistry
from .types import ScanOptions
Expand Down Expand Up @@ -51,6 +52,7 @@
"SuiteGeneratorRegistry",
"quality_suite_generator_registry",
"quality_scan",
"third_party_scan",
"vulnerability_suite_generator_registry",
"vulnerability_scan",
]
57 changes: 57 additions & 0 deletions libs/giskard-scan/src/giskard/scan/_telemetry_props.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Aggregate, non-identifying properties for PostHog (no names, messages, or content)."""

from __future__ import annotations

from collections import Counter
from typing import Any

from .generators.base import ScenarioGenerator, TargetMode


def generator_type_counts(generators: list[ScenarioGenerator]) -> dict[str, int]:
return dict(Counter(type(generator).__name__ for generator in generators))
Comment on lines +17 to +18

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.

high

The generators list returned by the registries can contain both generator classes (e.g., HallucinationScenarioGenerator) and generator instances (e.g., GCGInjectionScenarioGenerator()). Calling type(generator).__name__ on a class returns "type" instead of the actual class name, which will corrupt the telemetry data.

We should check if the generator is a class (using isinstance(generator, type)) and use generator.__name__ in that case, falling back to type(generator).__name__ for instances.

def generator_type_counts(generators: list[ScenarioGenerator | type[ScenarioGenerator]]) -> dict[str, int]:
    return dict(
        Counter(
            generator.__name__ if isinstance(generator, type) else type(generator).__name__
            for generator in generators
        )
    )



def suite_scan_shape_properties(
*,
scan_kind: str,
language_count: int,
target_mode: TargetMode,
generator_count: int,
scenario_count: int,
generator_types: dict[str, int],
parallel: bool,
max_concurrency: int | None,
**extra: Any,
) -> dict[str, Any]:
return {
"integration": "giskard-scan",
"scan_kind": scan_kind,
"language_count": language_count,
"target_mode": target_mode,
"generator_count": generator_count,
"scenario_count": scenario_count,
"generator_types": generator_types,
"parallel": parallel,
"max_concurrency": max_concurrency,
**extra,
}


def third_party_scan_shape_properties(
*,
tool: str,
language_count: int | None,
target_mode: TargetMode,
has_probe_filter: bool,
has_tag_filter: bool,
) -> dict[str, Any]:
return {
"integration": "giskard-scan",
"scan_kind": f"third_party_{tool}",
"tool": tool,
"language_count": language_count,
"target_mode": target_mode,
"has_probe_filter": has_probe_filter,
"has_tag_filter": has_tag_filter,
}
18 changes: 18 additions & 0 deletions libs/giskard-scan/src/giskard/scan/integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Third-party scanner integrations for giskard.scan (experimental)."""

from typing import Any, Protocol

from giskard.checks import SuiteResult, Target, Trace

from ._entry_point import third_party_scan


class ScanAdapter(Protocol):
async def run[InputType, OutputType, TraceType: Trace]( # pyright: ignore[reportMissingTypeArgument]
self,
target: Target[InputType, OutputType, TraceType],
**kwargs: Any,
) -> SuiteResult: ...


__all__ = ["ScanAdapter", "third_party_scan"]
Loading
Loading