Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions .devcontainer/devcontainer_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ set -e

MYPY_CACHE="/workspace/.mypy_cache"
VIRTUAL_ENV="/opt/venv"
PYRIT_CONFIG_DIR="/home/vscode/.pyrit"
Copy link
Contributor

@rlundeen2 rlundeen2 Feb 6, 2026

Choose a reason for hiding this comment

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

Should this be ~/.pyrit?

Copy link
Contributor

Choose a reason for hiding this comment

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

In the devcontainer that is the home directory.

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense, but I guess wondering if using ~ is more generic for diff containers.

Copy link
Contributor Author

@ValbuenaVC ValbuenaVC Feb 6, 2026

Choose a reason for hiding this comment

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

In my experience this tends to fail given that ~ can alias to home for the root user during the build process. But I don't know enough about our Docker build pipeline to confirm it

PYRIT_CONFIG_FILE="$PYRIT_CONFIG_DIR/.pyrit_conf"

# Create the .pyrit config directory and copy example config if not exists
if [ ! -d "$PYRIT_CONFIG_DIR" ]; then
Copy link
Contributor

Choose a reason for hiding this comment

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

it might be nice to have someone else weigh in on this. I'm hesitant about auto copying things over like this though. @romanlutz who is working on docker things

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm confused. What I would expect to happen is that my directory at ~/.pyrit shows up at ~/.pyrit inside the container which is /home/vscode/.pyrit.

What is the copying logic meant to copy? The example file? What would that be useful for?

Copy link
Contributor

Choose a reason for hiding this comment

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

Right now it's also copying over the example if the file doesn't exist which probably isn't useful like you mention.

Would it make sense to copy over ~/.pyrit directory? Including the .env files there?

Copy link
Contributor Author

@ValbuenaVC ValbuenaVC Feb 6, 2026

Choose a reason for hiding this comment

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

What would be the most intuitive way to handle discovery and population of the config file in the Docker image? I'm not sure I understand Roman's original request to make sure the file is in the devcontainer, since if the example file is in the git repo then when the Docker image clones and builds it should be included. Is there something I'm missing?

Copy link
Contributor Author

@ValbuenaVC ValbuenaVC Feb 6, 2026

Choose a reason for hiding this comment

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

I feel like we have two main options:

  1. Leave .pyrit_conf_example and ask users to create .pyrit_conf using it like we do with our .env file(s)
  2. Deliberately create a .pyrit_conf file with certain values from the example as a template.
    I'm in favor of the first given that it's more consistent with how we do environment variables but as I said I think I'm misunderstanding the premise

Copy link
Contributor

Choose a reason for hiding this comment

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

Example file is with the code but not in home directory / .pyrit. That is where it looks for env files, not in the repo. That's why the copying over to .pyrit is odd IMO

echo "Creating PyRIT config directory..."
mkdir -p "$PYRIT_CONFIG_DIR"
fi

if [ ! -f "$PYRIT_CONFIG_FILE" ] && [ -f "/workspace/.pyrit_conf_example" ]; then
echo "Copying example PyRIT config file..."
cp /workspace/.pyrit_conf_example "$PYRIT_CONFIG_FILE"
echo "✅ Created $PYRIT_CONFIG_FILE from example. Edit as needed."
fi

# Create the mypy cache directory if it doesn't exist
if [ ! -d "$MYPY_CACHE" ]; then
echo "Creating mypy cache directory..."
Expand Down
78 changes: 78 additions & 0 deletions .pyrit_conf_example
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# PyRIT Configuration File Example
# ================================
# This is a YAML-formatted configuration file. Copy to ~/.pyrit/.pyrit_conf
# or specify a custom path when loading via --config-file.
#
# For documentation on configuration options, see:
# https://github.com/Azure/PyRIT/blob/main/doc/setup/configuration.md

# Memory Database Type
# --------------------
# Specifies which database backend to use for storing prompts and results.
# Options: in_memory, sqlite, azure_sql (case-insensitive)
# - in_memory: Temporary in-memory database (data lost on exit)
# - sqlite: Persistent local SQLite database (default)
# - azure_sql: Azure SQL database (requires connection string in env vars)
memory_db_type: sqlite

