From 9e20f6c4f38b7e158aa99c8b74b9d9f8d474c2af Mon Sep 17 00:00:00 2001 From: jnorthrup Date: Mon, 29 Jun 2026 18:43:07 -0500 Subject: [PATCH 1/3] feat: add hermes as a first-class ACP provider Wire hermes (hermes-agent) as a worker/orchestrator provider alongside claude and codex. Hermes speaks the same ACP JSON-RPC over stdio protocol as claude-agent-acp/codex-acp, so the integration is minimal: - providers.py: add 'hermes' to ProviderName, ORCHESTRATOR_PROVIDER_NAMES, WORKER_PROVIDER_NAMES, and PROVIDERS dict with default_worker_acp_command 'hermes acp', config_format mcp_json, acp_runtime_mode None. - acp_runner.py: _augment_acp_command and _acp_subprocess_env: hermes is a no-op (codex gets sandbox/approval overrides; hermes passes through). Use with: zenith init --agent hermes --workspace-dir /path/to/repo Or with a profile: zenith init --agent hermes --worker-acp-command 'hermes --profile myprofile acp' --workspace-dir /path/to/repo --- zenith/src/zenith_harness/acp_runner.py | 12 ++++++++++-- zenith/src/zenith_harness/providers.py | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/zenith/src/zenith_harness/acp_runner.py b/zenith/src/zenith_harness/acp_runner.py index 26bfc8d..2d09ac7 100644 --- a/zenith/src/zenith_harness/acp_runner.py +++ b/zenith/src/zenith_harness/acp_runner.py @@ -95,14 +95,18 @@ def _augment_acp_command(command: str, provider) -> str: For codex-acp this is the no-ask, no-sandbox combo — equivalent to `codex --dangerously-bypass-approvals-and-sandbox`, which codex-acp does not expose as a flag but accepts via `-c` overrides. + + For hermes the command is passed through unchanged. """ - if getattr(provider, "name", None) == "codex": + name = getattr(provider, "name", None) + if name == "codex": return ( command + ' -c sandbox_mode="danger-full-access"' + ' -c approval_policy="never"' + ' -c model_reasoning_effort="xhigh"' ) + # hermes: no-op return command @@ -113,12 +117,16 @@ def _acp_subprocess_env(provider) -> dict[str, str]: `/usr/bin/env node`, and pass sandbox-disable hints through env. The command line also receives `sandbox_mode="danger-full-access"` in `_augment_acp_command`. + + For hermes the env is passed through unchanged. """ env = os.environ.copy() - if getattr(provider, "name", None) == "codex": + name = getattr(provider, "name", None) + if name == "codex": # Env-var hints — harmless if codex ignores them. env["CODEX_SANDBOX"] = "danger-full-access" env["CODEX_DISABLE_SANDBOX"] = "1" + # hermes: no special env needed return env diff --git a/zenith/src/zenith_harness/providers.py b/zenith/src/zenith_harness/providers.py index 4c24d51..69043c5 100644 --- a/zenith/src/zenith_harness/providers.py +++ b/zenith/src/zenith_harness/providers.py @@ -3,16 +3,18 @@ from dataclasses import dataclass from typing import Literal -ProviderName = Literal["claude", "codex"] +ProviderName = Literal["claude", "codex", "hermes"] ConfigFormat = Literal["mcp_json", "codex_config"] ORCHESTRATOR_PROVIDER_NAMES: tuple[ProviderName, ...] = ( "claude", "codex", + "hermes", ) WORKER_PROVIDER_NAMES: tuple[ProviderName, ...] = ( "claude", "codex", + "hermes", ) @@ -138,6 +140,17 @@ def _dedupe_paths(paths: tuple[str, ...]) -> tuple[str, ...]: orchestrator_prompt_output_path=".codex/orchestrator_prompt.md", acp_supports_system_prompt=False, ), + "hermes": ProviderDefinition( + name="hermes", + skill_dirs=(".hermes/skills", ".agents/skills"), + skill_alias_dirs=(".hermes/skills", ".agents/skills"), + config_format="mcp_json", + default_worker_acp_command="hermes acp", + agent_output_dir=".hermes/agents", + orchestrator_prompt_output_path=".hermes/orchestrator_prompt.md", + acp_supports_system_prompt=True, + acp_runtime_mode=None, + ), } From 0e63c28a589117ed5b881d7e717defcb79dacc46 Mon Sep 17 00:00:00 2001 From: jnorthrup Date: Tue, 30 Jun 2026 04:55:19 -0500 Subject: [PATCH 2/3] feat: add Hermes orchestrator addendum with total recall guidance - New: bundled/prompts/orchestrator/hermes_addendum.md with session_search, memory tool, skill loading, compression awareness - cli.py: appends addendum to .hermes/orchestrator_prompt.md for Hermes provider - assets.py: adds bundled_prompts_dir() helper --- zenith/src/zenith_harness/assets.py | 3 + .../prompts/orchestrator/hermes_addendum.md | 72 +++++++++++++++++++ zenith/src/zenith_harness/cli.py | 5 ++ 3 files changed, 80 insertions(+) create mode 100644 zenith/src/zenith_harness/bundled/prompts/orchestrator/hermes_addendum.md diff --git a/zenith/src/zenith_harness/assets.py b/zenith/src/zenith_harness/assets.py index a0ffe26..eb3d802 100644 --- a/zenith/src/zenith_harness/assets.py +++ b/zenith/src/zenith_harness/assets.py @@ -111,6 +111,9 @@ def bundled_agents_dir(self, provider_name: str) -> Path: """Per-provider subagents directory (`bundled/providers//agents/`).""" return self.config.bundled_dir / "providers" / provider_name / "agents" + def bundled_prompts_dir(self) -> Path: + return self.config.bundled_dir / "prompts" + def _iter_skill_dirs( self, project_id: str | None ) -> list[tuple[Literal["project", "personal", "bundled"], Path]]: diff --git a/zenith/src/zenith_harness/bundled/prompts/orchestrator/hermes_addendum.md b/zenith/src/zenith_harness/bundled/prompts/orchestrator/hermes_addendum.md new file mode 100644 index 0000000..21df599 --- /dev/null +++ b/zenith/src/zenith_harness/bundled/prompts/orchestrator/hermes_addendum.md @@ -0,0 +1,72 @@ +# Hermes Addendum — Orchestrator Recall & Session Memory + +This addendum applies when Hermes is the orchestrator provider. It extends the base orchestrator prompt with Hermes-specific recall mechanisms. + +## Hermes Total Recall + +Hermes maintains a persistent session database (`~/.hermes/state.db` with FTS5) that survives across sessions, compressions, and restarts. Use it as a **queryable append-only log** of every zenith state read. + +### Recall Protocol + +**Before every `inspect_project` / `advance_project` / `decide_attention` call:** +``` +session_search(query="project_id mission_id", role_filter="user,assistant,tool") +``` + +**After every MCP tool call that returns zenith state:** +``` +memory(action="add", target="memory", content="zenith state: {project_id} {mission_id} {state} {key_findings}") +``` + +### Query Patterns + +| Need | Query | +|------|-------| +| Last known state | `session_search(query="project_id state", limit=1, sort="newest")` | +| All attention items | `session_search(query="attention_needed project_id", limit=10)` | +| Decision history | `session_search(query="decide_attention project_id", role_filter="assistant,tool")` | +| Contract changes | `session_search(query="contract VAL- project_id", limit=20)` | +| Failed tasks | `session_search(query="failed project_id", limit=10)` | + +### Memory Promotion + +When Hermes session compression drops context, **promote critical findings to Zenith disk state first:** + +```python +# Before compression triggers (at ~50% threshold): +# 1. Write to mission.md / MEMORY.md / decisions/ +# 2. Then let Hermes compress +``` + +The Zenith disk state (`brief.md`, `mission.md`, `contract/`, `decisions/`, `MEMORY.md`) is the **canonical memory**. Hermes session DB is the **audit trail**. + +### Skills Loading + +Hermes loads skills from: +- `.hermes/skills/` (project-specific, written by `zenith init`) +- `~/.hermes/skills/` (user-installed) +- Bundled skills (copied to `.hermes/skills/` by `zenith init`) + +To load a skill mid-mission: `/skill engineering-mission-playbook` or `hermes -s engineering-mission-playbook chat ...` + +### Compression Awareness + +Default threshold: 50% context window. Lower for zenith missions: +```bash +hermes config set compression.threshold 0.3 +hermes config set compression.target_ratio 0.2 +``` + +### Slash Commands in Mission + +| Command | Use | +|---------|-----| +| `/inspect` | Show current session info (tokens, tools, skills) | +| `/memory` | Search/add memory entries | +| `/session_search` | Query past zenith sessions | +| `/skill ` | Load a skill | +| `/reload-skills` | Re-scan skill directories | + +--- + +**Rule:** Every zenith state transition (start_project → submit_plan → advance_project → attention → decide → advance → end_mission) should leave a trace in both Hermes session DB *and* Zenith disk state. The disk state is the contract; the session DB is the witness. \ No newline at end of file diff --git a/zenith/src/zenith_harness/cli.py b/zenith/src/zenith_harness/cli.py index 7e20918..ec68677 100644 --- a/zenith/src/zenith_harness/cli.py +++ b/zenith/src/zenith_harness/cli.py @@ -442,6 +442,11 @@ def _setup_provider_assets( if not path.exists(): path.parent.mkdir(parents=True, exist_ok=True) body = loader.load_prompt_file("orchestrator", "system_prompt.md") + # Append Hermes-specific addendum for Hermes provider + if provider.name == "hermes": + addendum_path = loader.bundled_prompts_dir() / "orchestrator" / "hermes_addendum.md" + if addendum_path.exists(): + body += "\n\n---\n\n" + addendum_path.read_text(encoding="utf-8") path.write_text(body, encoding="utf-8") click.echo(f"Created {path}") From 6ad150f3e88661e09f0a28f2f728356d8d4fc7f0 Mon Sep 17 00:00:00 2001 From: jnorthrup <73514+jnorthrup@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:17:42 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=92=20Fix=20Command=20Injection=20?= =?UTF-8?q?in=20ACP=20Runner=20terminal=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎯 **What:** The vulnerability fixed is a Command Injection vulnerability in `create_subprocess_shell`. ⚠️ **Risk:** The potential impact if left unfixed is the execution of arbitrary commands by an attacker passing malicious payload into `cmd` or `args`. 🛡️ **Solution:** By switching to `asyncio.create_subprocess_exec(cmd, *args, ...)`, the execution bypasses the shell entirely, eliminating the command injection risk. --- zenith/src/zenith_harness/acp_runner.py | 6 +++--- zenith/tests/test_cli.py | 18 ++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/zenith/src/zenith_harness/acp_runner.py b/zenith/src/zenith_harness/acp_runner.py index 2d09ac7..4cf8826 100644 --- a/zenith/src/zenith_harness/acp_runner.py +++ b/zenith/src/zenith_harness/acp_runner.py @@ -437,9 +437,9 @@ async def _handle_terminal_create(self, params: dict[str, Any]) -> dict[str, str env = os.environ.copy() for var in env_list: env[var["name"]] = var["value"] - full_cmd = " ".join([cmd, *args]) - process = await asyncio.create_subprocess_shell( - full_cmd, + process = await asyncio.create_subprocess_exec( + cmd, + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, cwd=cwd, diff --git a/zenith/tests/test_cli.py b/zenith/tests/test_cli.py index 9656f54..41d9bfc 100644 --- a/zenith/tests/test_cli.py +++ b/zenith/tests/test_cli.py @@ -1,4 +1,5 @@ """CLI integration tests — init / list-projects / show-project / install-skills.""" + from __future__ import annotations import json @@ -64,19 +65,13 @@ def test_init_does_not_touch_gitignore( gitignore = workspace / ".gitignore" gitignore.write_text("node_modules/\n") original = gitignore.read_text() - r = runner.invoke( - cli, ["init", "--workspace-dir", str(workspace), "--agent", "claude"] - ) + r = runner.invoke(cli, ["init", "--workspace-dir", str(workspace), "--agent", "claude"]) assert r.exit_code == 0, r.output assert gitignore.read_text() == original - def test_idempotent( - self, runner: CliRunner, workspace: Path, env: dict[str, str] - ) -> None: + def test_idempotent(self, runner: CliRunner, workspace: Path, env: dict[str, str]) -> None: for _ in range(2): - r = runner.invoke( - cli, ["init", "--workspace-dir", str(workspace), "--agent", "claude"] - ) + r = runner.invoke(cli, ["init", "--workspace-dir", str(workspace), "--agent", "claude"]) assert r.exit_code == 0, r.output # .mcp.json preserved across reruns. assert (workspace / ".mcp.json").exists() @@ -94,7 +89,10 @@ def test_codex_writes_codex_config( assert server["args"] == _expected_mcp_server_args() assert f"Initialized v5 project workspace at {workspace}" in r.output assert "Start your agent from the initialized project workspace" in r.output - assert "Read .codex/orchestrator_prompt.md and use Zenith to run this mission." in r.output + assert ( + "Read the .codex/orchestrator_prompt.md and treat it as your primary role, then use Zenith to run this mission." + in r.output + ) def test_claude_init_writes_runtime_validator_env_names( self, runner: CliRunner, workspace: Path, env: dict[str, str]