From 2750ed9fb88c37e188b2e115238df228984a7935 Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 11:54:40 +0300 Subject: [PATCH 1/2] feat: make global checklists work with Hermes Agent (and every polyhook caller) - Read stdin through `polyhook::read_from` so `polyhook::respond` knows the caller and answers in its own wire format (fixes #196, supersedes #197). Hermes gets `{"action":"block","message":...}`; Claude Code gets a PreToolUse `permissionDecision: "deny"` instead of the legacy top-level `decision: "block"` that ended the whole session. - Prefer `$HERMES_REAL_HOME` over `HOME` when resolving the global directory, so Hermes' sandboxed HOME (containers, TERMINAL_HOME_MODE=profile) sees the same global checklists as every other agent. - Tests: Claude Code and Hermes response shapes, Hermes session scoping and on_session_end cleanup, and a binary test with a sandboxed HOME. - Docs: Hermes Agent setup (pre_tool_call + on_session_end hooks, consent, `hermes hooks test`). - Promote clippy::map_flatten to deny. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + Installation.md | 31 ++++++++- README.md | 4 +- core/Cargo.toml | 1 + core/src/bin/main.rs | 12 ++-- core/src/bin/main_tests.rs | 112 ++++++++++++++++++++++++++++++++ core/src/global_config.rs | 19 +++++- core/src/global_config_tests.rs | 38 +++++++++++ core/tests/cli.rs | 53 +++++++++++++++ 9 files changed, 261 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b62f964..cd4c346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Global checklists: `$STEPLOCK_GLOBAL_DIR`, `$XDG_CONFIG_HOME/steplock` or `~/.config/steplock` holds checklists that apply to every project; project checklists with the same name override them - `steplock init --global` and `steplock clean --global`; `steplock validate` also checks global checklists - `run_with_global` and `global_steplock_dir` library API +- Hermes Agent setup guide (`pre_tool_call` + `on_session_end` shell hooks) - `steplock init` command creates `.steplock/checklists/` and `.gitignore` skeleton - `session:stop` event cleans up the session directory so the checklist resets - `allow_preview_request` config option generates a `preview.sh` showing checklist progress @@ -24,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Incrementally ratcheted clippy deny list (14 lints and counting) ### Fixed +- Hook responses use the calling agent's own format again (fixes #196). The CLI parsed stdin directly instead of reading it through polyhook, so the detected caller was lost and every response used the legacy Claude Code shape. In Claude Code that top-level `decision: "block"` ended the whole session instead of denying one tool call, and agents that don't accept that shape let the call through. +- Global checklists resolve to the same directory under Hermes Agent when it sandboxes `HOME` (containers, `TERMINAL_HOME_MODE=profile`): `$HERMES_REAL_HOME` is preferred over `HOME` - `ack.sh` exits 0 with a message when the session is already complete - Unknown CLI arguments now exit 1 with a usage hint instead of silently doing nothing - `on_tool` is now optional in `config.toml` (omit to match any tool) diff --git a/Installation.md b/Installation.md index 861b55b..81c32fb 100644 --- a/Installation.md +++ b/Installation.md @@ -149,7 +149,36 @@ In your project's `.claude/settings.json`: } ``` -To gate every project, put the same block in `~/.claude/settings.json` and add your checklists to the global steplock directory (see [Global checklists](#global-checklists)). +To gate every project, put the same block in `~/.claude/settings.json` and add your checklists to the global steplock directory (see [Global checklists](#global-checklists)). The same global checklists apply to every agent you register the hook in, such as [Hermes Agent](#hermes-agent). + +### Hermes Agent + +In `~/.hermes/config.yaml`: + +```yaml +hooks: + pre_tool_call: + - matcher: "terminal" + command: "steplock" + timeout: 10 + on_session_end: + - command: "steplock" + timeout: 10 +``` + +- `pre_tool_call` with `matcher: "terminal"` gates shell commands. polyhook maps Hermes `terminal` to `bash`, so checklists with `on_tool = "bash"` work unchanged. +- `on_session_end` removes the session state when the Hermes session ends. +- steplock answers in Hermes's own format (`{"action": "block", "message": ...}`). +- Hermes asks once to approve each new hook command. In non-interactive contexts (gateway, cron), set `hooks_auto_accept: true` or `HERMES_ACCEPT_HOOKS=1`. Restart Hermes after changing hooks. +- Check the setup with `hermes hooks doctor`, then fire it with a test payload: + + ```sh + echo '{"args": {"command": "git push origin main"}, "session_id": "check"}' > /tmp/push.json + hermes hooks test pre_tool_call --for-tool terminal --payload-file /tmp/push.json + # parsed (Hermes wire shape): {"action": "block", "message": "[example-gate: 1/2] ..."} + ``` + +Hermes runs hooks in the directory where Hermes started, not in the terminal tool's working directory. Global checklists do not depend on this. A project `.steplock/` is found only when Hermes was started inside that project. ### Cursor / Windsurf / Cline / Amp diff --git a/README.md b/README.md index f337d04..543d26f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,9 @@ Rules: - A project checklist with the same directory name replaces the global one. An empty `.steplock/checklists//` directory turns that global checklist off for the project. - Session state and the audit log for global checklists are written to the global directory, not to the project. -Run `steplock init --global` to create the directory with a sample checklist. Register the hook once in your user-level tool settings (for Claude Code, `~/.claude/settings.json`) so it runs in every project. +Run `steplock init --global` to create the directory with a sample checklist. Register the hook once in each agent's user-level settings so it runs in every project: `~/.claude/settings.json` for Claude Code, `~/.hermes/config.yaml` for Hermes Agent (see [Installation](Installation.md#hermes-agent)). Every agent reads the same global directory, so one checklist gates all of them. + +Some agents give hooks a sandboxed `HOME`. Hermes Agent does this in containers and with `TERMINAL_HOME_MODE=profile`, and exports the real home as `HERMES_REAL_HOME`. steplock uses that real home, so the global directory stays the same. To pin it explicitly, set `STEPLOCK_GLOBAL_DIR`. --- diff --git a/core/Cargo.toml b/core/Cargo.toml index bd02780..b7565b7 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -27,6 +27,7 @@ missing_copy_implementations = "deny" # least one more (see .steplock/checklists/pre-push). [lints.clippy] dbg_macro = "deny" +map_flatten = "deny" todo = "deny" unimplemented = "deny" manual_string_new = "deny" diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index 4231822..b3a29c1 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -8,7 +8,6 @@ use std::path::{Path, PathBuf}; use std::process; use clap::{Parser, Subcommand}; -use polyhook::parse; use steplock::{global_steplock_dir, run_with_global, HookEvent, HookResponse}; /// Extra help text shown after the generated command list. @@ -254,12 +253,11 @@ fn run_app( repo_root: &Path, global_dir: Option<&Path>, ) -> Result { - let mut bytes = Vec::new(); - reader - .read_to_end(&mut bytes) - .map_err(|e| format!("steplock: failed to read hook input: {e}"))?; - - let ph_event = parse::parse_event(&bytes) + // Read through `polyhook::read_from`, not `parse::parse_event`: reading records the + // detected caller (Claude Code, Hermes, Cursor, ...) that `polyhook::respond` needs to + // answer in that agent's own wire format. Parsing raw bytes leaves it unset, so every + // response falls back to the legacy Claude Code shape. + let ph_event = polyhook::read_from(&mut reader) .map_err(|e| format!("steplock: failed to read hook input: {e}"))?; let event = polyhook_to_hook_event(ph_event); diff --git a/core/src/bin/main_tests.rs b/core/src/bin/main_tests.rs index 9b38c26..1f936c6 100644 --- a/core/src/bin/main_tests.rs +++ b/core/src/bin/main_tests.rs @@ -1,6 +1,7 @@ //! Unit tests for `main`. use super::*; use clap::CommandFactory; +use polyhook::parse; use std::fs; use std::iter; use tempfile::TempDir; @@ -365,3 +366,114 @@ fn cli_help_and_version_are_not_errors() { fn cli_definition_is_valid() { Cli::command().debug_assert(); } + +fn hermes_stdin(cmd: &str, session: &str) -> String { + serde_json::json!({ + "hook_event_name": "pre_tool_call", + "tool_name": "terminal", + "tool_input": { "command": cmd }, + "session_id": session, + "cwd": "/tmp/project", + "extra": { "task_id": "t1", "tool_call_id": "c1" } + }) + .to_string() +} + +/// Serialize through the same path `run_hook` uses, so the assertion covers the caller +/// context that `run_app` records while reading stdin. +fn respond_json(resp: &polyhook::HookResponse) -> serde_json::Value { + let mut buf = Vec::new(); + polyhook::respond_to(&mut buf, resp).unwrap(); + serde_json::from_slice(&buf).unwrap() +} + +#[test] +fn claude_code_block_denies_the_tool_call_not_the_session() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let stdin = claude_stdin("git push origin main", "s1"); + let json = respond_json(&run_app(stdin.as_bytes(), tmp.path(), None).unwrap()); + assert!( + json.get("decision").is_none(), + "legacy session-level block: {json}" + ); + let decision = json + .get("hookSpecificOutput") + .and_then(|o| o.get("permissionDecision")) + .and_then(serde_json::Value::as_str); + assert_eq!( + decision, + Some("deny"), + "expected PreToolUse deny, got {json}" + ); +} + +#[test] +fn hermes_terminal_command_blocks_in_hermes_format() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let stdin = hermes_stdin("git push origin main", "hermes-s1"); + let json = respond_json(&run_app(stdin.as_bytes(), tmp.path(), None).unwrap()); + assert_eq!( + json.get("action").and_then(serde_json::Value::as_str), + Some("block"), + "expected Hermes block, got {json}" + ); + let message = json + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert!( + message.contains("ack.sh"), + "block message must carry the ack command: {json}" + ); + assert!( + tmp.path() + .join(".steplock/sessions/hermes-s1/quality-gate/state.json") + .exists(), + "Hermes session_id must scope the session state" + ); +} + +#[test] +fn hermes_non_matching_command_approves() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let stdin = hermes_stdin("ls -la", "hermes-s1"); + let json = respond_json(&run_app(stdin.as_bytes(), tmp.path(), None).unwrap()); + assert_eq!( + json, + serde_json::json!({}), + "Hermes approve is an empty object" + ); +} + +#[test] +fn hermes_session_end_cleans_global_session() { + let project = TempDir::new().unwrap(); + let global = TempDir::new().unwrap(); + setup_checklist(global.path().join("x").as_path()); + // Global dir layout is `/checklists/`, so move the sample under it. + fs::rename(global.path().join("x/.steplock"), global.path().join("g")).unwrap(); + let global_dir = global.path().join("g"); + + let push = hermes_stdin("git push", "hermes-s2"); + run_app(push.as_bytes(), project.path(), Some(&global_dir)).unwrap(); + assert!( + global_dir.join("sessions/hermes-s2").exists(), + "global session created" + ); + + let end = serde_json::json!({ + "hook_event_name": "on_session_end", + "session_id": "hermes-s2", + "cwd": "/tmp/project", + "extra": {} + }) + .to_string(); + run_app(end.as_bytes(), project.path(), Some(&global_dir)).unwrap(); + assert!( + !global_dir.join("sessions/hermes-s2").exists(), + "on_session_end must clean the global session" + ); +} diff --git a/core/src/global_config.rs b/core/src/global_config.rs index 228db0e..5529322 100644 --- a/core/src/global_config.rs +++ b/core/src/global_config.rs @@ -10,12 +10,19 @@ use std::path::PathBuf; /// Set it to an empty string to turn global checklists off. pub const GLOBAL_DIR_ENV: &str = "STEPLOCK_GLOBAL_DIR"; +/// Variables agents set to the user's real home when they give hooks a sandboxed `HOME`. +/// Hermes Agent points `HOME` at a per-profile directory in containers or with +/// `TERMINAL_HOME_MODE=profile`, and exports the real home as `HERMES_REAL_HOME`. Reading it +/// keeps the global directory the same no matter which agent runs the hook. +const REAL_HOME_ENVS: [&str; 1] = ["HERMES_REAL_HOME"]; + /// Resolve the global steplock directory from the process environment. /// /// Lookup order: /// 1. `$STEPLOCK_GLOBAL_DIR` — used as-is; an empty value disables global checklists. /// 2. `$XDG_CONFIG_HOME/steplock` — when `XDG_CONFIG_HOME` is set to an absolute path. -/// 3. `/.config/steplock`, where `` comes from [`dirs::home_dir`]. +/// 3. `/.config/steplock`. `` is the real home an agent reports (such as +/// `$HERMES_REAL_HOME`) when it is an absolute path, else [`dirs::home_dir`]. /// /// Returns `None` when global checklists are disabled or no home directory is known. /// The directory is not required to exist. @@ -25,7 +32,7 @@ pub fn global_steplock_dir() -> Option { } /// Resolve the global steplock directory with `var` as the environment lookup and `home` -/// as the user's home directory. +/// as the process home directory (used when no agent reports a real home). fn resolve_global_dir( var: impl Fn(&str) -> Option, home: Option, @@ -42,7 +49,13 @@ fn resolve_global_dir( return Some(xdg.join("steplock")); } } - home.map(|home| home.join(".config").join("steplock")) + let real_home = REAL_HOME_ENVS + .iter() + .filter_map(|key| var(key).map(PathBuf::from)) + .find(|dir| dir.is_absolute()); + real_home + .or(home) + .map(|home| home.join(".config").join("steplock")) } #[cfg(test)] diff --git a/core/src/global_config_tests.rs b/core/src/global_config_tests.rs index f50c8b5..41db5d8 100644 --- a/core/src/global_config_tests.rs +++ b/core/src/global_config_tests.rs @@ -81,3 +81,41 @@ fn none_without_home() { "no env and no home means no global dir" ); } + +#[test] +fn prefers_agent_real_home_over_sandboxed_home() { + let dir = resolve_global_dir( + lookup(&[("HERMES_REAL_HOME", abs("real").into())]), + Some(abs("profile-home")), + ); + assert_eq!( + dir, + Some(home_config("real")), + "HERMES_REAL_HOME must win over a sandboxed HOME" + ); +} + +#[test] +fn ignores_relative_agent_real_home() { + let dir = resolve_global_dir( + lookup(&[("HERMES_REAL_HOME", "relative".into())]), + Some(abs("home")), + ); + assert_eq!( + dir, + Some(home_config("home")), + "relative HERMES_REAL_HOME must fall back to the home directory" + ); +} + +#[test] +fn xdg_config_home_wins_over_agent_real_home() { + let dir = resolve_global_dir( + lookup(&[ + ("XDG_CONFIG_HOME", abs("xdg").into()), + ("HERMES_REAL_HOME", abs("real").into()), + ]), + Some(abs("home")), + ); + assert_eq!(dir, Some(abs("xdg").join("steplock")), "XDG path expected"); +} diff --git a/core/tests/cli.rs b/core/tests/cli.rs index b156006..4effc9f 100644 --- a/core/tests/cli.rs +++ b/core/tests/cli.rs @@ -360,3 +360,56 @@ fn clean_global_removes_global_sessions() { "global session dir must be removed" ); } + +#[test] +fn hermes_hook_uses_real_home_global_checklist_when_home_is_sandboxed() { + let project = TempDir::new().unwrap(); + let real_home = TempDir::new().unwrap(); + let profile_home = TempDir::new().unwrap(); + global_checklist( + &real_home.path().join(".config/steplock"), + "push-gate", + "Shared push question?", + ); + let stdin = serde_json::json!({ + "hook_event_name": "pre_tool_call", + "tool_name": "terminal", + "tool_input": { "command": "git push origin main" }, + "session_id": "hermes-cli", + "cwd": project.path(), + "extra": {} + }) + .to_string(); + + let mut child = Command::new(STEPLOCK) + .current_dir(project.path()) + .env_remove("STEPLOCK_GLOBAL_DIR") + .env_remove("XDG_CONFIG_HOME") + .env("HOME", profile_home.path()) + .env("USERPROFILE", profile_home.path()) + .env("HERMES_REAL_HOME", real_home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn steplock"); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + json.get("action").and_then(serde_json::Value::as_str), + Some("block"), + "expected Hermes block, got {json}" + ); + assert!( + json.get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .contains("Shared push question?"), + "global checklist from the real home must run: {json}" + ); +} From c6aba61104a1bc5d1aa6848b4c7c0ae9c3fe82bb Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 13:01:34 +0300 Subject: [PATCH 2/2] test: rename tests/cli.rs to tests/cli_tests.rs Matches the _tests.rs naming used for unit tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/tests/{cli.rs => cli_tests.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename core/tests/{cli.rs => cli_tests.rs} (100%) diff --git a/core/tests/cli.rs b/core/tests/cli_tests.rs similarity index 100% rename from core/tests/cli.rs rename to core/tests/cli_tests.rs