Skip to content
Merged
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
30 changes: 30 additions & 0 deletions docs/user-guide/runtimes/hermes.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,36 @@ tool selection**. Hermes gives you the knobs:
Keeping the live tool count small is the single highest-leverage thing you can do
for reliability on a local model.

## Keeping it current after a repo update

**An installed profile is a copy, not a link.** `hermes profile install` writes
`~/.hermes/profiles/f0sectools/config.yaml` once; pulling a new server into this
repo does not change it. A profile installed before a server shipped simply will
not list that server, and Hermes reports no error — the tools are just absent.

After pulling a version that adds or renames a server, re-install over the
existing profile:

```bash
cd /path/to/sec-tools
hermes profile install ./integrations/hermes/distribution
```

Hermes backs up the previous `config.yaml` alongside it. If you have hand-edited
that file (model routing, gateways, personalities), diff the backup afterwards
and re-apply your changes — the distribution ships our wiring, not your local
tuning.

To check without installing, compare the two lists:

```bash
diff <(grep -oE 'f0-[a-z-]+:' integrations/hermes/distribution/config.yaml | sort -u) \
<(grep -oE 'f0-[a-z-]+:' ~/.hermes/profiles/f0sectools/config.yaml | sort -u)
```

The equivalent for pi is `scripts/sync_pi_config.py` (see the pi guide), which
merges rather than overwrites and can be wired as a `post-merge` hook.

## Profiles: deployment pattern

A Hermes **profile** is a fully isolated installation — its own `HERMES_HOME`
Expand Down
8 changes: 4 additions & 4 deletions integrations/hermes/distribution/distribution.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: f0sectools
version: 0.2.0
version: 0.3.0
description: >
F0RT1KA security-operations agent — read-only SOC/IR/CISO tooling over
Microsoft Defender, Entra ID, LimaCharlie, ProjectAchilles, Intune, and
Tenable, driven by a local small model. Gated writes for ProjectAchilles
validation runs (flag + human confirmation + audit).
Microsoft Sentinel, Defender, Entra ID, Intune, Purview, LimaCharlie,
Tenable and ProjectAchilles, driven by a local small model. Gated writes
for ProjectAchilles validation runs (flag + human confirmation + audit).
author: F0RT1KA Contributors
license: Apache-2.0
hermes_requires: ">=0.18.0"
Expand Down
31 changes: 31 additions & 0 deletions integrations/test_integrations_valid.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,34 @@ def test_distribution_config_valid():
assert cfg["mcp_servers"]["f0-pa-actions"]["enabled"] is False
# No operator-specific model config is baked in (config.yaml is preserved on update).
assert "model" not in cfg and "providers" not in cfg


def test_distribution_manifest_names_every_platform_it_ships():
"""The manifest description is what an operator reads before installing.

It listed six platforms while the distribution wired nine — Purview and
Sentinel shipped and nobody updated the prose, so the profile under-sold
itself and misdescribed its own contents. `mcp_servers` completeness was
already guarded; the sentence a human reads was not.
"""
manifest = yaml.safe_load(
(ROOT / "integrations/hermes/distribution/distribution.yaml").read_text(encoding="utf-8")
)
description = manifest["description"].lower()
missing = sorted(
{p.name.removesuffix("-mcp").split("-")[0] for p in (ROOT / "servers").iterdir()
if p.is_dir()}
- {word for word in description.replace(",", " ").split()}
)
# projectachilles-actions collapses onto projectachilles; both start the same.
missing = [m for m in missing if m not in description]
assert missing == [], f"distribution.yaml description omits: {missing}"


def test_distribution_version_tracks_the_repo_version():
"""A profile pinned at an old version tells operators nothing changed."""
manifest = yaml.safe_load(
(ROOT / "integrations/hermes/distribution/distribution.yaml").read_text(encoding="utf-8")
)
root = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
assert str(manifest["version"]) == root["project"]["version"]
36 changes: 36 additions & 0 deletions scripts/sync_pi_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,41 @@ def render_mcp_json(template_text: str, repo_root: Path, uv_path: str) -> str:
return text