# Initializers
# ------------
# List of built-in initializers to run during PyRIT initialization.
# Initializers configure default values for converters, scorers, and targets.
# Names are normalized to snake_case (e.g., "SimpleInitializer" -> "simple").
#
# Available initializers:
# - simple: Basic OpenAI configuration (requires OPENAI_CHAT_* env vars)
# - airt: AI Red Team setup with Azure OpenAI (requires AZURE_OPENAI_* env vars)
# - load_default_datasets: Loads default datasets for all registered scenarios
# - objective_list: Sets default objectives for scenarios
# - openai_objective_target: Sets up OpenAI target for scenarios
#
# Each initializer can be specified as:
# - A simple string (name only)
# - A dictionary with 'name' and optional 'args' for constructor arguments
#
# Example:
# initializers:
# - simple
# - name: airt
# args:
# some_param: value
initializers:
- simple

# Initialization Scripts
# ----------------------
# List of paths to custom Python scripts containing PyRITInitializer subclasses.
# Paths can be absolute or relative to the current working directory.
#
# Behavior:
# - Omit this field (or set to null): No custom scripts loaded (default)
# - Set to []: Explicitly load no scripts (same as omitting)
# - Set to list of paths: Load the specified scripts
#
# Example:
# initialization_scripts:
# - /path/to/my_custom_initializer.py
# - ./local_initializer.py

# Environment Files
# -----------------
# List of .env file paths to load during initialization.
# Later files override values from earlier files.
#
# Behavior:
# - Omit this field (or set to null): Load default .env and .env.local from ~/.pyrit/ if they exist
# - Set to []: Explicitly load NO environment files
# - Set to list of paths: Load only the specified files
#
# Example:
# env_files:
# - /path/to/.env
# - /path/to/.env.local

# Silent Mode
# -----------
# If true, suppresses print statements during initialization.
# Useful for non-interactive environments or when embedding PyRIT in other tools.
silent: false
76 changes: 60 additions & 16 deletions pyrit/cli/frontend_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence

from pyrit.setup import ConfigurationLoader
from pyrit.setup.configuration_loader import _MEMORY_DB_TYPE_MAP

try:
import termcolor

Expand Down Expand Up @@ -66,39 +69,74 @@ class FrontendCore:
def __init__(
self,
*,
database: str = SQLITE,
config_file: Optional[Path] = None,
database: Optional[str] = None,
initialization_scripts: Optional[list[Path]] = None,
initializer_names: Optional[list[str]] = None,
env_files: Optional[list[Path]] = None,
log_level: str = "WARNING",
log_level: Optional[int] = None,
):
"""
Initialize PyRIT context.

Configuration is loaded in the following order (later values override earlier):
1. Default config file (~/.pyrit/.pyrit_conf) if it exists
Copy link
Contributor

Choose a reason for hiding this comment

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

Make sure to update devcontainer configuration to get that file into the devcontainer (if it isn't already).

Copy link
Contributor Author

@ValbuenaVC ValbuenaVC Feb 6, 2026

Choose a reason for hiding this comment

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

It should be by default, but an explicit check has been added to the devcontainer shell script, as well as explicit creation of .pyrit_conf from .pyrit_conf_example.

2. Explicit config_file argument if provided
3. Individual CLI arguments (database, initializers, etc.)

Args:
config_file: Optional path to a YAML-formatted configuration file.
The file uses .pyrit_conf extension but is YAML format.
database: Database type (InMemory, SQLite, or AzureSQL).
initialization_scripts: Optional list of initialization script paths.
initializer_names: Optional list of built-in initializer names to run.
env_files: Optional list of environment file paths to load in order.
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Defaults to WARNING.
log_level: Logging level constant (e.g., logging.WARNING). Defaults to logging.WARNING.

Raises:
ValueError: If database or log_level are invalid.
ValueError: If database is invalid, or if config file is invalid.
FileNotFoundError: If an explicitly specified config_file does not exist.
"""
# Validate inputs
self._database = validate_database(database=database)
self._initialization_scripts = initialization_scripts
self._initializer_names = initializer_names
self._env_files = env_files
self._log_level = validate_log_level(log_level=log_level)
# Use provided log level or default to WARNING
self._log_level = log_level if log_level is not None else logging.WARNING

