Secrets: The Red Line for Infrastructure AI Agents¶
How Merlya guarantees your passwords never end up in logs, the LLM, or stored in plaintext.
The Problem with Current AI Agents¶
As an infrastructure engineer, I've tested many AI agents: OpenHands, Gemini CLI, SHAI, and others. The same concerns kept coming back:
- Decisions made "because the model thinks it's better" — no justification or traceability
- Commands executed implicitly — no explicit confirmation for destructive operations
- Secrets handled like regular variables — displayed in logs, sent to APIs
- Very few usable traces — impossible to do a post-incident audit
These problems aren't anecdotal. In a production environment, they're deal-breakers.
@name — Host Target or Secret Reference?¶
Merlya uses @ for two separate purposes. The distinction is determined by context, not format. The formal rule to avoid any ambiguity:
@ usage | Where it appears | Resolved by | Convention |
|---|---|---|---|
@web-01 as a target | target= argument to agent tools | Inventory DB lookup | No colons in host names |
@db:password in a command | Inside SSH/bash command strings | Keyring at execution time | Use namespace:name with colons |
@variable in message text | Anywhere in REPL input | REPL expansion before LLM | Plain names |
The : colon is the formal discriminator. Secret names must be namespaced with : (e.g. @db:prod:password, @elevation:web-01:password). Host names cannot contain :. This makes the two namespaces structurally disjoint.
If you type @web-01 as a target but also have a secret named web-01, the system detects the collision, resolves to inventory, and warns you to rename the secret (e.g. @ssh:web-01).
Merlya's "Secret-Zero-Trust" Architecture¶
Merlya was designed with a simple philosophy: the LLM must never see secrets.
The Secrets Flow¶
┌─────────────────────────────────────────────────────────────────┐
│ SECRETS FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. User enters password │
│ └── prompt_secret() → System Keyring │
│ │
│ 2. Merlya stores a REFERENCE │
│ └── @db:password (not the value!) │
│ │
│ 3. LLM generates a command │
│ └── "mysql -p @db:password -e 'SELECT 1'" │
│ │
│ 4. LATE resolution (execution-time) │
│ └── resolve_all_references() → mysql -p secretvalue │
│ │
│ 5. Return to LLM │
│ └── "mysql -p *** -e 'SELECT 1'" (safe_command) │
│ │
└─────────────────────────────────────────────────────────────────┘
The secret is never: - Displayed in logs - Sent to the LLM - Stored in plaintext on disk
Layer 1: Secure Storage via Keyring¶
Secrets are stored in your OS system keyring:
| OS | Backend |
|---|---|
| macOS | Keychain |
| Windows | Credential Manager |
| Linux | Secret Service (GNOME Keyring, KWallet) |
# merlya/secrets/store.py
def set(self, name: str, value: str) -> None:
if self._keyring_available:
keyring.set_password(SERVICE_NAME, name, value)
else:
self._memory_store[name] = value # Fallback with warning
If the keyring isn't available (Docker container without access), Merlya uses in-memory storage with an explicit warning: secrets will be lost on exit.
Layer 2: References Instead of Values¶
When you enter a password, Merlya never returns it to the LLM. Instead, it generates a reference:
# merlya/tools/interaction.py
safe_values = {}
for name, val in values.items():
if name.lower() in {"password", "token", "secret", "key"}:
secret_key = f"{key_prefix}:{name}"
secret_store.set(secret_key, val)
safe_values[name] = f"@{secret_key}" # Reference, not value!
else:
safe_values[name] = val
The LLM sees @db:password, never MySuperPassword123!.
Layer 3: Late Resolution¶
@secret-name references are resolved only at execution time, after the LLM has given its instruction:
# merlya/tools/core/resolve.py
def resolve_secrets(command: str, secrets: SecretStore) -> tuple[str, str]:
"""
SECURITY: This function should only be called at execution time,
never before sending commands to the LLM.
Returns:
Tuple of (resolved_command, safe_command_for_logging).
"""
for match in REFERENCE_PATTERN.finditer(command):
secret_value = secrets.get(secret_name)
resolved = resolved[:start] + secret_value + resolved[end:]
safe = safe[:start] + "***" + safe[end:]
return resolved, safe
The resolved command (resolved) is executed but never logged or returned to the LLM. Only the masked version (safe) is visible.
Layer 4: Proactive Plaintext Password Detection¶
Merlya blocks commands containing plaintext passwords:
# merlya/tools/core/security.py
UNSAFE_PASSWORD_PATTERNS = (
re.compile(r"echo\s+['\"]?(?!@)[^'\"]+['\"]?\s*\|\s*sudo\s+-S"), # echo 'pass' | sudo
re.compile(r"-p['\"][^'\"@]+['\"]"), # mysql -p'password'
re.compile(r"--password[=\s]+['\"]?(?!@)[^@\s'\"]+"), # --password=value
re.compile(r"sshpass\s+-p\s+['\"]?(?!@)[^'\"@\s]+"), # sshpass -p password
# ... 8 patterns total
)
def detect_unsafe_password(command: str) -> str | None:
for pattern in UNSAFE_PASSWORD_PATTERNS:
if pattern.search(command):
return "SECURITY: Command may contain a plaintext password."
return None
If the LLM tries to generate mysql -p'secretvalue' instead of mysql -p @db:password, the command is rejected with a clear error message.
Layer 5: Automatic Log Sanitization¶
Even if a secret passed through the other layers, the audit system would mask it:
# merlya/audit/logger.py
_SENSITIVE_KEY_PATTERNS = (
"password", "passwd", "pwd", "secret", "key", "token",
"api_key", "bearer", "jwt", "oauth", "credential",
"private_key", "ssh_key", "id_rsa", "certificate",
# ... 30+ patterns
)
_SENSITIVE_VALUE_PATTERNS = (
re.compile(r"^A[KBS]IA[A-Z0-9]{16}$"), # AWS access key
re.compile(r"^gh[pousr]_[A-Za-z0-9_]{36,}$"), # GitHub token
re.compile(r"^eyJ[A-Za-z0-9_-]*\.eyJ"), # JWT
# ... 9 patterns to detect secrets by their format
)
Sanitization is recursive: it traverses nested dicts and lists.
Setting a Host's Sudo / Elevation Password¶
For the full elevation system documentation (methods, configuration, examples), see the Privilege Elevation Guide.
There are two ways to give Merlya a sudo or root password for a host.
Automatic (interactive session)¶
Just ask for something that requires elevation:
If web-01 has elevation_method = sudo_password in the inventory, the agent automatically requests credentials:
- The REPL prompts you:
🔐 Credentials needed for sudo @web-01 - You enter the password (hidden input)
- Merlya verifies it via
sudo -S trueon the host — if it fails, you retry (max 3 attempts) - The verified password is stored in keyring as
sudo:web-01:password - A reference
@sudo:web-01:passwordis passed to the agent — the raw value is never sent to the LLM
On subsequent requests for the same host, the keyring lookup succeeds immediately and no prompt is shown.
Manual (pre-populate the keyring)¶
Useful before a session, in CI/CD, or to set credentials without triggering a command:
# sudo password for web-01
/secret set sudo:web-01:password
# root password for db-prod (su method)
/secret set root:db-prod:password
Both commands prompt for the value with hidden input.
Key naming convention¶
| Elevation method | Key format | Example |
|---|---|---|
sudo_password | sudo:<hostname>:password | sudo:web-01:password |
su (root) | root:<hostname>:password | root:db-prod:password |
The hostname must match the name field in your inventory (not the IP or FQDN), since that is how the agent looks it up.
Check what is stored¶
Clear a stored password¶
/secret clear-elevation web-01 # Clear for one host
/secret clear-elevation --all # Clear all elevation passwords
Comparison with Other Agents¶
| Aspect | OpenHands | Gemini CLI | SHAI | Merlya |
|---|---|---|---|---|
| Secrets in logs | Possible | Possible | Possible | Masked |
| Secrets sent to LLM | Yes | Yes | Yes | Never (references) |
| Secure storage | Env vars | Env vars | File | OS Keyring |
| Plaintext detection | No | No | No | 8 patterns |
| Audit trail | Basic | Basic | Minimal | SQLite + SIEM |
Concrete Example¶
What you type:¶
What the LLM sees:¶
User: Connect to the MySQL database on db-prod and list tables
Assistant: I'll request MySQL credentials then list the tables.
Tool call: request_credentials(service="mysql", host="db-prod")
Tool result: {"username": "admin", "password": "@mysql:db-prod:password"}
Tool call: ssh_execute(host="db-prod", command="mysql -u admin -p @mysql:db-prod:password -e 'SHOW TABLES'")
Tool result: {
"stdout": "Tables_in_mydb\nusers\norders\nproducts",
"command": "mysql -u admin -p *** -e 'SHOW TABLES'" // <- Masked!
}
What's actually executed:¶
What's logged:¶
The password appears nowhere except in the actual command execution.
Configuration¶
Check keyring status¶
Displays:
Secret Store Status
Backend: keyring (macOS Keychain)
Stored secrets: 3
- mysql:db-prod:password
- ssh:bastion:passphrase
- api:monitoring:token
Non-interactive mode (CI/CD)¶
In non-interactive mode (merlya run --yes), credentials cannot be prompted. Merlya will fail immediately with a clear error if credentials are needed but not pre-configured.
Pre-store credentials before running¶
# Store sudo password for target hosts
merlya secret set sudo:192.168.1.7:password
# Store database credentials
merlya secret set mysql:db-prod:password
# Then run in non-interactive mode
merlya run --yes "Check database status on db-prod"
Use NOPASSWD sudo¶
Configure sudo without password on target hosts:
Error handling¶
If credentials are missing in --yes mode, Merlya returns:
❌ Cannot obtain credentials in non-interactive mode.
Missing: password for [email protected]
To fix this, before running in --yes mode:
1. Store credentials in keyring: merlya secret set sudo:192.168.1.7:password
2. Or configure NOPASSWD sudo on the target host
3. Or run in interactive mode (without --yes)
This fail-fast behavior prevents retry loops and wasted API calls.
Audit and Compliance¶
Every secret access is traced:
| id | event_type | action | target | created_at |
|----|------------|--------|--------|------------|
| a1 | secret_accessed | get | mysql:db-prod:password | 2024-12-16 10:23:45 |
| a2 | secret_accessed | set | ssh:bastion:passphrase | 2024-12-16 10:22:30 |
SIEM export:
Conclusion¶
Secrets are the first red line for any infrastructure AI agent. Merlya implements a defense in depth architecture with 5 layers of protection:
- Secure storage via OS keyring
- References instead of values for the LLM
- Late resolution only at execution time
- Proactive detection of plaintext passwords
- Automatic sanitization in logs
This architecture ensures that even if one layer fails, the others protect your secrets.
Merlya is an AI CLI agent for infrastructure management, designed with security as the absolute priority.
Useful links: - Full documentation - SSH Guide - Configuration