def merge_into_existing(rendered: str, target: Path) -> str:
"""Keep servers the live config has that this repo does not ship.

A pi install is shared: this machine also carries `f0-library` from the
sibling offensive-testing repo. Writing the rendered template wholesale
deletes any such entry — the operator adds a server and silently loses a
different one, the only trace being a .bak file they have no reason to read.

Ownership is decided by whether an entry references this checkout, not by
whether the template still names it. "Absent from the template" would keep a
server we renamed or removed alive forever, pointing at a command that no
longer exists — the same silent drift in the opposite direction.
"""
if not target.is_file():
return rendered
try:
existing = json.loads(target.read_text(encoding="utf-8")).get("mcpServers", {})
except (ValueError, OSError):
return rendered # unreadable or not JSON: replace it rather than guess
if not isinstance(existing, dict):
return rendered
config = json.loads(rendered)
ours = config.get("mcpServers", {})
marker = str(REPO)
foreign = {
name: entry
for name, entry in existing.items()
if name not in ours and marker not in json.dumps(entry)
}
if not foreign:
return rendered
config["mcpServers"] = {**foreign, **ours}
return json.dumps(config, indent=2) + "\n"


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--pi-home", default=str(Path.home() / ".pi" / "agent"))
Expand All @@ -55,6 +90,7 @@ def main(argv: list[str] | None = None) -> int:
changed = False

target = pi_home / "mcp.json"
rendered = merge_into_existing(rendered, target)
if target.is_file() and target.read_text(encoding="utf-8") == rendered:
print(f"mcp.json up to date ({target})")
else:
Expand Down
40 changes: 40 additions & 0 deletions scripts/test_sync_pi_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pytest

from scripts import sync_pi_config as sync
from scripts.sync_pi_config import PLACEHOLDER, TEMPLATE, render_mcp_json


Expand All @@ -23,3 +24,42 @@ def test_render_substitutes_path_and_uv_and_stays_valid_json():
def test_render_rejects_broken_template():
with pytest.raises(json.JSONDecodeError):
render_mcp_json('{"mcpServers": ', Path("/opt/checkout"), "uv")


def test_sync_preserves_servers_this_repo_does_not_own(tmp_path, monkeypatch):
"""A live pi install commonly carries MCP servers from other checkouts —
this machine has `f0-library` from the sibling repo. Rendering the template
wholesale silently deletes them: the operator asked to add a server and
lost one, with the only trace a .bak file.
"""
pi_home = tmp_path / "agent"
pi_home.mkdir()
(pi_home / "mcp.json").write_text(json.dumps({"mcpServers": {
"f0-library": {"command": "uv", "args": ["run", "f0-library-mcp"]},
"f0-defender": {"command": "uv", "args": ["run", "--stale", "f0-defender-mcp"]},
}}))
sync.main(["--pi-home", str(pi_home)])
result = json.loads((pi_home / "mcp.json").read_text())["mcpServers"]
assert "f0-library" in result, "a foreign server must survive the sync"
assert "f0-sentinel" in result, "every server this repo ships must be installed"
assert "--stale" not in json.dumps(result["f0-defender"]), "ours are refreshed"


def test_sync_drops_a_server_this_repo_no_longer_ships(tmp_path):
""""Foreign" cannot mean "absent from the template": a server we renamed or
removed would then be preserved forever, pointing at a command that no
longer exists. Ownership is decided by whether the entry references this
checkout, so a stale entry of ours is dropped while a sibling repo's is not.
"""
pi_home = tmp_path / "agent"
pi_home.mkdir()
(pi_home / "mcp.json").write_text(json.dumps({"mcpServers": {
"f0-library": {"command": "uv", "args": ["run", "--directory",
"/elsewhere/f0_library", "f0-library-mcp"]},
"f0-retired": {"command": "uv", "args": ["run", "--directory",
str(sync.REPO), "f0-retired-mcp"]},
}}))
sync.main(["--pi-home", str(pi_home)])
result = json.loads((pi_home / "mcp.json").read_text())["mcpServers"]
assert "f0-library" in result, "another checkout's server is not ours to remove"
assert "f0-retired" not in result, "a server we no longer ship must not linger"
Loading