# Load configuration using ConfigurationLoader.load_with_overrides
try:
config = ConfigurationLoader.load_with_overrides(
config_file=config_file,
memory_db_type=database,
initializers=initializer_names,
initialization_scripts=[str(p) for p in initialization_scripts] if initialization_scripts else None,
env_files=[str(p) for p in env_files] if env_files else None,
)
except ValueError as e:
# Re-raise with user-friendly message for CLI users
error_msg = str(e)
if "memory_db_type" in error_msg:
raise ValueError(
f"Invalid database type '{database}'. Must be one of: InMemory, SQLite, AzureSQL"
) from e
raise

# Store the merged configuration
self._config = config

# Extract values from config for internal use
# Use canonical mapping from configuration_loader
self._database = _MEMORY_DB_TYPE_MAP[config.memory_db_type]
self._initialization_scripts = config._resolve_initialization_scripts()
self._initializer_names = (
[ic.name for ic in config._initializer_configs] if config._initializer_configs else None
)
self._env_files = config._resolve_env_files()

# Lazy-loaded registries
self._scenario_registry: Optional[ScenarioRegistry] = None
self._initializer_registry: Optional[InitializerRegistry] = None
self._initialized = False

# Configure logging
logging.basicConfig(level=getattr(logging, self._log_level))
logging.basicConfig(level=self._log_level)

async def initialize_async(self) -> None:
"""Initialize PyRIT and load registries (heavy operation)."""
Expand Down Expand Up @@ -462,15 +500,15 @@ def validate_database(*, database: str) -> str:
return database


def validate_log_level(*, log_level: str) -> str:
def validate_log_level(*, log_level: str) -> int:
"""
Validate log level.
Validate log level and convert to logging constant.

Args:
log_level: Log level string (case-insensitive).

Returns:
Validated log level in uppercase.
Validated log level as logging constant (e.g., logging.WARNING).

Raises:
ValueError: If log level is invalid.
Expand All @@ -479,7 +517,8 @@ def validate_log_level(*, log_level: str) -> str:
level_upper = log_level.upper()
if level_upper not in valid_levels:
raise ValueError(f"Invalid log level: {log_level}. Must be one of: {', '.join(valid_levels)}")
return level_upper
level_value: int = getattr(logging, level_upper)
return level_value


def validate_integer(value: str, *, name: str = "value", min_value: Optional[int] = None) -> int:
Expand Down Expand Up @@ -734,6 +773,11 @@ async def print_initializers_list_async(*, context: FrontendCore, discovery_path

# Shared argument help text
ARG_HELP = {
"config_file": (
"Path to a YAML configuration file. Allows specifying database, initializers (with args), "
"initialization scripts, and env files. CLI arguments override config file values. "
"If not specified, ~/.pyrit/.pyrit_conf is loaded if it exists."
),
"initializers": "Built-in initializer names to run before the scenario (e.g., openai_objective_target)",
"initialization_scripts": "Paths to custom Python initialization scripts to run before the scenario",
"env_files": "Paths to environment files to load in order (e.g., .env.production .env.local). Later files "
Expand Down Expand Up @@ -768,7 +812,7 @@ def parse_run_arguments(*, args_string: str) -> dict[str, Any]:
- max_retries: Optional[int]
- memory_labels: Optional[dict[str, str]]
- database: Optional[str]
- log_level: Optional[str]
- log_level: Optional[int]
- dataset_names: Optional[list[str]]
- max_dataset_size: Optional[int]

Expand Down
20 changes: 18 additions & 2 deletions pyrit/cli/pyrit_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
"""

import asyncio
import logging
import sys
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
from pathlib import Path
from typing import Optional

from pyrit.cli import frontend_core
Expand All @@ -34,6 +36,9 @@ def parse_args(args: Optional[list[str]] = None) -> Namespace:
# Run a scenario with built-in initializers
pyrit_scan foundry --initializers openai_objective_target load_default_datasets

# Run with a configuration file (recommended for complex setups)
pyrit_scan foundry --config-file ./my_config.yaml

# Run with custom initialization scripts
pyrit_scan garak.encoding --initialization-scripts ./my_config.py

Expand All @@ -45,10 +50,16 @@ def parse_args(args: Optional[list[str]] = None) -> Namespace:
formatter_class=RawDescriptionHelpFormatter,
)

parser.add_argument(
"--config-file",
type=Path,
help=frontend_core.ARG_HELP["config_file"],
)

