From 879a17092cb193918240287d40f25774b6627ad4 Mon Sep 17 00:00:00 2001 From: ravitejarenangi Date: Tue, 8 Sep 2026 12:26:52 +0530 Subject: [PATCH 1/4] feat(hooks): add Hermes-native config-protection and console-log guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes ships its own shell-hook system (hooks: block in config.yaml, Claude-compatible JSON payloads, per-tool matchers, consent allowlist), and the hermes install target deliberately ships no hook runtime — so Hermes operators get rules/skills/commands with zero hooks. This ports the two harness-independent ECC hooks as self-contained Python scripts with no ECC runtime dependency: - config-protection: blocks edits to existing lint/format configs and ratchet baselines (pre_tool_call) - check-console-log: warns on console.log() in edited TS/JS (post_tool_call) Includes install, consent, and verification steps in docs/HERMES-HOOKS.md. --- docs/HERMES-HOOKS.md | 83 +++++++++++++++++++++++++++++ hooks/hermes/check-console-log.py | 61 +++++++++++++++++++++ hooks/hermes/config-protection.py | 88 +++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 docs/HERMES-HOOKS.md create mode 100755 hooks/hermes/check-console-log.py create mode 100755 hooks/hermes/config-protection.py diff --git a/docs/HERMES-HOOKS.md b/docs/HERMES-HOOKS.md new file mode 100644 index 000000000..0ced4ea77 --- /dev/null +++ b/docs/HERMES-HOOKS.md @@ -0,0 +1,83 @@ +# Hermes Hooks — Native Guards Without the Hook Runtime + +Hermes ships its own shell-hook system: a `hooks:` block in `~/.hermes/config.yaml`, +Claude-Code-compatible JSON payloads on stdin, per-tool regex matchers, a +first-use consent allowlist, and `hermes hooks list | test | doctor` for +inspection. ECC's `hooks/hooks.json` is Claude Code format, and the Hermes +install target (`--target hermes`) deliberately does not install the hook +runtime — so Hermes operators get ECC's rules, skills, and commands with no +hooks at all. + +This directory ports the two ECC hooks that matter outside Claude Code as +self-contained Hermes-native hooks. No ECC runtime, no `run-with-flags.js`, no +`.pi` extension — just two Python scripts and a config block. + +## Why only these two + +ECC's hook graph is 24 commands. Most of it is Claude Code-specific +(plugin bootstrap, plan canvas, Claude session transcripts, cost tracking +against Claude's usage API) or duplicates the target harness's own gates. +These two are harness-independent policy: + +1. **config-protection** — blocks edits to existing linter/formatter configs + (`.eslintrc*`, `eslint.config.*`, `.prettierrc*`, `biome.json`, `ruff.toml`, + `.stylelintrc*`, markdownlint) and lint ratchet baselines. An agent under + pressure weakens the check instead of fixing the code; this hook refuses + that path. Creating a config from scratch and reading configs stay allowed. +2. **check-console-log** — warns (stderr, never blocks) when an edited + TS/JS file now contains `console.log()`. Tests, `.d.ts`, and scripts are + excluded. + +## Install + +```bash +# 1. Put the scripts anywhere stable (they are referenced by absolute path): +mkdir -p ~/.hermes/hooks +cp hooks/hermes/config-protection.py ~/.hermes/hooks/ecc-config-protection.py +cp hooks/hermes/check-console-log.py ~/.hermes/hooks/ecc-check-console-log.py +chmod +x ~/.hermes/hooks/ecc-*.py + +# 2. Append to ~/.hermes/config.yaml (Hermes wants the mapping keyed by event): +hooks: + pre_tool_call: + - matcher: "patch|write_file|edit|write|apply_patch|str_replace_editor" + command: "python3 /Users/YOU/.hermes/hooks/ecc-config-protection.py" + timeout: 10 + post_tool_call: + - matcher: "patch|write_file|edit|write|apply_patch|str_replace_editor" + command: "python3 /Users/YOU/.hermes/hooks/ecc-check-console-log.py" + timeout: 10 +``` + +Restart Hermes (hooks are registered at session start), then approve the +consent prompt on first use of each hook, or pre-approve with: + +```bash +hermes hooks test pre_tool_call --for-tool patch +hermes hooks test post_tool_call --for-tool write_file +``` + +## Verify + +```bash +hermes hooks list # both hooks, with consent status +hermes hooks doctor # exec bit, allowlist, timing +``` + +Payload-level proof (block case must print a JSON decision; allow cases stay silent): + +```bash +echo '{"hook_event_name":"pre_tool_call","tool_name":"patch","tool_input":{"path":"/repo/eslint.config.mjs","old_string":"a","new_string":"b"}}' \ + | python3 ~/.hermes/hooks/ecc-config-protection.py +# {"decision": "block", "reason": "BLOCKED: modifying eslint.config.mjs ..."} +``` + +## Notes + +- Match only the editing tools your Hermes config exposes; the shipped matcher + covers the common file-edit tool names. +- Hermes matches tool names against the regex with `fullmatch`. +- `post_tool_call` hooks cannot block; stderr is surfaced as a warning line. +- Hooks fail open on malformed payloads by design (a broken hook must not + brick the agent); the config-protection hook emits its refusal as a JSON + `decision`, not an exit code, so a parse error can never block. diff --git a/hooks/hermes/check-console-log.py b/hooks/hermes/check-console-log.py new file mode 100755 index 000000000..c5a615075 --- /dev/null +++ b/hooks/hermes/check-console-log.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +""" +Hermes native hook (ported from ECC's scripts/hooks/check-console-log.js). + +post_tool_call observer: after any file edit, WARN (never block) when the +edited file now contains console.log(). Print to stderr only — Hermes shows +hook stderr as a warning line. + +Contract (agent/shell_hooks.py): + stdin : JSON payload {"hook_event_name":"post_tool_call","tool_name":"...", + "tool_input":{...},"tool_result":{...}} + exit : 0 (observer, no stdout JSON) +""" +import json +import os +import re +import sys + +EDIT_TOOLS = {"patch", "write_file", "edit", "write", "apply_patch", "str_replace_editor"} +EXCLUDE = re.compile(r"(\.d\.ts$|/__tests?__/|\.test\.[tj]sx?$|\.spec\.[tj]sx?$|/scripts/|console-safety)") + + +def main() -> int: + try: + raw = sys.stdin.read() + payload = json.loads(raw) if raw.strip() else {} + except Exception: + return 0 + + tool = str(payload.get("tool_name") or "") + if tool not in EDIT_TOOLS: + return 0 + + ti = payload.get("tool_input") or {} + path = None + for key in ("path", "file_path", "filePath", "target"): + v = ti.get(key) + if isinstance(v, str) and v.strip(): + path = v.strip() + break + if not path or not os.path.isfile(path): + return 0 + if not re.search(r"\.[tj]sx?$", path) or EXCLUDE.search(path): + return 0 + + try: + with open(path, "r", errors="replace") as f: + content = f.read() + except OSError: + return 0 + + if "console.log(" in content: + sys.stderr.write( + f"[hook] console.log() present in {path} — remove it before committing " + "(use the project logger instead)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/hooks/hermes/config-protection.py b/hooks/hermes/config-protection.py new file mode 100755 index 000000000..8e68a222d --- /dev/null +++ b/hooks/hermes/config-protection.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Hermes native hook (ported from ECC's scripts/hooks/config-protection.js). + +pre_tool_call guard: BLOCK edits to existing linter/formatter config files. +Agents under pressure weaken configs to make checks pass instead of fixing +the source; this hook forces the fix to land in code. + +Contract (agent/shell_hooks.py): + stdin : JSON payload {"hook_event_name":"pre_tool_call","tool_name":"...", + "tool_input": {...}} + stdout : JSON {"decision":"block","reason":...} to block, nothing to allow + exit : 0 always (we speak JSON, not exit codes) + +Blocks ONLY file-editing tools, ONLY when the target already exists on disk. +Creating a config from scratch is allowed (new project), and so is reading. +""" +import json +import os +import sys + +PROTECTED = { + ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml", + "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", + "eslint.config.mts", "eslint.config.cts", + ".prettierrc", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.json", ".prettierrc.yml", ".prettierrc.yaml", + "prettier.config.js", "prettier.config.cjs", "prettier.config.mjs", + "biome.json", "biome.jsonc", ".ruff.toml", "ruff.toml", ".shellcheckrc", + ".stylelintrc", ".stylelintrc.json", ".stylelintrc.yml", + ".markdownlint.json", ".markdownlint.yaml", ".markdownlintrc", + # Aarogya ratchets — same class of "weaken the check instead of fixing code" + "lint-ratchet-baseline.json", +} + +EDIT_TOOLS = {"patch", "write_file", "edit", "write", "apply_patch", "str_replace_editor"} + + +def main() -> int: + try: + raw = sys.stdin.read() + payload = json.loads(raw) if raw.strip() else {} + except Exception: + return 0 # unparseable payload never blocks + + tool = str(payload.get("tool_name") or "") + if tool not in EDIT_TOOLS: + return 0 + + ti = payload.get("tool_input") or {} + # Every Hermes/CLI editing tool puts the target somewhere obvious. + candidates = [] + for key in ("path", "file_path", "filePath", "target", "filename", "notebook_path"): + v = ti.get(key) + if isinstance(v, str) and v.strip(): + candidates.append(v.strip()) + for k in ("edits", "operations"): + seq = ti.get(k) + if isinstance(seq, list): + for item in seq: + if isinstance(item, dict): + for key in ("path", "file_path", "filePath"): + v = item.get(key) + if isinstance(v, str) and v.strip(): + candidates.append(v.strip()) + if not candidates: + return 0 + + for p in candidates: + base = os.path.basename(p) + if base in PROTECTED and os.path.exists(p): + print( + json.dumps( + { + "decision": "block", + "reason": ( + f"BLOCKED: modifying {base} is not allowed. Fix the source code so the " + "linter/formatter passes instead of weakening the config. For a legitimate " + "config change, ask the owner to edit it directly." + ), + } + ) + ) + return 0 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file From b30a8c782a1b85673d07c54dec09854ff7c7c7a2 Mon Sep 17 00:00:00 2001 From: ravitejarenangi Date: Tue, 8 Sep 2026 12:38:29 +0530 Subject: [PATCH 2/4] =?UTF-8?q?fix(hooks):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20close=20bypasses,=20surface=20failures,=20add=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile + CodeRabbit review of #3019: - config-protection: compare basenames case-insensitively (macOS/Windows resolve ESLINT.CONFIG.JS to the protected eslint.config.js) and use lstat so a dangling symlink to a protected name still blocks (os.path.exists follows the link and returns False). - Both hooks: fail-open stays, silence goes — every swallowed path now writes a one-line stderr diagnostic. - config-protection: sys.stdout.write instead of print() per repo Python style; block decisions carry a trailing newline. - New tests/hermes-hooks.test.js: 13 cases through the real scripts via spawnSync — block (all names, case variants, dangling symlink, edits[], per-tool), allow (normal source, new config, non-edit tools), fail-open with diagnostics (malformed payload, missing path, unreadable file). - docs: absolute-path copy-paste block via $HOME (CodeRabbit: /Users/YOU placeholders were unverifiable), self-contained block-case proof that creates its target, pointer to the committed suite. --- docs/HERMES-HOOKS.md | 25 +++- hooks/hermes/check-console-log.py | 31 +++- hooks/hermes/config-protection.py | 59 ++++++-- tests/hermes-hooks.test.js | 241 ++++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 26 deletions(-) create mode 100644 tests/hermes-hooks.test.js diff --git a/docs/HERMES-HOOKS.md b/docs/HERMES-HOOKS.md index 0ced4ea77..0c7570650 100644 --- a/docs/HERMES-HOOKS.md +++ b/docs/HERMES-HOOKS.md @@ -37,16 +37,22 @@ cp hooks/hermes/config-protection.py ~/.hermes/hooks/ecc-config-protection.py cp hooks/hermes/check-console-log.py ~/.hermes/hooks/ecc-check-console-log.py chmod +x ~/.hermes/hooks/ecc-*.py -# 2. Append to ~/.hermes/config.yaml (Hermes wants the mapping keyed by event): +# 2. Point the commands at the copies you just made. Substitute YOUR absolute +# home path — Hermes resolves hook commands verbatim, without ~ expansion. +HOOK_DIR="$HOME/.hermes/hooks" + +# 3. Append to ~/.hermes/config.yaml (Hermes wants the mapping keyed by event): +cat >> ~/.hermes/config.yaml < None: + sys.stderr.write(f"[ecc-check-console-log] {msg}\n") + + def main() -> int: + raw = sys.stdin.read() try: - raw = sys.stdin.read() payload = json.loads(raw) if raw.strip() else {} - except Exception: + if not isinstance(payload, dict): + _warn(f"payload is {type(payload).__name__}, not an object — skipping") + return 0 + except ValueError as exc: + _warn(f"unparseable payload ({exc}) — skipping") return 0 tool = str(payload.get("tool_name") or "") @@ -32,26 +43,34 @@ def main() -> int: return 0 ti = payload.get("tool_input") or {} + if not isinstance(ti, dict): + _warn("tool_input is not an object — skipping") + return 0 path = None for key in ("path", "file_path", "filePath", "target"): v = ti.get(key) if isinstance(v, str) and v.strip(): path = v.strip() break - if not path or not os.path.isfile(path): + if not path: + _warn(f"no path found in tool_input for tool {tool!r} — skipping") return 0 if not re.search(r"\.[tj]sx?$", path) or EXCLUDE.search(path): return 0 + if not os.path.isfile(path): + _warn(f"{path} is not a regular file on disk — skipping") + return 0 try: with open(path, "r", errors="replace") as f: content = f.read() - except OSError: + except OSError as exc: + _warn(f"could not read {path} ({exc.strerror or exc}) — skipping") return 0 if "console.log(" in content: - sys.stderr.write( - f"[hook] console.log() present in {path} — remove it before committing " + _warn( + f"console.log() present in {path} — remove it before committing " "(use the project logger instead)." ) return 0 diff --git a/hooks/hermes/config-protection.py b/hooks/hermes/config-protection.py index 8e68a222d..31315f840 100755 --- a/hooks/hermes/config-protection.py +++ b/hooks/hermes/config-protection.py @@ -14,6 +14,10 @@ Contract (agent/shell_hooks.py): Blocks ONLY file-editing tools, ONLY when the target already exists on disk. Creating a config from scratch is allowed (new project), and so is reading. + +Error handling: the hook is fail-open BY DESIGN (a broken hook must not +brick the agent), but it never fails silently — every swallowed path writes +a one-line diagnostic to stderr. """ import json import os @@ -35,11 +39,19 @@ PROTECTED = { EDIT_TOOLS = {"patch", "write_file", "edit", "write", "apply_patch", "str_replace_editor"} +def _warn(msg: str) -> None: + sys.stderr.write(f"[ecc-config-protection] {msg}\n") + + def main() -> int: + raw = sys.stdin.read() try: - raw = sys.stdin.read() payload = json.loads(raw) if raw.strip() else {} - except Exception: + if not isinstance(payload, dict): + _warn(f"payload is {type(payload).__name__}, not an object — allowing") + return 0 + except ValueError as exc: + _warn(f"unparseable payload ({exc}) — allowing") return 0 # unparseable payload never blocks tool = str(payload.get("tool_name") or "") @@ -47,6 +59,9 @@ def main() -> int: return 0 ti = payload.get("tool_input") or {} + if not isinstance(ti, dict): + _warn("tool_input is not an object — allowing") + return 0 # Every Hermes/CLI editing tool puts the target somewhere obvious. candidates = [] for key in ("path", "file_path", "filePath", "target", "filename", "notebook_path"): @@ -63,24 +78,38 @@ def main() -> int: if isinstance(v, str) and v.strip(): candidates.append(v.strip()) if not candidates: + _warn(f"no path found in tool_input for tool {tool!r} — allowing") return 0 for p in candidates: + # Case-insensitive filesystems (macOS default, Windows) resolve + # ESLINT.CONFIG.JS to the protected eslint.config.js; compare the + # basename lowercased so a case variant cannot slip past. base = os.path.basename(p) - if base in PROTECTED and os.path.exists(p): - print( - json.dumps( - { - "decision": "block", - "reason": ( - f"BLOCKED: modifying {base} is not allowed. Fix the source code so the " - "linter/formatter passes instead of weakening the config. For a legitimate " - "config change, ask the owner to edit it directly." - ), - } + if base.lower() in PROTECTED: + # os.path.exists() is False for a dangling symlink, yet editing + # tools happily follow/replace the link target — use lstat so a + # dangling symlink to a protected name still counts as existing. + try: + os.lstat(p) + exists = True + except OSError: + exists = False + if exists: + sys.stdout.write( + json.dumps( + { + "decision": "block", + "reason": ( + f"BLOCKED: modifying {base} is not allowed. Fix the source code so the " + "linter/formatter passes instead of weakening the config. For a legitimate " + "config change, ask the owner to edit it directly." + ), + } + ) + + "\n" ) - ) - return 0 + return 0 return 0 diff --git a/tests/hermes-hooks.test.js b/tests/hermes-hooks.test.js new file mode 100644 index 000000000..64d8cb3d1 --- /dev/null +++ b/tests/hermes-hooks.test.js @@ -0,0 +1,241 @@ +/** + * Tests for hooks/hermes/ — the Hermes-native config-protection and + * console-log guard hooks. + * + * Run standalone: node tests/hermes-hooks.test.js + * Picked up automatically by tests/run-all.js (glob: tests/ star star /.test.js). + * + * Each case feeds a REAL payload through the actual script via spawnSync — + * same stdin/stdout contract Hermes uses — and asserts on the emitted JSON + * or stderr. Fail-open paths additionally assert the diagnostic line, so a + * regression cannot silently disable the policy. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..'); +const GUARD = path.join(repoRoot, 'hooks', 'hermes', 'config-protection.py'); +const LOGGER = path.join(repoRoot, 'hooks', 'hermes', 'check-console-log.py'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +/** Run a hook with a payload; return {rc, stdout, stderr} and assert clean spawn. */ +function runHook(script, payload) { + const res = spawnSync('python3', [script], { + input: JSON.stringify(payload), + encoding: 'utf8', + timeout: 15000, + }); + assert.strictEqual(res.error, undefined, `spawn failed: ${res.error}`); + assert.strictEqual(res.status, 0, `hook must always exit 0 (got ${res.status})`); + return { stdout: res.stdout, stderr: res.stderr }; +} + +function decision(stdout) { + const text = (stdout || '').trim(); + if (!text) return null; + const parsed = JSON.parse(text); + assert.ok( + parsed.decision === 'block' && typeof parsed.reason === 'string' && parsed.reason.length > 0, + `block decision must carry a reason, got: ${text}` + ); + return parsed; +} + +// ─── config-protection: blocking ─────────────────────────────────────────── + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hermes-hooks-')); +const protectedConfigs = [ + 'eslint.config.mjs', + '.eslintrc.json', + 'prettier.config.js', + 'biome.json', + 'ruff.toml', + 'lint-ratchet-baseline.json', +]; + +let passed = 0; +let failed = 0; +const results = []; + +function run(name, fn) { + const ok = test(name, fn); + if (ok) passed += 1; else failed += 1; +} + +run('blocks edits to every protected config name', () => { + for (const name of protectedConfigs) { + const target = path.join(tmp, name); + fs.writeFileSync(target, '{}\n'); + const { stdout } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: 'patch', + tool_input: { path: target, old_string: 'a', new_string: 'b' }, + }); + const d = decision(stdout); + assert.ok(d.reason.includes(name), `reason should name ${name}`); + } +}); + +run('blocks case-variant names (case-insensitive filesystems)', () => { + const target = path.join(tmp, 'ESLINT.CONFIG.MJS'); + fs.writeFileSync(path.join(tmp, 'eslint.config.mjs'), 'export default {}\n'); + // On a case-sensitive filesystem the variant is a distinct name; the hook + // must still refuse it so a macOS/Windows rename cannot smuggle an edit. + const { stdout } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: 'patch', + tool_input: { path: target, old_string: 'a', new_string: 'b' }, + }); + assert.ok(decision(stdout), 'case variant of a protected name must block'); +}); + +run('blocks a dangling symlink to a protected name', () => { + const dangling = path.join(tmp, 'eslint.config.cjs'); + try { fs.unlinkSync(dangling); } catch { /* not present yet */ } + fs.symlinkSync(path.join(tmp, 'does-not-exist-at-all'), dangling); + // Sanity: the link itself exists (lstat succeeds) but resolves nowhere — + // os.path.exists() is False here, which is exactly the bypass being pinned. + assert.ok(fs.lstatSync(dangling).isSymbolicLink(), 'symlink must exist for the case to be real'); + assert.ok(!fs.existsSync(dangling), 'link must dangle for the case to be real'); + const { stdout } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: 'patch', + tool_input: { path: dangling, old_string: 'a', new_string: 'b' }, + }); + assert.ok(decision(stdout), 'dangling symlink to protected name must block'); +}); + +run('blocks paths under edits[] (multi-edit inputs)', () => { + const target = path.join(tmp, '.eslintrc'); + fs.writeFileSync(target, '{}\n'); + const { stdout } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: 'str_replace_editor', + tool_input: { edits: [{ old_string: 'a', new_string: 'b', file_path: target }] }, + }); + assert.ok(decision(stdout), 'nested edits[] path must block'); +}); + +run('blocks for every recognised editing tool', () => { + const target = path.join(tmp, 'biome.json'); + fs.writeFileSync(target, '{}\n'); + for (const tool of ['patch', 'write_file', 'edit', 'write', 'apply_patch', 'str_replace_editor']) { + const { stdout } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: tool, + tool_input: { path: target }, + }); + assert.ok(decision(stdout), `${tool} must be gated`); + } +}); + +// ─── config-protection: allowing ─────────────────────────────────────────── + +run('allows edits to normal source files', () => { + const target = path.join(tmp, 'src.tsx'); + fs.writeFileSync(target, 'export const x = 1;\n'); + const { stdout, stderr } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: 'patch', + tool_input: { path: target, old_string: '1', new_string: '2' }, + }); + assert.strictEqual(stdout.trim(), '', 'normal edits emit nothing'); + assert.strictEqual(stderr.trim(), '', 'and stay silent'); +}); + +run('allows creating a protected config that does not exist yet', () => { + const { stdout } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: 'write_file', + tool_input: { path: path.join(tmp, 'fresh-project', '.eslintrc.json'), content: '{}' }, + }); + assert.strictEqual(stdout.trim(), '', 'first-time config creation is allowed'); +}); + +run('allows non-editing tools (read/search)', () => { + const target = path.join(tmp, '.eslintrc'); + for (const tool of ['read_file', 'search_files', 'terminal']) { + const { stdout } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: tool, + tool_input: { path: target, pattern: 'x' }, + }); + assert.strictEqual(stdout.trim(), '', `${tool} is never gated`); + } +}); + +// ─── config-protection: fail-open + diagnostics ──────────────────────────── + +run('malformed payload: allows AND reports on stderr', () => { + const res = spawnSync('python3', [GUARD], { input: 'not-json{{', encoding: 'utf8', timeout: 15000 }); + assert.strictEqual(res.status, 0, 'must exit 0 (fail-open)'); + assert.strictEqual(res.stdout.trim(), '', 'no decision emitted'); + assert.ok(res.stderr.includes('unparseable payload'), 'diagnostic required'); +}); + +run('missing path in tool_input: allows AND reports', () => { + const { stdout, stderr } = runHook(GUARD, { + hook_event_name: 'pre_tool_call', + tool_name: 'patch', + tool_input: { old_string: 'a', new_string: 'b' }, + }); + assert.strictEqual(stdout.trim(), ''); + assert.ok(stderr.includes('no path found'), 'diagnostic required'); +}); + +// ─── check-console-log ───────────────────────────────────────────────────── + +run('warns when an edited file contains console.log()', () => { + const target = path.join(tmp, 'noisy.ts'); + fs.writeFileSync(target, 'export function f(){ console.log("x"); }\n'); + const { stdout, stderr } = runHook(LOGGER, { + hook_event_name: 'post_tool_call', + tool_name: 'write_file', + tool_input: { path: target, content: 'x' }, + }); + assert.strictEqual(stdout.trim(), '', 'observer emits no stdout JSON'); + assert.ok(stderr.includes('console.log() present'), 'warning required'); +}); + +run('stays silent for clean files, tests, and non-edit tools', () => { + const clean = path.join(tmp, 'clean.ts'); + fs.writeFileSync(clean, 'export function g(){ return 1; }\n'); + const spec = path.join(tmp, 'thing.spec.ts'); + fs.writeFileSync(spec, 'console.log("in a spec");\n'); + for (const [tool, p] of [['write_file', clean], ['write_file', spec], ['read_file', clean]]) { + const { stdout, stderr } = runHook(LOGGER, { + hook_event_name: 'post_tool_call', + tool_name: tool, + tool_input: { path: p }, + }); + assert.strictEqual(stdout.trim(), '', 'no stdout'); + assert.ok(!stderr.includes('console.log() present'), `${p} must not warn`); + } +}); + +run('unreadable file: skips AND reports on stderr', () => { + const { stderr } = runHook(LOGGER, { + hook_event_name: 'post_tool_call', + tool_name: 'write_file', + tool_input: { path: path.join(tmp, 'not-on-disk.ts') }, + }); + assert.ok(/not a regular file|skipping/.test(stderr), 'diagnostic required'); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +if (failed > 0) process.exit(1); From df77893e0a39cdb7107390bfd5e4b8865838f861 Mon Sep 17 00:00:00 2001 From: ravitejarenangi Date: Tue, 8 Sep 2026 13:03:06 +0530 Subject: [PATCH 3/4] =?UTF-8?q?feat(install):=20hermes-hooks=20install=20m?= =?UTF-8?q?odule=20=E2=80=94=20installer-native=20delivery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile's review asked for committed coverage and installer parity; this makes the hooks installable through ECC's own machinery instead of a copy-paste doc: - manifests/install-modules.json: new module hermes-hooks (kind hooks, targets hermes + openclaw, paths hooks/hermes + docs/HERMES-HOOKS.md, light/stable, defaultInstall). Disjoint from hooks-runtime, which stays Claude/Cursor/OpenCode/CodeBuddy — the Hermes target gets hooks support without inheriting the Claude-format hook graph. - manifests/install-profiles.json: full profile gains hermes-hooks (validator requires full to include every module). - docs/HERMES-HOOKS.md: install section now leads with ./install.sh --target hermes --modules hermes-hooks --enable-hooks; the manual block registers from the landed ~/.hermes/hooks/hermes/ paths. The installer never edits config.yaml — registration stays operator-owned. - tests/hermes-hooks.test.js: installer-integration case (14 total) drives the real installer into a throwaway HOME and proves the LANDED guard blocks an eslint.config edit. Validated: install-manifests (37 modules), no-personal-paths, markdownlint, 14/14 tests. E2E on a throwaway HOME: module lands both scripts + doc and the landed guard emits a block decision. --- docs/HERMES-HOOKS.md | 23 ++++++++++++++--------- manifests/install-modules.json | 17 +++++++++++++++++ manifests/install-profiles.json | 3 ++- tests/hermes-hooks.test.js | 23 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/docs/HERMES-HOOKS.md b/docs/HERMES-HOOKS.md index 0c7570650..6d9926b19 100644 --- a/docs/HERMES-HOOKS.md +++ b/docs/HERMES-HOOKS.md @@ -30,18 +30,23 @@ These two are harness-independent policy: ## Install +Preferred — through the ECC installer (module `hermes-hooks`): + ```bash -# 1. Put the scripts anywhere stable (they are referenced by absolute path): -mkdir -p ~/.hermes/hooks -cp hooks/hermes/config-protection.py ~/.hermes/hooks/ecc-config-protection.py -cp hooks/hermes/check-console-log.py ~/.hermes/hooks/ecc-check-console-log.py -chmod +x ~/.hermes/hooks/ecc-*.py +./install.sh --target hermes --modules hermes-hooks --enable-hooks +# or: node scripts/install-apply.js --target hermes --modules hermes-hooks --enable-hooks +``` -# 2. Point the commands at the copies you just made. Substitute YOUR absolute -# home path — Hermes resolves hook commands verbatim, without ~ expansion. -HOOK_DIR="$HOME/.hermes/hooks" +This lands `hooks/hermes/*.py` under `~/.hermes/hooks/hermes/` and this guide +under `~/.hermes/docs/`. Then register the hooks (the installer deliberately +never edits your `config.yaml`): -# 3. Append to ~/.hermes/config.yaml (Hermes wants the mapping keyed by event): +```bash +# Scripts are already at ~/.hermes/hooks/hermes/. Point the commands at them — +# Hermes resolves hook commands verbatim, without ~ expansion. +HOOK_DIR="$HOME/.hermes/hooks/hermes" + +# Append to ~/.hermes/config.yaml (Hermes wants the mapping keyed by event): cat >> ~/.hermes/config.yaml < { assert.ok(/not a regular file|skipping/.test(stderr), 'diagnostic required'); }); + +// --- installer integration: the hermes-hooks module installs and the landed copy runs --- +run('hermes-hooks module installs via the real installer and the landed guard blocks', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-mod-')); + const inst = spawnSync('node', ['scripts/install-apply.js', '--target', 'hermes', '--modules', 'hermes-hooks', '--enable-hooks'], { + cwd: repoRoot, encoding: 'utf8', env: { ...process.env, HOME: home }, timeout: 60000, + }); + assert.strictEqual(inst.status, 0, 'installer must exit 0'); + const landed = path.join(home, '.hermes', 'hooks', 'hermes', 'config-protection.py'); + assert.ok(fs.existsSync(landed), 'guard must land under ~/.hermes/hooks/hermes/'); + assert.ok(fs.existsSync(path.join(home, '.hermes', 'docs', 'HERMES-HOOKS.md')), 'doc must land'); + const t = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-guard-')); + const target = path.join(t, 'eslint.config.mjs'); + fs.writeFileSync(target, 'export default {}\n'); + const g = spawnSync('python3', [landed], { + input: JSON.stringify({ hook_event_name: 'pre_tool_call', tool_name: 'patch', tool_input: { path: target, old_string: 'a', new_string: 'b' } }), + encoding: 'utf8', timeout: 15000, + }); + assert.ok((g.stdout || '').includes('"decision": "block"'), 'landed guard must block'); + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(t, { recursive: true, force: true }); +}); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); if (failed > 0) process.exit(1); From 9c21c20744dae408c2728065b9f48f0c2d05b544 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:49:53 -0400 Subject: [PATCH 4/4] docs: match Hermes hook examples to installed filenames --- docs/HERMES-HOOKS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/HERMES-HOOKS.md b/docs/HERMES-HOOKS.md index 6d9926b19..d9d8c701a 100644 --- a/docs/HERMES-HOOKS.md +++ b/docs/HERMES-HOOKS.md @@ -51,11 +51,11 @@ cat >> ~/.hermes/config.yaml <