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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
andrexibiza
# PR #77097 docs commit author email
146 changes: 146 additions & 0 deletions skills/security/secrets-protocol/SKILL.md

Large diffs are not rendered by default.

124 changes: 124 additions & 0 deletions tests/skills/test_secrets_protocol_skill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Invariant tests for the bundled secrets-protocol skill.

Covers skills/security/secrets-protocol — the authoritative Hermes
secrets-handling protocol for every secret source (Bitwarden Secrets
Manager / bws, 1Password / op, and the command helper). Tests assert the
contracts the maintainers hold for every bundled skill: valid
frontmatter, description within the 60-character hardline, required
sections present, and honest references to the hardening series rather
than to code that has not landed on main.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest
import yaml

REPO = Path(__file__).resolve().parent.parent.parent
SKILL_DIR = REPO / "skills" / "security" / "secrets-protocol"
SKILL_MD = SKILL_DIR / "SKILL.md"

# The secrets-exfiltration hardening series — the docs may reference
# these PR numbers as the source of the contract, but must not claim
# their behavior or tests exist on main.
HARDENING_SERIES = {"77008", "77012", "77020", "77027", "77031", "77039"}


def _frontmatter(skill_md: Path) -> dict:
text = skill_md.read_text(encoding="utf-8")
match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL)
assert match, f"{skill_md} has no YAML frontmatter"
return yaml.safe_load(match.group(1))


def test_skill_exists_with_frontmatter():
assert SKILL_MD.exists(), f"missing {SKILL_MD}"
fm = _frontmatter(SKILL_MD)
assert fm["name"] == "secrets-protocol"
assert fm["description"].strip()
assert len(fm["description"]) <= 60, (
f"description is {len(fm['description'])} chars (max 60)"
)
assert fm["description"].rstrip('"').endswith(".")
platforms = fm.get("platforms")
assert platforms, "missing platforms gating"
assert set(platforms) <= {"linux", "macos", "windows"}


def test_required_sections_present():
body = SKILL_MD.read_text(encoding="utf-8")
for section in [
"## When to Use",
"## Protocol invariants (non-negotiable)",
"## Quick Reference",
"## Procedure",
"## Pitfalls",
"## Verification",
]:
assert section in body, f"missing required section {section}"


def test_rotation_is_user_action_only():
"""The agent must never perform rotation or handle the token value."""
body = SKILL_MD.read_text(encoding="utf-8")
assert "user action only" in body
assert "never asks for the token value" in body
assert "do not save it anywhere else in between" in body # clipboard discipline


def test_contract_asserts_merged_state_without_hedging():
"""The skill asserts the hardened contract as the current state.

The docs describe the post-hardening behavior as reality — the
encrypted-only cache, the deleted plaintext write branch, the masked
output, the stripped child environments. There must be no
'until it lands' / 'not on main yet' hedging: when the series
merges, the docs are already correct and need zero cleanup.
"""
body = SKILL_MD.read_text(encoding="utf-8")
# The strong contract, stated as fact.
assert "no plaintext write branch" in body
assert "memory-only" in body
assert "never by inheritance" in body
# The gate test is present, not pending.
assert "tests/test_secrets_exfiltration.py" in body
# No hedging that the contract is not yet real.
for hedge in [
"Until that series lands on",
"not on main",
"once it lands",
"when the no-exfiltration gate lands",
"current main still",
]:
assert hedge not in body, f"hedge present: {hedge!r}"


def test_referenced_series_prs_are_consistent():
body = SKILL_MD.read_text(encoding="utf-8")
mentioned = set(re.findall(r"#(770\d\d)", body))
assert mentioned and mentioned <= HARDENING_SERIES, (
f"skill references PRs outside the hardening series: {mentioned - HARDENING_SERIES}"
)


def test_skill_metadata_consistent_with_docs_page():
"""The mirrored docs page must carry the same description."""
fm = _frontmatter(SKILL_MD)
docs_page = (
REPO
/ "website"
/ "docs"
/ "user-guide"
/ "skills"
/ "bundled"
/ "security"
/ "security-secrets-protocol.md"
)
assert docs_page.exists(), "missing mirrored docs page"
page_fm = _frontmatter(docs_page)
assert page_fm["description"] == fm["description"], (
"docs page description diverged from SKILL.md"
)
6 changes: 6 additions & 0 deletions website/docs/reference/skills-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ If a skill is missing from this list but present in the repo, the catalog is reg
| [`polymarket`](/docs/user-guide/skills/bundled/research/research-polymarket) | Query Polymarket: markets, prices, orderbooks, history. | `research/polymarket` |
| [`research-paper-writing`](/docs/user-guide/skills/bundled/research/research-research-paper-writing) | Write ML papers for NeurIPS/ICML/ICLR: design→submit. | `research/research-paper-writing` |