parser.add_argument(
"--log-level",
type=frontend_core.validate_log_level_argparse,
default="WARNING",
default=logging.WARNING,
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) (default: WARNING)",
)

Expand Down Expand Up @@ -182,6 +193,7 @@ def main(args: Optional[list[str]] = None) -> int:
return 1

context = frontend_core.FrontendCore(
config_file=parsed_args.config_file,
database=parsed_args.database,
initialization_scripts=initialization_scripts,
env_files=env_files,
Expand All @@ -194,7 +206,10 @@ def main(args: Optional[list[str]] = None) -> int:
# Discover from scenarios directory
scenarios_path = frontend_core.get_default_initializer_discovery_path()

context = frontend_core.FrontendCore(log_level=parsed_args.log_level)
context = frontend_core.FrontendCore(
config_file=parsed_args.config_file,
log_level=parsed_args.log_level,
)
return asyncio.run(frontend_core.print_initializers_list_async(context=context, discovery_path=scenarios_path))

# Verify scenario was provided
Expand All @@ -218,6 +233,7 @@ def main(args: Optional[list[str]] = None) -> int:

# Create context with initializers
context = frontend_core.FrontendCore(
config_file=parsed_args.config_file,
database=parsed_args.database,
initialization_scripts=initialization_scripts,
initializer_names=parsed_args.initializers,
Expand Down
20 changes: 14 additions & 6 deletions pyrit/cli/pyrit_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
import cmd
import sys
import threading
from typing import TYPE_CHECKING
from pathlib import Path
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
from pyrit.models.scenario_result import ScenarioResult
Expand Down Expand Up @@ -98,7 +99,7 @@ def __init__(
super().__init__()
self.context = context
self.default_database = context._database
self.default_log_level = context._log_level
self.default_log_level: Optional[int] = context._log_level
self.default_env_files = context._env_files

# Track scenario execution history: list of (command_string, ScenarioResult) tuples
Expand Down Expand Up @@ -217,24 +218,24 @@ def do_run(self, line: str) -> None:
return

# Resolve env files if provided
resolved_env_files = None
resolved_env_files: Optional[list[Path]] = None
if args["env_files"]:
try:
resolved_env_files = frontend_core.resolve_env_files(env_file_paths=args["env_files"])
resolved_env_files = list(frontend_core.resolve_env_files(env_file_paths=args["env_files"]))
except ValueError as e:
print(f"Error: {e}")
return
else:
# Use default env files from shell startup
resolved_env_files = self.default_env_files
resolved_env_files = list(self.default_env_files) if self.default_env_files else None

# Create a context for this run with overrides
run_context = frontend_core.FrontendCore(
database=args["database"] or self.default_database,
initialization_scripts=resolved_scripts,
initializer_names=args["initializers"],
env_files=resolved_env_files,
log_level=args["log_level"] or self.default_log_level,
log_level=args["log_level"] if args["log_level"] else self.default_log_level,
)
# Use the existing registries (don't reinitialize)
run_context._scenario_registry = self.context._scenario_registry
Expand Down Expand Up @@ -453,6 +454,12 @@ def main() -> int:
description="PyRIT Interactive Shell - Load modules once, run commands instantly",
)

parser.add_argument(
"--config-file",
type=Path,
help=frontend_core.ARG_HELP["config_file"],
)

parser.add_argument(
"--database",
choices=[frontend_core.IN_MEMORY, frontend_core.SQLITE, frontend_core.AZURE_SQL],
Expand Down Expand Up @@ -488,6 +495,7 @@ def main() -> int:

# Create context (initializers are specified per-run, not at startup)
context = frontend_core.FrontendCore(
config_file=args.config_file,
database=args.database,
initialization_scripts=None,
initializer_names=None,
Expand Down
4 changes: 4 additions & 0 deletions pyrit/common/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ def in_git_repo() -> bool:

CONFIGURATION_DIRECTORY_PATH = pathlib.Path.home() / ".pyrit"

# Default configuration file name and path
DEFAULT_CONFIG_FILENAME = ".pyrit_conf"
DEFAULT_CONFIG_PATH = CONFIGURATION_DIRECTORY_PATH / DEFAULT_CONFIG_FILENAME

# Points to the root of the project
HOME_PATH = pathlib.Path(PYRIT_PATH, "..").resolve()

Expand Down
Loading