Skip to content
34 changes: 33 additions & 1 deletion .github/instructions/docs.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,34 @@ When making changes:

## Jupytext Usage Reference

Generate .ipynb from .py (with execution):
### Critical pre-execution checklist

Before running `jupytext --execute`, make sure the kernel will exercise *the code in this checkout*, not some stale install:

1. **Use a kernel bound to a Python env that has this worktree installed editable.**
Reusing an existing `pyrit` kernel is fine *only if* it points at the current
checkout — otherwise it will resolve imports against an unrelated copy and
either pass on stale code or fail on missing new symbols.
- Quick check: `python -c "import pyrit, pathlib; print(pathlib.Path(pyrit.__file__).resolve())"`
- If it doesn't match this worktree, install editable here: `pip install -e .`
(this rebinds the existing kernel to this checkout, no new kernel needed).
- Only create a new kernel (`python -m ipykernel install --user --name <name>`)
if you actually need an isolated env.
2. **Credentials must be pre-configured.** Most notebooks call live targets
(OpenAI, Azure, etc.) and load creds from `~/.pyrit/.env`. Make sure the
required keys are present before executing.

### Keep the cell outputs

**Do not strip cell outputs from notebooks under `doc/`.** Outputs are part of the
documentation — readers rely on seeing rendered tables, images, and printer output.
If a notebook can't execute end-to-end, that is exactly the regression we want
to surface in review; don't paper over it by committing an output-less notebook.
`nbstripout` is intentionally not run against `doc/` content for this reason.

### Commands

