diff --git a/website/docs/user-guide/features/search-steering.md b/website/docs/user-guide/features/search-steering.md new file mode 100644 index 000000000000..44792618750a --- /dev/null +++ b/website/docs/user-guide/features/search-steering.md @@ -0,0 +1,80 @@ +--- +sidebar_position: 96 +title: "Search Steering" +description: "Zero-match search steering and multi-path recovery — actionable hints when a content search comes up empty" +--- + +# Search Steering + +A content search that returns zero matches is a dead turn for an agent: the model knows the text exists somewhere, the search says it doesn't, and there is nothing in the result to explain the gap. **Search Steering** fixes that. When `search_files` finds nothing, Hermes runs a few cheap probes and attaches an actionable hint — wrong casing, regex metacharacters that need escaping, or matches hiding in hidden/gitignored files — so the model can correct course on the next call instead of retrying the same query. + +It also recovers from sloppy multi-path inputs. Models routinely pass several search roots in one string (`"dir1 dir2"` or comma-separated). Instead of failing the whole call when one of them doesn't exist, Hermes searches every path that does exist, merges the results, and tells you what it skipped. + +## Why zero-match steering matters + +A bare `0 matches` result gives the model nothing to steer by. The most common causes of a false zero are cheap to check and cheap to fix: + +- **Wrong casing** — the pattern is case-sensitive by default, and the real text differs in case. +- **Regex metacharacters** — the pattern is interpreted as a regex, so `.`, `*`, `(`, `[`, etc. silently change what is being searched for. +- **Hidden or gitignored files** — ripgrep skips dot-directories and `.gitignore`d files by default, so a match that lives only in `.hidden/` or a vendored directory is invisible to the normal search. + +A hint costs one or two short commands and converts a dead turn into a corrected one. Without it, the model's only moves are guessing, re-searching the same pattern, or burning a read on a directory listing. + +## How the probes work + +When a content search returns exactly zero results, Hermes runs a bounded probe sequence — count-only, with a short timeout — and attaches the first finding as a warning on the result: + +1. **Case-insensitive probe.** Re-runs the pattern with case-insensitive matching. If it hits, the hint explains that casing may be wrong. +2. **Hidden/ignored probe.** Re-runs with hidden files and ignore rules included. If the only matches live there, the hint says so explicitly. +3. **Literal probe.** Only when the pattern contains regex metacharacters, re-runs it as a fixed string. If that hits, the pattern was being interpreted as regex. + +The probes are cheap by design: count-only output, capped output lines, short timeouts, and at most a handful of invocations per zero-match result. They only run when the search genuinely found nothing — a normal search with matches pays no probe cost. + +:::info +Probes never change the search results themselves. They only add a `warning` field to the zero-match result; the model sees the same empty result it would have seen, plus the hint that explains it. +::: + +## Multi-path recovery + +Models often pass multiple search roots as a single `path` string — `"src tests"` or `"src,tests,docs"`. Hermes treats a path that doesn't exist as a potential multi-path input: + +- The string is split on whitespace and commas. +- Every candidate that exists is searched, and the results are **merged** into a single result set. +- Missing candidates are skipped and reported in a note: `path contained 3 entries; searched 2 that exist; skipped missing: vendor`. +- When more than three paths are missing, the note caps the list: `skipped missing: a, b, c (+4 more)`. + +This applies to both content searches and file-name searches, and the merged result respects the caller's `limit` across all searched paths. + +If the input is a **single** path and it doesn't exist, multi-path recovery doesn't apply — Hermes keeps the `Path not found: ` error, and when the parent directory exists it adds similar-path suggestions so the model can spot a typo (`Similar paths: src/utils, src/utilities`). + +## Engine support + +Content search prefers **ripgrep** when it is available (faster, respects `.gitignore` and hidden-file defaults) and falls back to **grep** when it isn't. The steering pass runs after either engine: the probes are engine-agnostic and the hints are identical whether the search was executed by ripgrep or by the grep fallback. If neither engine is installed, the search returns a clear install hint for ripgrep. + +## What you'll see + +Zero-match content searches now carry a warning explaining the near-miss, instead of a bare zero: + +``` +0 exact matches, but 4 case-insensitive match(es) in 2 file(s) — the pattern's casing may be wrong. +``` + +``` +0 matches in visible files, but 3 match(es) in 1 hidden or gitignored file(s) — these are excluded by default. Search the hidden path explicitly to include them. +``` + +``` +0 regex matches, but 12 literal match(es) — the pattern contains regex metacharacters that likely need escaping (or pass a simpler substring). +``` + +Multi-path searches with a missing entry report the merge in the result's note: + +``` +path contained 3 entries; searched 2 that exist; skipped missing: vendor +``` + +A single nonexistent path keeps the familiar error, with a typo hint when one can be inferred: + +``` +Path not found: srce/utils. Similar paths: src/utils, src/utilities +``` diff --git a/website/docs/user-guide/secrets/onepassword.md b/website/docs/user-guide/secrets/onepassword.md index 787d996cb67b..dedabcfa8eb1 100644 --- a/website/docs/user-guide/secrets/onepassword.md +++ b/website/docs/user-guide/secrets/onepassword.md @@ -2,6 +2,14 @@ Resolve provider API keys from [1Password](https://1password.com/) at process startup instead of storing them in plaintext inside `~/.hermes/.env`. You keep your keys as 1Password items and reference them by `op://vault/item/field`; rotating a credential becomes a single change in 1Password. +## The security posture is the feature + +The plaintext-secrets vulnerability class does not exist here. Resolved values live in Hermes' process memory and nowhere else; every disclosure path is closed by design: + +- **Never persisted as plaintext.** The disk cache is encrypted-only: resolved values are written to `/cache/op_cache.enc.json` under AES-GCM, and a plaintext cache file is never written. Any legacy plaintext `op_cache.json` from older versions is migrated into the encrypted cache and removed on first read. With `cache_ttl_seconds: 0`, cache reuse is fully disabled — every fetch resolves fresh and nothing touches disk at all. +- **Never printed.** Resolved values are masked in status lines and logs. Dry-runs and startup summaries show variable names and `op://` references, never the values themselves. +- **Never inherited by children.** `OP_SERVICE_ACCOUNT_TOKEN`, `OP_CONNECT_TOKEN`, and `OP_SESSION_*` are stripped from every spawned child process; the `op` child gets the credential explicitly through its own minimal allowlisted environment, not by inheritance. Resolved values are never exported into any child's environment. + ## How it works 1. You install the official [1Password CLI](https://developer.1password.com/docs/cli/get-started/) (`op`) and authenticate it — either with a **service-account token** (headless servers) or an **interactive/desktop session** (your laptop). @@ -127,7 +135,7 @@ secrets: | `account` | `""` | Account shorthand / sign-in address passed as `op read --account`. Empty uses `op`'s default account. | | `service_account_token_env` | `OP_SERVICE_ACCOUNT_TOKEN` | Env var Hermes reads the service-account token from. Its value is exported to the `op` child as `OP_SERVICE_ACCOUNT_TOKEN` (the name `op` expects). Leave the var unset to use a desktop/interactive session. | | `binary_path` | `""` | Absolute path to `op`. When set, it is used verbatim and `PATH` is **not** consulted — pin this to avoid trusting whatever `op` appears first on `PATH`. | -| `cache_ttl_seconds` | `300` | How long resolved values are reused (in-process and on disk). Set to `0` to disable **both** cache layers — no values are written to disk at all. | +| `cache_ttl_seconds` | `300` | How long resolved values are reused (in-process and in the encrypted disk cache). Set to `0` to disable fresh-cache reuse — every fetch resolves fresh and no values are written to disk at all. | | `override_existing` | `true` | When true, resolved values overwrite anything already in env (so rotation takes effect). Flip to `false` to let `.env` / shell exports win; those references are then skipped *before* `op` is invoked. | ## Failure modes @@ -146,18 +154,21 @@ Startup warnings now include a `→` remediation line telling you exactly which ## Caching -Successful, complete pulls are cached in-process and on disk under `/cache/op_cache.json` (written atomically, mode `0600`), so back-to-back short-lived `hermes` invocations don't re-shell `op` for every reference. The cache: +Successful, complete pulls are cached in-process and on disk so back-to-back short-lived `hermes` invocations don't re-shell `op` for every reference. The disk cache is **encrypted-only**: resolved values are stored under `/cache/op_cache.enc.json` (written atomically, mode `0600`), encrypted with AES-GCM. A plaintext cache is never written. - stores only resolved secret **values** — never the service-account token or any raw auth material (auth is fingerprinted into the cache key); - is invalidated when the token, account, `OP_SESSION_*` variables, or the set of references change; - is **not** written when a pull had any per-reference error, so a transient auth failure isn't frozen in for the TTL; -- is fully disabled — reads *and* writes — when `cache_ttl_seconds: 0`. +- **migrates and removes** any legacy plaintext `op_cache.json` left by older versions on first read — after that, only the encrypted cache exists on disk; +- is fully disabled — reads *and* writes — when `cache_ttl_seconds: 0`: every fetch resolves fresh and nothing is written to disk at all. ## Security notes - A 1Password service-account token can read every secret the account has access to. Store it in `~/.hermes/.env` (not `config.yaml`), and revoke + regenerate from 1Password if it leaks. - Hermes refuses to let a resolved value overwrite the token env var itself, even with `override_existing: true`. -- The `op` child process gets a minimal allowlisted environment (auth/session vars + `PATH`/`HOME`), not a copy of the full `os.environ`, so post-dotenv provider credentials aren't all inherited by the child. +- The disk cache is **never plaintext** — resolved values are AES-GCM encrypted at rest in `op_cache.enc.json`, and the legacy plaintext cache is migrated and removed on first read. +- `OP_SERVICE_ACCOUNT_TOKEN`, `OP_CONNECT_TOKEN`, and `OP_SESSION_*` are stripped from **every** spawned child process — children never inherit the bootstrap credential, the Connect server token, or the 1Password session. The `op` child gets the credential explicitly: Hermes exports it into the child's own minimal allowlisted environment (auth/session vars + `PATH`/`HOME`), not a copy of the full `os.environ`, so post-dotenv provider credentials aren't inherited either. +- Resolved values are masked in status lines and logs — dry-runs and summaries show variable names and `op://` references, never the values themselves. - References are validated to start with `op://`, and the reference is passed after a `--` option terminator so a crafted value can't be parsed as an `op` flag. ## When NOT to use this diff --git a/website/sidebars.ts b/website/sidebars.ts index 7b1e47098dfb..2eb1004a21dc 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -85,6 +85,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/skins', 'user-guide/features/plugins', 'user-guide/features/built-in-plugins', + 'user-guide/features/search-steering', ], }, {