Skip to content

Commit 485be33

Browse files
authored
MAINT CONTROVERSIAL: Make env files configurable (#1253)
1 parent 12397c8 commit 485be33

10 files changed

Lines changed: 380 additions & 56 deletions

File tree

.env_example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# This is an example of the .env file. Copy to .env and fill in your secrets.
1+
# This is an example of the .env file. Copy to ~/.pyrit/.env and fill in your endpoint configurations.
22
# Note that if you are using Entra authentication for certain Azure resources (use_entra_auth = True in PyRIT),
33
# keys for those resources are not needed.
44

doc/setup/populating_secrets.md

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,33 @@ With this setup, you can run most PyRIT notebooks and examples!
2121

2222
## Setting Up Environment Variables
2323

24-
PyRIT loads secrets and endpoints from environment variables or a `.env` file in your repo root. The `.env_example` file shows the format and available options.
24+
PyRIT loads secrets and endpoints from environment variables or `.env` files. The `.env_example` file shows the format and available options.
25+
26+
### Environment Variable Precedence
27+
28+
When `initialize_pyrit_async` runs, environment variables are loaded in a specific order. **Later sources override earlier ones:**
29+
30+
```{mermaid}
31+
flowchart LR
32+
A["1. System Environment"] --> B{"env_files provided?"}
33+
B -->|No| C["2. ~/.pyrit/.env"]
34+
C --> D["3. ~/.pyrit/.env.local"]
35+
B -->|Yes| E["2. Your specified files (in order)"]
36+
```
37+
38+
**Default behavior** (no `env_files` argument):
39+
40+
| Priority | Source | Description |
41+
|----------|--------|-------------|
42+
| Lowest | System environment variables | Always loaded as the baseline |
43+
| Medium | `~/.pyrit/.env` | Default config file (loaded if it exists) |
44+
| Highest | `~/.pyrit/.env.local` | Local overrides (loaded if it exists) |
45+
46+
**Custom behavior** (with `env_files` argument): Only your specified files are loaded, in order. Default paths are completely ignored.
2547

2648
### Creating Your .env File
2749

28-
1. Copy `.env_example` to `.env` in your repository root
50+
1. Copy `.env_example` to `.env` in your home directory in ~/.pyrit/.env
2951
2. Add your API credentials. For example, for Azure OpenAI:
3052

3153
```bash
@@ -37,12 +59,33 @@ To find these values in Azure Portal: `Azure Portal > Azure AI Services > Azure
3759

3860
### Using .env.local for Overrides
3961

40-
You can use `.env.local` to override values in `.env` without modifying the base file. This is useful for:
62+
You can use `~/.pyrit/.env.local` to override values in `~/.pyrit/.env` without modifying the base file. This is useful for:
4163
- Testing different targets
4264
- Using personal credentials instead of shared ones
4365
- Switching between configurations quickly
4466

45-
Simply create `.env.local` and add any variables you want to override. PyRIT will prioritize `.env.local` over `.env`.
67+
Simply create `.env.local` in your `~/.pyrit/` directory and add any variables you want to override.
68+
69+
### Custom Environment Files
70+
71+
You can also specify exactly which `.env` files to load using the `env_files` parameter:
72+
73+
```python
74+
from pathlib import Path
75+
from pyrit.setup import initialize_pyrit_async
76+
77+
await initialize_pyrit_async(
78+
memory_db_type="InMemory",
79+
env_files=[Path("./project-config.env"), Path("./local-overrides.env")]
80+
)
81+
```
82+
83+
When `env_files` is provided:
84+
- **Only** the specified files are loaded (default paths are skipped entirely)
85+
- Files are loaded in order—later files override earlier ones
86+
- A `ValueError` is raised if any specified file doesn't exist
87+
88+
The CLI also supports custom environment files via the `--env-files` flag.
4689

4790
## Authentication Options
4891

pyrit/auxiliary_attacks/gcg/experiments/run.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def run_trainer(*, model_name: str, setup: str = "single", **extra_config_parame
3939
"Model name not supported. Currently supports 'mistral', 'llama_2', 'llama_3', 'vicuna', and 'phi_3_mini'"
4040
)
4141

42-
_load_environment_files()
42+
_load_environment_files(env_files=None)
4343
hf_token = os.environ.get("HUGGINGFACE_TOKEN")
4444
if not hf_token:
4545
raise ValueError("Please set the HUGGINGFACE_TOKEN environment variable")

pyrit/cli/frontend_core.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def __init__(
7777
database: str = SQLITE,
7878
initialization_scripts: Optional[list[Path]] = None,
7979
initializer_names: Optional[list[str]] = None,
80+
env_files: Optional[list[Path]] = None,
8081
log_level: str = "WARNING",
8182
):
8283
"""
@@ -86,6 +87,7 @@ def __init__(
8687
database: Database type (InMemory, SQLite, or AzureSQL).
8788
initialization_scripts: Optional list of initialization script paths.
8889
initializer_names: Optional list of built-in initializer names to run.
90+
env_files: Optional list of environment file paths to load in order.
8991
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). Defaults to WARNING.
9092
9193
Raises:
@@ -95,6 +97,7 @@ def __init__(
9597
self._database = validate_database(database=database)
9698
self._initialization_scripts = initialization_scripts
9799
self._initializer_names = initializer_names
100+
self._env_files = env_files
98101
self._log_level = validate_log_level(log_level=log_level)
99102

100103
# Lazy-loaded registries
@@ -119,6 +122,7 @@ async def initialize_async(self) -> None:
119122
memory_db_type=self._database,
120123
initialization_scripts=None,
121124
initializers=None,
125+
env_files=self._env_files,
122126
)
123127

124128
# Load registries
@@ -259,6 +263,7 @@ async def run_scenario_async(
259263
memory_db_type=context._database,
260264
initialization_scripts=context._initialization_scripts,
261265
initializers=initializer_instances,
266+
env_files=context._env_files,
262267
)
263268

264269
# Get scenario class
@@ -557,6 +562,46 @@ def wrapper(value):
557562
return wrapper
558563

559564

565+
def resolve_initialization_scripts(script_paths: list[str]) -> list[Path]:
566+
"""
567+
Resolve initialization script paths.
568+
569+
Args:
570+
script_paths: List of script path strings.
571+
572+
Returns:
573+
List of resolved Path objects.
574+
575+
Raises:
576+
FileNotFoundError: If a script path does not exist.
577+
"""
578+
from pyrit.cli.initializer_registry import InitializerRegistry
579+
580+
return InitializerRegistry.resolve_script_paths(script_paths=script_paths)
581+
582+
583+
def resolve_env_files(*, env_file_paths: list[str]) -> list[Path]:
584+
"""
585+
Resolve environment file paths to absolute Path objects.
586+
587+
Args:
588+
env_file_paths: List of environment file path strings.
589+
590+
Returns:
591+
List of resolved Path objects.
592+
593+
Raises:
594+
ValueError: If any path does not exist.
595+
"""
596+
resolved_paths = []
597+
for path_str in env_file_paths:
598+
path = Path(path_str).resolve()
599+
if not path.exists():
600+
raise ValueError(f"Environment file not found: {path}")
601+
resolved_paths.append(path)
602+
return resolved_paths
603+
604+
560605
# Argparse-compatible validators
561606
#
562607
# These wrappers adapt our core validators (which use keyword-only parameters and raise
@@ -573,6 +618,7 @@ def wrapper(value):
573618
validate_log_level_argparse = _argparse_validator(validate_log_level)
574619
positive_int = _argparse_validator(lambda v: validate_integer(v, min_value=1))
575620
non_negative_int = _argparse_validator(lambda v: validate_integer(v, min_value=0))
621+
resolve_env_files_argparse = _argparse_validator(resolve_env_files)
576622

577623

578624
def parse_memory_labels(json_string: str) -> dict[str, str]:
@@ -604,24 +650,6 @@ def parse_memory_labels(json_string: str) -> dict[str, str]:
604650
return labels
605651

606652

607-
def resolve_initialization_scripts(script_paths: list[str]) -> list[Path]:
608-
"""
609-
Resolve initialization script paths.
610-
611-
Args:
612-
script_paths: List of script path strings.
613-
614-
Returns:
615-
List of resolved Path objects.
616-
617-
Raises:
618-
FileNotFoundError: If a script path does not exist.
619-
"""
620-
from pyrit.cli.initializer_registry import InitializerRegistry
621-
622-
return InitializerRegistry.resolve_script_paths(script_paths=script_paths)
623-
624-
625653
def get_default_initializer_discovery_path() -> Path:
626654
"""
627655
Get the default path for discovering initializers.
@@ -688,6 +716,8 @@ async def print_initializers_list_async(*, context: FrontendCore, discovery_path
688716
ARG_HELP = {
689717
"initializers": "Built-in initializer names to run before the scenario (e.g., openai_objective_target)",
690718
"initialization_scripts": "Paths to custom Python initialization scripts to run before the scenario",
719+
"env_files": "Paths to environment files to load in order (e.g., .env.production .env.local). Later files "
720+
"override earlier ones.",
691721
"scenario_strategies": "List of strategy names to run (e.g., base64 rot13)",
692722
"max_concurrency": "Maximum number of concurrent attack executions (must be >= 1)",
693723
"max_retries": "Maximum number of automatic retries on exception (must be >= 0)",
@@ -728,6 +758,7 @@ def parse_run_arguments(*, args_string: str) -> dict[str, Any]:
728758
"scenario_name": parts[0],
729759
"initializers": None,
730760
"initialization_scripts": None,
761+
"env_files": None,
731762
"scenario_strategies": None,
732763
"max_concurrency": None,
733764
"max_retries": None,
@@ -752,6 +783,13 @@ def parse_run_arguments(*, args_string: str) -> dict[str, Any]:
752783
while i < len(parts) and not parts[i].startswith("--"):
753784
result["initialization_scripts"].append(parts[i])
754785
i += 1
786+
elif parts[i] == "--env-files":
787+
# Collect env file paths until next flag
788+
result["env_files"] = []
789+
i += 1
790+
while i < len(parts) and not parts[i].startswith("--"):
791+
result["env_files"].append(parts[i])
792+
i += 1
755793
elif parts[i] in ("--strategies", "-s"):
756794
# Collect strategies until next flag
757795
result["scenario_strategies"] = []

pyrit/cli/pyrit_scan.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ def parse_args(args=None) -> Namespace:
9494
help=frontend_core.ARG_HELP["initialization_scripts"],
9595
)
9696

97+
parser.add_argument(
98+
"--env-files",
99+
type=str,
100+
nargs="+",
101+
help=frontend_core.ARG_HELP["env_files"],
102+
)
103+
97104
parser.add_argument(
98105
"--strategies",
99106
"-s",
@@ -152,9 +159,18 @@ def main(args=None) -> int:
152159
print(f"Error: {e}")
153160
return 1
154161

162+
env_files = None
163+
if parsed_args.env_files:
164+
try:
165+
env_files = frontend_core.resolve_env_files(env_file_paths=parsed_args.env_files)
166+
except ValueError as e:
167+
print(f"Error: {e}")
168+
return 1
169+
155170
context = frontend_core.FrontendCore(
156171
database=parsed_args.database,
157172
initialization_scripts=initialization_scripts,
173+
env_files=env_files,
158174
log_level=parsed_args.log_level,
159175
)
160176

@@ -181,11 +197,17 @@ def main(args=None) -> int:
181197
script_paths=parsed_args.initialization_scripts
182198
)
183199

200+
# Collect environment files
201+
env_files = None
202+
if parsed_args.env_files:
203+
env_files = frontend_core.resolve_env_files(env_file_paths=parsed_args.env_files)
204+
184205
# Create context with initializers
185206
context = frontend_core.FrontendCore(
186207
database=parsed_args.database,
187208
initialization_scripts=initialization_scripts,
188209
initializer_names=parsed_args.initializers,
210+
env_files=env_files,
189211
log_level=parsed_args.log_level,
190212
)
191213

pyrit/cli/pyrit_shell.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ class PyRITShell(cmd.Cmd):
3939
Shell Startup Options:
4040
--database <type> Database type (InMemory, SQLite, AzureSQL) - default for all runs
4141
--log-level <level> Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - default for all runs
42+
--env-files <path> ... Environment files to load in order - default for all runs
4243
4344
Run Command Options:
4445
--initializers <name> ... Built-in initializers to run before the scenario
4546
--initialization-scripts <...> Custom Python scripts to run before the scenario
47+
--env-files <path> ... Environment files to load in order (overrides startup default)
4648
--strategies, -s <s1> ... Strategy names to use
4749
--max-concurrency <N> Maximum concurrent operations
4850
--max-retries <N> Maximum retry attempts
@@ -97,6 +99,7 @@ def __init__(
9799
self.context = context
98100
self.default_database = context._database
99101
self.default_log_level = context._log_level
102+
self.default_env_files = context._env_files
100103

101104
# Track scenario execution history: list of (command_string, ScenarioResult) tuples
102105
self._scenario_history: list[tuple[str, ScenarioResult]] = []
@@ -150,6 +153,7 @@ def do_run(self, line):
150153
Options:
151154
--initializers <name> ... Built-in initializers to run before the scenario
152155
--initialization-scripts <...> Custom Python scripts to run before the scenario
156+
--env-files <path> ... Environment files to load in order
153157
--strategies, -s <s1> <s2> ... Strategy names to use
154158
--max-concurrency <N> Maximum concurrent operations
155159
--max-retries <N> Maximum retry attempts
@@ -214,11 +218,24 @@ def do_run(self, line):
214218
print(f"Error: {e}")
215219
return
216220

221+
# Resolve env files if provided
222+
resolved_env_files = None
223+
if args["env_files"]:
224+
try:
225+
resolved_env_files = frontend_core.resolve_env_files(env_file_paths=args["env_files"])
226+
except ValueError as e:
227+
print(f"Error: {e}")
228+
return
229+
else:
230+
# Use default env files from shell startup
231+
resolved_env_files = self.default_env_files
232+
217233
# Create a context for this run with overrides
218234
run_context = frontend_core.FrontendCore(
219235
database=args["database"] or self.default_database,
220236
initialization_scripts=resolved_scripts,
221237
initializer_names=args["initializers"],
238+
env_files=resolved_env_files,
222239
log_level=args["log_level"] or self.default_log_level,
223240
)
224241
# Use the existing registries (don't reinitialize)
@@ -455,13 +472,30 @@ def main():
455472
help="Default logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) (default: WARNING, can be overridden per-run)",
456473
)
457474

475+
parser.add_argument(
476+
"--env-files",
477+
type=str,
478+
nargs="+",
479+
help="Environment files to load in order (default for all runs, can be overridden per-run)",
480+
)
481+
458482
args = parser.parse_args()
459483

484+
# Resolve env files if provided
485+
env_files = None
486+
if args.env_files:
487+
try:
488+
env_files = frontend_core.resolve_env_files(env_file_paths=args.env_files)
489+
except ValueError as e:
490+
print(f"Error: {e}")
491+
return 1
492+
460493
# Create context (initializers are specified per-run, not at startup)
461494
context = frontend_core.FrontendCore(
462495
database=args.database,
463496
initialization_scripts=None,
464497
initializer_names=None,
498+
env_files=env_files,
465499
log_level=args.log_level,
466500
)
467501

pyrit/common/path.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def in_git_repo() -> bool:
3131

3232
PYRIT_PATH = pathlib.Path(__file__, "..", "..").resolve()
3333

34+
CONFIGURATION_DIRECTORY_PATH = pathlib.Path.home() / ".pyrit"
35+
3436
# Points to the root of the project
3537
HOME_PATH = pathlib.Path(PYRIT_PATH, "..").resolve()
3638

0 commit comments

Comments
 (0)