Skip to content
Draft
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
70 changes: 70 additions & 0 deletions .pyrit_conf_example
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# PyRIT Configuration File Example
# ================================
# Copy this file to ~/.pyrit/.pyrit_conf or specify a custom path when loading.
#
# 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.
#
# Example:
# initialization_scripts:
# - /path/to/my_custom_initializer.py
# - ./local_initializer.py
initialization_scripts: []

# Environment Files
# -----------------
# List of .env file paths to load during initialization.
# Later files override values from earlier files.
# If not specified, PyRIT loads ~/.pyrit/.env and ~/.pyrit/.env.local by default.
#
# Example:
# env_files:
# - /path/to/.env
# - /path/to/.env.local
env_files: []

# Silent Mode
# -----------
# If true, suppresses print statements during initialization.
# Useful for non-interactive environments or when embedding PyRIT in other tools.
silent: false
138 changes: 129 additions & 9 deletions pyrit/cli/frontend_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def cprint(text: str, color: str = None, attrs: list = None) -> None: # type: i
ScenarioMetadata,
ScenarioRegistry,
)
from pyrit.setup import ConfigurationLoader

logger = logging.getLogger(__name__)

Expand All @@ -66,31 +67,58 @@ 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[str] = 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
2. Explicit config_file argument if provided
3. Individual CLI arguments (database, initializers, etc.)

Args:
config_file: Optional path to a YAML configuration file.
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.

Raises:
ValueError: If database or log_level are invalid.
ValueError: If database or log_level are 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)
from pyrit.setup import ConfigurationLoader

# Load configuration from files and merge with CLI arguments
config = self._load_and_merge_config(
config_file=config_file,
database=database,
initialization_scripts=initialization_scripts,
initializer_names=initializer_names,
env_files=env_files,
)

# Store the merged configuration
self._config = config

# Extract values from config for internal use
# Map snake_case db type back to PascalCase for backward compatibility
db_type_map = {"in_memory": IN_MEMORY, "sqlite": SQLITE, "azure_sql": AZURE_SQL}
self._database = 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()

# Log level comes from CLI arg (not in config file), default to WARNING
effective_log_level = log_level if log_level is not None else "WARNING"
self._log_level = validate_log_level(log_level=effective_log_level)

# Lazy-loaded registries
self._scenario_registry: Optional[ScenarioRegistry] = None
Expand All @@ -100,6 +128,93 @@ def __init__(
# Configure logging
logging.basicConfig(level=getattr(logging, self._log_level))

def _load_and_merge_config(
self,
*,
config_file: Optional[Path],
database: Optional[str],
initialization_scripts: Optional[list[Path]],
initializer_names: Optional[list[str]],
env_files: Optional[list[Path]],
) -> "ConfigurationLoader":
"""
Load configuration from files and merge with CLI arguments.

Precedence (later overrides earlier):
1. Default config file (~/.pyrit/.pyrit_conf) if it exists
2. Explicit config_file argument if provided
3. Individual CLI arguments

Args:
config_file: Optional explicit config file path.
database: Optional database type from CLI.
initialization_scripts: Optional scripts from CLI.
initializer_names: Optional initializer names from CLI.
env_files: Optional env files from CLI.

Returns:
Merged ConfigurationLoader instance.
"""
from pyrit.setup import ConfigurationLoader

# Start with defaults
config_data: dict = {
"memory_db_type": "sqlite",
"initializers": [],
"initialization_scripts": [],
"env_files": [],
}

# 1. Try loading default config file if it exists
default_config_path = ConfigurationLoader.get_default_config_path()
if default_config_path.exists():
try:
default_config = ConfigurationLoader.from_yaml_file(default_config_path)
config_data["memory_db_type"] = default_config.memory_db_type
config_data["initializers"] = [
{"name": ic.name, "args": ic.args} if ic.args else ic.name
for ic in default_config._initializer_configs
]
config_data["initialization_scripts"] = default_config.initialization_scripts
config_data["env_files"] = default_config.env_files
except Exception as e:
logger.warning(f"Failed to load default config file {default_config_path}: {e}")

# 2. Load explicit config file if provided (overrides default)
if config_file is not None:
if not config_file.exists():
raise FileNotFoundError(f"Configuration file not found: {config_file}")
explicit_config = ConfigurationLoader.from_yaml_file(config_file)
config_data["memory_db_type"] = explicit_config.memory_db_type
config_data["initializers"] = [
{"name": ic.name, "args": ic.args} if ic.args else ic.name
for ic in explicit_config._initializer_configs
]
config_data["initialization_scripts"] = explicit_config.initialization_scripts
config_data["env_files"] = explicit_config.env_files

# 3. Apply CLI overrides (non-None values take precedence)
if database is not None:
# Normalize to snake_case for ConfigurationLoader
normalized_db = database.lower().replace("-", "_")
# Handle PascalCase inputs
if normalized_db == "inmemory":
normalized_db = "in_memory"
elif normalized_db == "azuresql":
normalized_db = "azure_sql"
config_data["memory_db_type"] = normalized_db

if initialization_scripts is not None:
config_data["initialization_scripts"] = [str(p) for p in initialization_scripts]

if initializer_names is not None:
config_data["initializers"] = initializer_names

if env_files is not None:
config_data["env_files"] = [str(p) for p in env_files]

return ConfigurationLoader.from_dict(config_data)

async def initialize_async(self) -> None:
"""Initialize PyRIT and load registries (heavy operation)."""
if self._initialized:
Expand Down Expand Up @@ -734,6 +849,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
26 changes: 22 additions & 4 deletions pyrit/cli/pyrit_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import asyncio
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 +35,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,6 +49,12 @@ 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,
Expand Down Expand Up @@ -176,12 +186,14 @@ def main(args: Optional[list[str]] = None) -> int:
env_files = None
if parsed_args.env_files:
try:
env_files = frontend_core.resolve_env_files(env_file_paths=parsed_args.env_files)
env_files = frontend_core.resolve_env_files(
env_file_paths=parsed_args.env_files)
except ValueError as e:
print(f"Error: {e}")
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 @@ -214,10 +229,12 @@ def main(args: Optional[list[str]] = None) -> int:
# Collect environment files
env_files = None
if parsed_args.env_files:
env_files = frontend_core.resolve_env_files(env_file_paths=parsed_args.env_files)
env_files = frontend_core.resolve_env_files(
env_file_paths=parsed_args.env_files)

# 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 All @@ -228,7 +245,8 @@ def main(args: Optional[list[str]] = None) -> int:
# Parse memory labels if provided
memory_labels = None
if parsed_args.memory_labels:
memory_labels = frontend_core.parse_memory_labels(json_string=parsed_args.memory_labels)
memory_labels = frontend_core.parse_memory_labels(
json_string=parsed_args.memory_labels)

# Run scenario
asyncio.run(
Expand Down
Loading
Loading