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
80 changes: 80 additions & 0 deletions website/docs/user-guide/features/search-steering.md
Original file line number Diff line number Diff line change
@@ -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: <path>` 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
```
19 changes: 15 additions & 4 deletions website/docs/user-guide/secrets/onepassword.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<hermes_home>/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).
Expand Down Expand Up @@ -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
Expand All @@ -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 `<hermes_home>/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 `<hermes_home>/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
Expand Down
1 change: 1 addition & 0 deletions website/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
{
Expand Down
Loading