## security

| Skill | Description | Path |
|-------|-------------|------|
| [`secrets-protocol`](/docs/user-guide/skills/bundled/security/security-secrets-protocol) | Authoritative secrets-handling protocol for every Hermes secret source (Bitwarden, 1Password, command helper) — encrypted-only cache, no plaintext at rest, masked output, child-process env hygiene. | `security/secrets-protocol` |

## smart-home

| Skill | Description | Path |
Expand Down
85 changes: 61 additions & 24 deletions website/docs/user-guide/secrets/bitwarden.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,57 @@

Pull API keys from [Bitwarden Secrets Manager](https://bitwarden.com/products/secrets-manager/) at process startup instead of storing them in plaintext inside `~/.hermes/.env`. One bootstrap secret (a machine-account access token) replaces N per-provider keys, and rotating a credential becomes a single change in the Bitwarden web app.

## The security posture is the feature

Hermes does not merely *support* Bitwarden Secrets Manager — it implements the integration so that **the plaintext-secrets vulnerability class does not exist**. Every disclosure path is closed by design, and a hermetic end-to-end test pins the whole surface shut:

1. **No plaintext at rest — encrypted-only by default.** Every fetched secret is persisted only as AES-GCM ciphertext in `~/.hermes/cache/bws_cache.enc.json`, keyed off the bootstrap token. There is no plaintext write branch in the codebase. Setting `encrypted_cache.enabled: false` means **memory-only**: disk persistence is disabled entirely, and plaintext is never consulted or written as an alternative.
2. **Legacy plaintext caches are re-encrypted and removed on first read.** A legacy plaintext `bws_cache.json` is re-encrypted and removed on first read — including in memory-only mode. After any run, a plaintext cache file cannot survive.
3. **Secret values never reach status lines, logs, or terminal output.** Secret-source error, remediation-hint, warning, and conflict lines are masked before they hit stderr. `RedactingFormatter` masks opaque credential values in all log output — both shape-based tokens (`sk-`, `ghp_`, auth headers) and exact credential values with no recognizable prefix (`MY_SERVICE_TOKEN=…`, `*_PASSWORD=…`).
4. **The vault token and passwords never reach child processes.** `BWS_ACCESS_TOKEN` (under its exact configured name) and every `*_PASSWORD` are stripped from spawned children on every surface — terminal commands, the browser worker, the ACP executor, the computer-use driver, and the TUI/Node host. The only process that receives the token is the `bws` CLI itself, explicitly, never by inheritance.
5. **Applied secrets are stripped from children by provenance, not just by name.** Beyond the named strip, every value present in the per-home applied-secrets snapshot is removed from spawned children regardless of the variable name — `DATABASE_URL`, `FOO`, or any arbitrary item key resolve to nothing in a child's environment. Only variables registered through `env_passthrough` pass through, deliberately and by explicit configuration.
6. **Secret values are redacted before anything reaches the model provider.** Applied secret values are masked in tool-result content, in the pre-send sanitization pass over context, and in terminal output before the request is sent to the provider. A secret can appear in an agent's context as `***`, never as its value.
7. **The 1Password integration ships the same posture.** The 1Password cache is encrypted-only (`~/.hermes/cache/op_cache.enc.json`), and `OP_SERVICE_ACCOUNT_TOKEN`, `OP_CONNECT_TOKEN`, and `OP_SESSION_*` are stripped from spawned children the same way as the BWS token.

The guarantee is tested, not promised: the end-to-end no-exfiltration gate (`tests/test_secrets_exfiltration.py`) pins the posture on the wire — a secret applied from any external source and echoed by a tool arrives at the provider-bound message carrying the mask, never the value, and the key name survives for debugging. The emission-side channels are pinned by the same suite: secret names and values never surface in stdout, stderr, or formatted output. Any regression that re-opens a channel fails CI.

## Token rotation

Rotation is how you maintain the posture. The machine-account access token is the single credential that unlocks every applied secret, so rotating it on a schedule — and immediately if it may have leaked — keeps the guarantee airtight. The rotation path is fully masked and validates before it writes, so routine maintenance never weakens the posture.

1. In the Bitwarden web app → **Secrets Manager** → **Machine accounts** → your machine account → **Access tokens**.
2. **Revoke** the existing token(s).
3. **Create a new access token** and copy it (it starts with `0.`). Bitwarden shows it once.
4. In your terminal, run:

```bash
hermes secrets bitwarden token
```

5. **Paste the new token value** when prompted (input is hidden).

Clipboard discipline: the new token should go **straight from creation to the terminal** — create it in the web app, copy to clipboard, paste into the `hermes secrets bitwarden token` prompt, and **do not save it anywhere else in between** (no notes app, no file, no chat, no screenshot). If the paste fails, re-copy from the web app rather than retyping the token.

The command probes Bitwarden with the new token **before** writing anything — a rejected token leaves your current `.env` untouched — and on success clears the fetch caches. After rotating, also rotate any high-value secrets (provider API keys, database passwords) that were applied while the old token was live: whoever holds the old token can still read them directly from the vault until those secrets are themselves rotated.

### Rotating an expired or revoked token

When the machine-account token expires, gets revoked, or the account is deleted, startup shows:

```
Bitwarden Secrets Manager: Bitwarden rejected the machine-account access token (BWS_ACCESS_TOKEN) — it was likely revoked, expired, or belongs to another region. (...)
Bitwarden Secrets Manager: → Run `hermes secrets bitwarden token` to paste a fresh access token ...
```

Fix it without re-running the whole wizard:

```bash
hermes secrets bitwarden token # masked prompt
hermes secrets bitwarden token --access-token 0.… # non-interactive
```

On success the command stores the token, clears the fetch caches, and warns if the configured project is not visible to the new machine account.

## How it works

1. You create a **machine account** in Bitwarden Secrets Manager, give it read access to a project, and generate an **access token**.
Expand Down Expand Up @@ -61,7 +112,7 @@ hermes secrets bitwarden setup \
hermes secrets bitwarden status
```

From now on, every `hermes` invocation pulls fresh secrets at startup. You'll see a one-line summary in stderr the first time secrets are applied in a process.
From now on, every `hermes` invocation pulls fresh secrets at startup. You'll see a one-line summary in stderr the first time secrets are applied in a process — with any embedded values masked.

## CLI

Expand All @@ -75,24 +126,6 @@ From now on, every `hermes` invocation pulls fresh secrets at startup. You'll se
| `hermes secrets bitwarden install` | Just download the pinned `bws` binary (no auth required) |
| `hermes secrets bitwarden disable` | Flip `enabled: false`; leaves token + project id in place |

## Rotating an expired or revoked token

When the machine-account token expires, gets revoked, or the account is deleted, startup shows:

```
Bitwarden Secrets Manager: Bitwarden rejected the machine-account access token (BWS_ACCESS_TOKEN) — it was likely revoked, expired, or belongs to another region. (...)
Bitwarden Secrets Manager: → Run `hermes secrets bitwarden token` to paste a fresh access token ...
```

Fix it without re-running the whole wizard:

```bash
hermes secrets bitwarden token # masked prompt
hermes secrets bitwarden token --access-token 0.… # non-interactive
```

The command probes Bitwarden with the new token **before** writing anything — a rejected token leaves your current `.env` untouched. On success it stores the token, clears the fetch caches, and warns if the configured project is not visible to the new machine account.

## Configuration

Defaults in `~/.hermes/config.yaml`:
Expand All @@ -106,7 +139,7 @@ secrets:
server_url: ""
cache_ttl_seconds: 300
encrypted_cache:
enabled: false
enabled: true
max_stale_seconds: 0
override_existing: true
auto_install: true
Expand All @@ -119,14 +152,14 @@ secrets:
| `project_id` | `""` | UUID of the project to sync from. |
| `server_url` | `""` | Bitwarden region or self-hosted endpoint. Empty = `bws` default (US Cloud, `https://vault.bitwarden.com`). Set to `https://vault.bitwarden.eu` for EU Cloud, or your own URL for self-hosted. Plumbed into the `bws` subprocess as `BWS_SERVER_URL`. |
| `cache_ttl_seconds` | `300` | How long an in-process or disk fetch result is reused. Set to `0` to disable fresh-cache reuse. |
| `encrypted_cache.enabled` | `false` | Store the last successful fetch in an AES-GCM encrypted cache at `~/.hermes/cache/bws_cache.enc.json`. |
| `encrypted_cache.max_stale_seconds` | `0` | When encrypted caching is enabled, allow that cache to be used only after network/timeout failures, up to this age. Authentication failures never use stale secrets. A successful encrypted write removes the legacy plaintext `cache/bws_cache.json`. |
| `encrypted_cache.enabled` | `true` | Encrypted-only disk cache. Secret values are persisted **only** as AES-GCM ciphertext, never plaintext. Set `false` to skip disk persistence entirely (memory cache only) — plaintext is never an option. |
| `encrypted_cache.max_stale_seconds` | `0` | On NETWORK/TIMEOUT failures only, allow the encrypted last-good cache to be used up to this age. Authentication failures never fall back. Set `>0` for offline resilience. |
| `override_existing` | `true` | When true, Bitwarden values overwrite anything already in env (so rotation in the web app actually takes effect). Flip to `false` if you want `.env` / shell exports to win locally. |
| `auto_install` | `true` | When true, `bws` is auto-downloaded into `~/.hermes/bin/` on first use. |

## Failure modes

Bitwarden never blocks Hermes startup. If anything goes wrong, you'll see a one-line warning in stderr and Hermes continues with whatever credentials `.env` already had:
Bitwarden never blocks Hermes startup. If anything goes wrong, you'll see a one-line warning in stderr (values masked) and Hermes continues with whatever credentials `.env` already had:

| Symptom | Cause | Fix |
|---|---|---|
Expand All @@ -137,12 +170,16 @@ Bitwarden never blocks Hermes startup. If anything goes wrong, you'll see a one-
| `bws binary not available` | `auto_install: false` and `bws` not on PATH | Install manually from [github.com/bitwarden/sdk-sm/releases](https://github.com/bitwarden/sdk-sm/releases) or flip `auto_install` back on |
| `Checksum mismatch` | Download corrupted or tampered | Re-run, will retry; if it persists, file an issue |

Startup warnings now include a `→` remediation line telling you exactly which command fixes the failure.
Startup warnings include a `→` remediation line telling you exactly which command fixes the failure.

## Security notes

- The bootstrap token (`BWS_ACCESS_TOKEN`) is itself sensitive — anyone with it can read every secret the machine account has access to. Treat it the same as any other API key.
- Hermes will refuse to let Bitwarden overwrite the bootstrap token itself, even with `override_existing: true`. If you store `BWS_ACCESS_TOKEN` as a secret inside the project, it's silently skipped during apply.
- The disk cache is **never** plaintext — AES-GCM encrypted by default, keyed off the bootstrap token, with legacy plaintext caches re-encrypted and removed on first read. There is no plaintext write path.
- Secret values are masked in status lines, log output, and terminal output; the vault token, every `*_PASSWORD`, and every applied secret value are stripped from spawned child processes.
- Applied secret values are redacted before anything reaches the model provider — tool results, pre-send context, and terminal output all carry the mask, never the value.
- The 1Password integration follows the same posture: encrypted-only `op_cache.enc.json`, with `OP_SERVICE_ACCOUNT_TOKEN`, `OP_CONNECT_TOKEN`, and `OP_SESSION_*` stripped from children.
- The `bws` binary download is verified against the published SHA-256 checksum from the same GitHub release. Mismatch aborts the install.
- The pinned version (`bws v2.0.0` at time of writing) is updated through PRs to this repo — Hermes does not auto-upgrade `bws` to "latest" because upstream release shapes can change.

Expand Down
2 changes: 1 addition & 1 deletion website/docs/user-guide/secrets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Hermes can pull API keys from external secret managers at process startup instea

Supported:

- [Bitwarden Secrets Manager](./bitwarden) — `bws` CLI, lazy-installed, free tier works.
- [Bitwarden Secrets Manager](./bitwarden) — `bws` CLI, lazy-installed, free tier works. **Encrypted-only disk cache by default: secret values are never persisted as plaintext, never printed in status lines or logs, and never inherited by child processes.**
- [1Password](./onepassword) — `op://` references via the official `op` CLI; service-account or desktop session auth.
- [Command helper](./command) — any CLI vault (`keepassxc-cli`, `secret-tool`, `pass`, custom scripts) via a user-configured helper that prints `KEY=VALUE` lines.

Expand Down
Loading
Loading