Generate .ipynb from .py (with execution — preferred):
```bash
jupytext --to ipynb --execute doc/path/to/your_notebook.py
```
Expand All @@ -56,6 +83,11 @@ Generate .py from .ipynb:
jupytext --to py:percent doc/path/to/notebook.ipynb
```

Sync structure only without executing (rarely correct — outputs will be empty):
```bash
jupytext --to ipynb doc/path/to/your_notebook.py
```

## Summary
- **Default strategy**: Update both files inline for simple changes
- **Be cautious and deliberate**: Out-of-sync files are worse than slow regeneration
Expand Down
279 changes: 234 additions & 45 deletions doc/code/output/0_output.ipynb

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions doc/code/output/0_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,51 @@
# %%
await output_attack_async(attack_result, format="markdown")

# %% [markdown]
# ### Blurring Images
#
# When an attack uses image converters or targets that return images, the rendered
# output can include payloads you may not want to look at directly during review.
# Pass `blur_images=True` to apply a Gaussian blur before rendering. The original
# image file is **not** modified — this is a reviewer-exposure knob, not access
# control.
#
# * In `pretty` output the blur is applied in-memory before display.
# * In `markdown` output a blurred copy is written to disk and the markdown links
# to it instead of the original. Pass `blurred_dir` to redirect those copies
# out of the source tree.
# * If blurring fails for any reason, a warning is logged and a plain-text link
# to the original is emitted (rather than silently rendering the unblurred image).
# * Tune the strength with `blur_radius` (default 20).
#
# To demonstrate, we'll run a quick attack against an image target so the result
# contains a real image, then print it with and without blurring.

# %%
import os

from pyrit.auth import get_azure_openai_auth
from pyrit.prompt_target import OpenAIImageTarget

image_endpoint = os.environ["OPENAI_IMAGE_ENDPOINT"]
image_target = OpenAIImageTarget(
endpoint=image_endpoint,
api_key=get_azure_openai_auth(image_endpoint),
output_format="jpeg",
)

image_attack = PromptSendingAttack(objective_target=image_target)
image_result = await image_attack.execute_async( # type: ignore
objective="Give me a picture of a raccoon pirate as a Spanish baker in Spain"
)

# Without blurring — the image renders normally
await output_attack_async(image_result, format="markdown")

# %%
# With blurring — the markdown links to a blurred copy on disk
await output_attack_async(image_result, format="markdown", blur_images=True, blur_radius=25)

# %% [markdown]
# ## Printing Conversations Directly
#
Expand Down
41 changes: 41 additions & 0 deletions pyrit/output/_image_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""
Internal image utilities for the output module.

Used by pretty and markdown conversation printers to apply a Gaussian blur
to images before they are displayed to a reviewer (the ``blur_images`` flag).
"""

import io
import logging

logger = logging.getLogger(__name__)


def blur_image_bytes(*, image_bytes: bytes, radius: int = 20) -> bytes:
"""
Apply a Gaussian blur to the given image bytes and return blurred PNG bytes.

Args:
image_bytes (bytes): The original encoded image bytes.
radius (int): The Gaussian blur radius. Larger values blur more.
Defaults to 20.

Returns:
bytes: The blurred image encoded as PNG. If blurring fails for any reason,
returns the original ``image_bytes`` unchanged and logs a warning.
"""
try:
from PIL import Image, ImageFilter

with Image.open(io.BytesIO(image_bytes)) as image:
image.load()
blurred = image.filter(ImageFilter.GaussianBlur(radius=radius))
buffer = io.BytesIO()
blurred.save(buffer, format="PNG")
return buffer.getvalue()
except Exception as exc:
logger.warning(f"Failed to blur image (radius={radius}); returning original bytes. Error: {exc}")
return image_bytes
38 changes: 36 additions & 2 deletions pyrit/output/attack_result/markdown.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import os
from datetime import datetime, timezone

from pyrit.common.deprecation import print_deprecation_message
Expand All @@ -26,6 +27,9 @@ def __init__(
display_inline: bool = True,
conversation_printer: MarkdownConversationPrinter | None = None,
score_printer: MarkdownScorePrinter | None = None,
blur_images: bool = False,
blur_radius: int = 20,
blurred_dir: str | os.PathLike[str] | None = None,
) -> None:
"""
Initialize the markdown printer.
Expand All @@ -38,13 +42,23 @@ def __init__(
Defaults to a new MarkdownConversationPrinter with matching sink.
score_printer (MarkdownScorePrinter | None): Score printer.
Defaults to a new MarkdownScorePrinter with matching sink.
blur_images (bool): If True, write a blurred copy of each referenced
image and link to it instead of the original. Forwarded to the default
conversation printer when one is not supplied. Defaults to False.
blur_radius (int): Gaussian blur radius applied when ``blur_images`` is True.
Defaults to 20.
blurred_dir (str | PathLike | None): Directory to write blurred copies into
Comment thread
rlundeen2 marked this conversation as resolved.
when ``blur_images`` is True. Defaults to None (sibling of the original).
"""
super().__init__(sink=sink)
self._display_inline = display_inline
self._score_printer = score_printer or MarkdownScorePrinter(sink=sink)
self._conversation_printer = conversation_printer or MarkdownConversationPrinter(
sink=sink,
score_printer=self._score_printer,
blur_images=blur_images,
blur_radius=blur_radius,
blurred_dir=blurred_dir,
)

async def render_async(
Expand Down Expand Up @@ -322,20 +336,40 @@ class MarkdownAttackResultMemoryPrinter(MarkdownAttackResultPrinter):
All formatting logic lives in MarkdownAttackResultPrinter.
"""

def __init__(self, *, sink: Sink | None = None, display_inline: bool = True) -> None:
def __init__(
self,
*,
sink: Sink | None = None,
display_inline: bool = True,
blur_images: bool = False,
blur_radius: int = 20,
blurred_dir: str | os.PathLike[str] | None = None,
) -> None:
"""
Initialize the markdown printer with CentralMemory data source.

Args:
sink (Sink | None): Output sink. Defaults to StdoutSink().
display_inline (bool): Kept for backward compatibility but unused.
All output is routed through the sink. Defaults to True.
blur_images (bool): If True, write a blurred copy of each referenced
image and link to it instead of the original. Defaults to False.
blur_radius (int): Gaussian blur radius applied when ``blur_images`` is True.
Defaults to 20.
blurred_dir (str | PathLike | None): Directory to write blurred copies into.
Defaults to None (sibling of the original).
"""
from pyrit.memory import CentralMemory
from pyrit.output.conversation.markdown import MarkdownConversationMemoryPrinter

score_printer = MarkdownScorePrinter(sink=sink)
conversation_printer = MarkdownConversationMemoryPrinter(sink=sink, score_printer=score_printer)
conversation_printer = MarkdownConversationMemoryPrinter(
sink=sink,
score_printer=score_printer,
blur_images=blur_images,
blur_radius=blur_radius,
blurred_dir=blurred_dir,
)
super().__init__(
sink=sink,
display_inline=display_inline,
Expand Down
24 changes: 23 additions & 1 deletion pyrit/output/attack_result/pretty.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def __init__(
enable_colors: bool = True,
conversation_printer: PrettyConversationPrinter | None = None,
score_printer: PrettyScorePrinter | None = None,
blur_images: bool = False,
blur_radius: int = 20,
) -> None:
"""
Initialize the pretty printer.
Expand All @@ -44,6 +46,11 @@ def __init__(
Defaults to a new PrettyConversationPrinter with matching settings.
score_printer (PrettyScorePrinter | None): Score printer.
Defaults to a new PrettyScorePrinter with matching settings.
blur_images (bool): If True, apply a Gaussian blur to image outputs before
displaying them. Forwarded to the default conversation printer when one
is not supplied. Defaults to False.
blur_radius (int): Gaussian blur radius applied when ``blur_images`` is True.
Defaults to 20.
"""
super().__init__(sink=sink)
self._width = width
Expand All @@ -58,6 +65,8 @@ def __init__(
indent_size=indent_size,
enable_colors=enable_colors,
score_printer=self._score_printer,
blur_images=blur_images,
blur_radius=blur_radius,
)

def _format_colored(self, text: str, *colors: str) -> str:
Expand Down Expand Up @@ -449,7 +458,14 @@ class PrettyAttackResultMemoryPrinter(PrettyAttackResultPrinter):
"""

def __init__(
self, *, sink: Sink | None = None, width: int = 100, indent_size: int = 2, enable_colors: bool = True
self,
*,
sink: Sink | None = None,
width: int = 100,
indent_size: int = 2,
enable_colors: bool = True,
blur_images: bool = False,
blur_radius: int = 20,
) -> None:
"""
Initialize the pretty printer with CentralMemory data source.
Expand All @@ -459,6 +475,10 @@ def __init__(
width (int): Maximum width for text wrapping. Defaults to 100.
indent_size (int): Number of spaces for indentation. Defaults to 2.
enable_colors (bool): Whether to enable ANSI color output. Defaults to True.
blur_images (bool): If True, apply a Gaussian blur to image outputs before
displaying them. Defaults to False.
blur_radius (int): Gaussian blur radius applied when ``blur_images`` is True.
Defaults to 20.
"""
from pyrit.memory import CentralMemory
from pyrit.output.conversation.pretty import PrettyConversationMemoryPrinter
Expand All @@ -470,6 +490,8 @@ def __init__(
indent_size=indent_size,
enable_colors=enable_colors,
score_printer=score_printer,
blur_images=blur_images,
blur_radius=blur_radius,
)
super().__init__(
sink=sink,
Expand Down
Loading
Loading