backend redesign

This commit is contained in:
2026-03-11 18:47:11 -04:00
parent 8ff277c8c6
commit e99ef5d2dd
210 changed files with 12147 additions and 155 deletions

View File

@@ -0,0 +1,40 @@
"""
Encrypted secrets management with master password protection.
This module provides secure storage for sensitive configuration like API keys,
using Argon2id for password-based key derivation and Fernet (AES-256) for encryption.
Basic usage:
from secrets_manager import SecretsStore
# First time setup
store = SecretsStore()
store.initialize("my-master-password")
store.set("ANTHROPIC_API_KEY", "sk-ant-...")
# Later usage
store = SecretsStore()
store.unlock("my-master-password")
api_key = store.get("ANTHROPIC_API_KEY")
Command-line interface:
python -m secrets_manager.cli init
python -m secrets_manager.cli set KEY VALUE
python -m secrets_manager.cli get KEY
python -m secrets_manager.cli list
python -m secrets_manager.cli change-password
"""
from .store import (
SecretsStore,
SecretsStoreError,
SecretsStoreLocked,
InvalidMasterPassword,
)
__all__ = [
"SecretsStore",
"SecretsStoreError",
"SecretsStoreLocked",
"InvalidMasterPassword",
]

View File

@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""
Command-line interface for managing the encrypted secrets store.
Usage:
python -m secrets.cli init # Initialize new secrets store
python -m secrets.cli set KEY VALUE # Set a secret
python -m secrets.cli get KEY # Get a secret
python -m secrets.cli delete KEY # Delete a secret
python -m secrets.cli list # List all secret keys
python -m secrets.cli change-password # Change master password
python -m secrets.cli export FILE # Export encrypted backup
python -m secrets.cli import FILE # Import encrypted backup
python -m secrets.cli migrate-from-env # Migrate secrets from .env file
"""
import sys
import argparse
import getpass
from pathlib import Path
from .store import SecretsStore, SecretsStoreError, InvalidMasterPassword
def get_password(prompt: str = "Master password: ", confirm: bool = False) -> str:
"""
Securely get password from user.
Args:
prompt: Password prompt
confirm: If True, ask for confirmation
Returns:
Password string
"""
password = getpass.getpass(prompt)
if confirm:
confirm_password = getpass.getpass("Confirm password: ")
if password != confirm_password:
print("Error: Passwords do not match", file=sys.stderr)
sys.exit(1)
return password
def cmd_init(args):
"""Initialize a new secrets store."""
store = SecretsStore()
if store.is_initialized:
print("Error: Secrets store is already initialized", file=sys.stderr)
print(f"Location: {store.secrets_file}", file=sys.stderr)
sys.exit(1)
password = get_password("Create master password: ", confirm=True)
if len(password) < 8:
print("Error: Password must be at least 8 characters", file=sys.stderr)
sys.exit(1)
store.initialize(password)
print(f"Secrets store initialized at {store.secrets_file}")
def cmd_set(args):
"""Set a secret value."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
password = get_password()
try:
store.unlock(password)
except InvalidMasterPassword:
print("Error: Invalid master password", file=sys.stderr)
sys.exit(1)
store.set(args.key, args.value)
print(f"✓ Secret '{args.key}' saved")
def cmd_get(args):
"""Get a secret value."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
password = get_password()
try:
store.unlock(password)
except InvalidMasterPassword:
print("Error: Invalid master password", file=sys.stderr)
sys.exit(1)
value = store.get(args.key)
if value is None:
print(f"Error: Secret '{args.key}' not found", file=sys.stderr)
sys.exit(1)
# Print to stdout (can be captured)
print(value)
def cmd_delete(args):
"""Delete a secret."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
password = get_password()
try:
store.unlock(password)
except InvalidMasterPassword:
print("Error: Invalid master password", file=sys.stderr)
sys.exit(1)
if store.delete(args.key):
print(f"✓ Secret '{args.key}' deleted")
else:
print(f"Error: Secret '{args.key}' not found", file=sys.stderr)
sys.exit(1)
def cmd_list(args):
"""List all secret keys."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
password = get_password()
try:
store.unlock(password)
except InvalidMasterPassword:
print("Error: Invalid master password", file=sys.stderr)
sys.exit(1)
keys = store.list_keys()
if not keys:
print("No secrets stored")
else:
print(f"Stored secrets ({len(keys)}):")
for key in sorted(keys):
# Show key and value length for verification
value = store.get(key)
value_str = str(value)
value_preview = value_str[:50] + "..." if len(value_str) > 50 else value_str
print(f" {key}: {value_preview}")
def cmd_change_password(args):
"""Change the master password."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
current_password = get_password("Current master password: ")
new_password = get_password("New master password: ", confirm=True)
if len(new_password) < 8:
print("Error: Password must be at least 8 characters", file=sys.stderr)
sys.exit(1)
try:
store.change_master_password(current_password, new_password)
except InvalidMasterPassword:
print("Error: Invalid current password", file=sys.stderr)
sys.exit(1)
def cmd_export(args):
"""Export encrypted secrets to a backup file."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
output_path = Path(args.file)
if output_path.exists() and not args.force:
print(f"Error: File {output_path} already exists. Use --force to overwrite.", file=sys.stderr)
sys.exit(1)
try:
store.export_encrypted(output_path)
except SecretsStoreError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_import(args):
"""Import encrypted secrets from a backup file."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
input_path = Path(args.file)
if not input_path.exists():
print(f"Error: File {input_path} does not exist", file=sys.stderr)
sys.exit(1)
password = get_password()
try:
store.import_encrypted(input_path, password)
except InvalidMasterPassword:
print("Error: Invalid master password or incompatible backup", file=sys.stderr)
sys.exit(1)
except SecretsStoreError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_migrate_from_env(args):
"""Migrate secrets from .env file to encrypted store."""
store = SecretsStore()
if not store.is_initialized:
print("Error: Secrets store is not initialized. Run 'init' first.", file=sys.stderr)
sys.exit(1)
# Look for .env file
backend_root = Path(__file__).parent.parent.parent
env_file = backend_root / ".env"
if not env_file.exists():
print(f"Error: .env file not found at {env_file}", file=sys.stderr)
sys.exit(1)
password = get_password()
try:
store.unlock(password)
except InvalidMasterPassword:
print("Error: Invalid master password", file=sys.stderr)
sys.exit(1)
# Parse .env file (simple parser - doesn't handle all edge cases)
migrated = 0
skipped = 0
with open(env_file) as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Parse KEY=VALUE format
if '=' not in line:
print(f"Warning: Skipping invalid line {line_num}: {line}", file=sys.stderr)
skipped += 1
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
# Remove quotes if present
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
# Check if key already exists
existing = store.get(key)
if existing is not None:
print(f"Warning: Secret '{key}' already exists, skipping", file=sys.stderr)
skipped += 1
continue
store.set(key, value)
print(f"✓ Migrated: {key}")
migrated += 1
print(f"\nMigration complete: {migrated} secrets migrated, {skipped} skipped")
if not args.keep_env:
# Ask for confirmation before deleting .env
confirm = input(f"\nDelete {env_file}? [y/N]: ").strip().lower()
if confirm == 'y':
env_file.unlink()
print(f"✓ Deleted {env_file}")
else:
print(f"Kept {env_file} (consider deleting it manually)")
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Manage encrypted secrets store",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest='command', help='Command to run')
subparsers.required = True
# init
parser_init = subparsers.add_parser('init', help='Initialize new secrets store')
parser_init.set_defaults(func=cmd_init)
# set
parser_set = subparsers.add_parser('set', help='Set a secret value')
parser_set.add_argument('key', help='Secret key name')
parser_set.add_argument('value', help='Secret value')
parser_set.set_defaults(func=cmd_set)
# get
parser_get = subparsers.add_parser('get', help='Get a secret value')
parser_get.add_argument('key', help='Secret key name')
parser_get.set_defaults(func=cmd_get)
# delete
parser_delete = subparsers.add_parser('delete', help='Delete a secret')
parser_delete.add_argument('key', help='Secret key name')
parser_delete.set_defaults(func=cmd_delete)
# list
parser_list = subparsers.add_parser('list', help='List all secret keys')
parser_list.set_defaults(func=cmd_list)
# change-password
parser_change = subparsers.add_parser('change-password', help='Change master password')
parser_change.set_defaults(func=cmd_change_password)
# export
parser_export = subparsers.add_parser('export', help='Export encrypted backup')
parser_export.add_argument('file', help='Output file path')
parser_export.add_argument('--force', action='store_true', help='Overwrite existing file')
parser_export.set_defaults(func=cmd_export)
# import
parser_import = subparsers.add_parser('import', help='Import encrypted backup')
parser_import.add_argument('file', help='Input file path')
parser_import.set_defaults(func=cmd_import)
# migrate-from-env
parser_migrate = subparsers.add_parser('migrate-from-env', help='Migrate from .env file')
parser_migrate.add_argument('--keep-env', action='store_true', help='Keep .env file after migration')
parser_migrate.set_defaults(func=cmd_migrate_from_env)
args = parser.parse_args()
try:
args.func(args)
except KeyboardInterrupt:
print("\nAborted", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,144 @@
"""
Cryptographic utilities for secrets management.
Uses Argon2id for password-based key derivation and Fernet for encryption.
"""
import os
import secrets as secrets_module
from typing import Tuple
from argon2 import PasswordHasher
from argon2.low_level import hash_secret_raw, Type
from cryptography.fernet import Fernet
import base64
# Argon2id parameters (OWASP recommended for password-based KDF)
# These provide strong defense against GPU/ASIC attacks
ARGON2_TIME_COST = 3 # iterations
ARGON2_MEMORY_COST = 65536 # 64 MB
ARGON2_PARALLELISM = 4 # threads
ARGON2_HASH_LENGTH = 32 # bytes (256 bits for Fernet key)
ARGON2_SALT_LENGTH = 16 # bytes (128 bits)
def generate_salt() -> bytes:
"""Generate a cryptographically secure random salt."""
return secrets_module.token_bytes(ARGON2_SALT_LENGTH)
def derive_key_from_password(password: str, salt: bytes) -> bytes:
"""
Derive an encryption key from a password using Argon2id.
Args:
password: The master password
salt: The salt (must be consistent for the same password to work)
Returns:
32-byte key suitable for Fernet encryption
"""
password_bytes = password.encode('utf-8')
# Use Argon2id (hybrid mode - best of Argon2i and Argon2d)
raw_hash = hash_secret_raw(
secret=password_bytes,
salt=salt,
time_cost=ARGON2_TIME_COST,
memory_cost=ARGON2_MEMORY_COST,
parallelism=ARGON2_PARALLELISM,
hash_len=ARGON2_HASH_LENGTH,
type=Type.ID # Argon2id
)
return raw_hash
def create_fernet(key: bytes) -> Fernet:
"""
Create a Fernet cipher instance from a raw key.
Args:
key: 32-byte raw key from Argon2id
Returns:
Fernet instance for encryption/decryption
"""
# Fernet requires a URL-safe base64-encoded 32-byte key
fernet_key = base64.urlsafe_b64encode(key)
return Fernet(fernet_key)
def encrypt_data(data: bytes, key: bytes) -> bytes:
"""
Encrypt data using Fernet (AES-256-CBC).
Args:
data: Raw bytes to encrypt
key: 32-byte encryption key
Returns:
Encrypted data (includes IV and auth tag)
"""
fernet = create_fernet(key)
return fernet.encrypt(data)
def decrypt_data(encrypted_data: bytes, key: bytes) -> bytes:
"""
Decrypt data using Fernet.
Args:
encrypted_data: Encrypted bytes from encrypt_data
key: 32-byte encryption key (must match encryption key)
Returns:
Decrypted raw bytes
Raises:
cryptography.fernet.InvalidToken: If decryption fails (wrong key/corrupted data)
"""
fernet = create_fernet(key)
return fernet.decrypt(encrypted_data)
def create_verification_hash(password: str, salt: bytes) -> str:
"""
Create a verification hash to check if a password is correct.
This is NOT for storing the password - it's for verifying the password
unlocks the correct key without trying to decrypt the entire secrets file.
Args:
password: The master password
salt: The salt used for key derivation
Returns:
Base64-encoded hash for verification
"""
# Derive key and hash it again for verification
key = derive_key_from_password(password, salt)
# Simple hash of the key for verification (not security critical since
# the key itself is already derived from Argon2id)
verification = base64.b64encode(key[:16]).decode('ascii')
return verification
def verify_password(password: str, salt: bytes, verification_hash: str) -> bool:
"""
Verify a password against a verification hash.
Args:
password: Password to verify
salt: Salt used for key derivation
verification_hash: Expected verification hash
Returns:
True if password is correct, False otherwise
"""
computed_hash = create_verification_hash(password, salt)
# Constant-time comparison to prevent timing attacks
return secrets_module.compare_digest(computed_hash, verification_hash)

View File

@@ -0,0 +1,406 @@
"""
Encrypted secrets store with master password protection.
The secrets are stored in an encrypted file, with the encryption key derived
from a master password using Argon2id. The master password can be changed
without re-encrypting all secrets.
"""
import json
import os
import stat
from pathlib import Path
from typing import Dict, Optional, Any
from cryptography.fernet import InvalidToken
from .crypto import (
generate_salt,
derive_key_from_password,
encrypt_data,
decrypt_data,
create_verification_hash,
verify_password,
)
class SecretsStoreError(Exception):
"""Base exception for secrets store errors."""
pass
class SecretsStoreLocked(SecretsStoreError):
"""Raised when trying to access secrets while store is locked."""
pass
class InvalidMasterPassword(SecretsStoreError):
"""Raised when master password is incorrect."""
pass
class SecretsStore:
"""
Encrypted secrets store with master password protection.
Usage:
# Initialize (first time)
store = SecretsStore()
store.initialize("my-secure-password")
# Unlock
store = SecretsStore()
store.unlock("my-secure-password")
# Access secrets
api_key = store.get("ANTHROPIC_API_KEY")
store.set("NEW_SECRET", "secret-value")
# Change master password
store.change_master_password("my-secure-password", "new-password")
# Lock when done
store.lock()
"""
def __init__(self, data_dir: Optional[Path] = None):
"""
Initialize secrets store.
Args:
data_dir: Directory for secrets files (defaults to backend/data)
"""
if data_dir is None:
# Default to backend/data
backend_root = Path(__file__).parent.parent.parent
data_dir = backend_root / "data"
self.data_dir = Path(data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.master_key_file = self.data_dir / ".master.key"
self.secrets_file = self.data_dir / "secrets.enc"
# Runtime state
self._encryption_key: Optional[bytes] = None
self._secrets: Optional[Dict[str, Any]] = None
@property
def is_initialized(self) -> bool:
"""Check if the secrets store has been initialized."""
return self.master_key_file.exists()
@property
def is_unlocked(self) -> bool:
"""Check if the secrets store is currently unlocked."""
return self._encryption_key is not None
def initialize(self, master_password: str) -> None:
"""
Initialize the secrets store with a master password.
This should only be called once when setting up the store.
Args:
master_password: The master password to protect the secrets
Raises:
SecretsStoreError: If store is already initialized
"""
if self.is_initialized:
raise SecretsStoreError(
"Secrets store is already initialized. "
"Use unlock() to access it or change_master_password() to change the password."
)
# Generate a new random salt
salt = generate_salt()
# Derive encryption key
encryption_key = derive_key_from_password(master_password, salt)
# Create verification hash
verification_hash = create_verification_hash(master_password, salt)
# Store salt and verification hash
master_key_data = {
"salt": salt.hex(),
"verification": verification_hash,
}
self.master_key_file.write_text(json.dumps(master_key_data, indent=2))
# Set restrictive permissions (owner read/write only)
os.chmod(self.master_key_file, stat.S_IRUSR | stat.S_IWUSR)
# Initialize empty secrets
self._encryption_key = encryption_key
self._secrets = {}
self._save_secrets()
print(f"✓ Secrets store initialized at {self.secrets_file}")
def unlock(self, master_password: str) -> None:
"""
Unlock the secrets store with the master password.
Args:
master_password: The master password
Raises:
SecretsStoreError: If store is not initialized
InvalidMasterPassword: If password is incorrect
"""
if not self.is_initialized:
raise SecretsStoreError(
"Secrets store is not initialized. Call initialize() first."
)
# Load salt and verification hash
master_key_data = json.loads(self.master_key_file.read_text())
salt = bytes.fromhex(master_key_data["salt"])
verification_hash = master_key_data["verification"]
# Verify password
if not verify_password(master_password, salt, verification_hash):
raise InvalidMasterPassword("Invalid master password")
# Derive encryption key
encryption_key = derive_key_from_password(master_password, salt)
# Load and decrypt secrets
if self.secrets_file.exists():
try:
encrypted_data = self.secrets_file.read_bytes()
decrypted_data = decrypt_data(encrypted_data, encryption_key)
self._secrets = json.loads(decrypted_data.decode('utf-8'))
except InvalidToken:
raise InvalidMasterPassword("Failed to decrypt secrets (invalid password)")
except json.JSONDecodeError as e:
raise SecretsStoreError(f"Corrupted secrets file: {e}")
else:
# No secrets file yet (fresh initialization)
self._secrets = {}
self._encryption_key = encryption_key
print(f"✓ Secrets store unlocked ({len(self._secrets)} secrets)")
def lock(self) -> None:
"""Lock the secrets store (clear decrypted data from memory)."""
self._encryption_key = None
self._secrets = None
def get(self, key: str, default: Any = None) -> Any:
"""
Get a secret value.
Args:
key: Secret key name
default: Default value if key doesn't exist
Returns:
Secret value or default
Raises:
SecretsStoreLocked: If store is locked
"""
if not self.is_unlocked:
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
return self._secrets.get(key, default)
def set(self, key: str, value: Any) -> None:
"""
Set a secret value.
Args:
key: Secret key name
value: Secret value (must be JSON-serializable)
Raises:
SecretsStoreLocked: If store is locked
"""
if not self.is_unlocked:
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
self._secrets[key] = value
self._save_secrets()
def delete(self, key: str) -> bool:
"""
Delete a secret.
Args:
key: Secret key name
Returns:
True if secret existed and was deleted, False otherwise
Raises:
SecretsStoreLocked: If store is locked
"""
if not self.is_unlocked:
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
if key in self._secrets:
del self._secrets[key]
self._save_secrets()
return True
return False
def list_keys(self) -> list[str]:
"""
List all secret keys.
Returns:
List of secret keys
Raises:
SecretsStoreLocked: If store is locked
"""
if not self.is_unlocked:
raise SecretsStoreLocked("Secrets store is locked. Call unlock() first.")
return list(self._secrets.keys())
def change_master_password(self, current_password: str, new_password: str) -> None:
"""
Change the master password.
This re-encrypts the secrets with a new key derived from the new password.
Args:
current_password: Current master password
new_password: New master password
Raises:
InvalidMasterPassword: If current password is incorrect
"""
# ALWAYS verify current password before changing
# Load salt and verification hash
if not self.is_initialized:
raise SecretsStoreError(
"Secrets store is not initialized. Call initialize() first."
)
master_key_data = json.loads(self.master_key_file.read_text())
salt = bytes.fromhex(master_key_data["salt"])
verification_hash = master_key_data["verification"]
# Verify current password is correct
if not verify_password(current_password, salt, verification_hash):
raise InvalidMasterPassword("Invalid current password")
# Unlock if needed to access secrets
was_unlocked = self.is_unlocked
if not was_unlocked:
# Store is locked, so unlock with current password
# (we already verified it above, so this will succeed)
encryption_key = derive_key_from_password(current_password, salt)
# Load and decrypt secrets
if self.secrets_file.exists():
encrypted_data = self.secrets_file.read_bytes()
decrypted_data = decrypt_data(encrypted_data, encryption_key)
self._secrets = json.loads(decrypted_data.decode('utf-8'))
else:
self._secrets = {}
self._encryption_key = encryption_key
# Generate new salt
new_salt = generate_salt()
# Derive new encryption key
new_encryption_key = derive_key_from_password(new_password, new_salt)
# Create new verification hash
new_verification_hash = create_verification_hash(new_password, new_salt)
# Update master key file
master_key_data = {
"salt": new_salt.hex(),
"verification": new_verification_hash,
}
self.master_key_file.write_text(json.dumps(master_key_data, indent=2))
os.chmod(self.master_key_file, stat.S_IRUSR | stat.S_IWUSR)
# Re-encrypt secrets with new key
old_key = self._encryption_key
self._encryption_key = new_encryption_key
self._save_secrets()
print("✓ Master password changed successfully")
# Lock if it wasn't unlocked before
if not was_unlocked:
self.lock()
def _save_secrets(self) -> None:
"""Save secrets to encrypted file."""
if not self.is_unlocked:
raise SecretsStoreLocked("Cannot save while locked")
# Serialize secrets to JSON
secrets_json = json.dumps(self._secrets, indent=2)
secrets_bytes = secrets_json.encode('utf-8')
# Encrypt
encrypted_data = encrypt_data(secrets_bytes, self._encryption_key)
# Write to file
self.secrets_file.write_bytes(encrypted_data)
# Set restrictive permissions
os.chmod(self.secrets_file, stat.S_IRUSR | stat.S_IWUSR)
def export_encrypted(self, output_path: Path) -> None:
"""
Export encrypted secrets to a file (for backup).
Args:
output_path: Path to export file
Raises:
SecretsStoreError: If secrets file doesn't exist
"""
if not self.secrets_file.exists():
raise SecretsStoreError("No secrets to export")
import shutil
shutil.copy2(self.secrets_file, output_path)
print(f"✓ Encrypted secrets exported to {output_path}")
def import_encrypted(self, input_path: Path, master_password: str) -> None:
"""
Import encrypted secrets from a file.
This will verify the password can decrypt the import before replacing
the current secrets.
Args:
input_path: Path to import file
master_password: Master password for the current store
Raises:
InvalidMasterPassword: If password doesn't work with import
"""
if not self.is_unlocked:
self.unlock(master_password)
# Try to decrypt the imported file with current key
try:
encrypted_data = Path(input_path).read_bytes()
decrypted_data = decrypt_data(encrypted_data, self._encryption_key)
imported_secrets = json.loads(decrypted_data.decode('utf-8'))
except InvalidToken:
raise InvalidMasterPassword(
"Cannot decrypt imported secrets with current master password"
)
except json.JSONDecodeError as e:
raise SecretsStoreError(f"Corrupted import file: {e}")
# Replace secrets
self._secrets = imported_secrets
self._save_secrets()
print(f"✓ Imported {len(self._secrets)} secrets from {input_path}")