Files
ECC/hooks/hermes/config-protection.py
T
ravitejarenangi b30a8c782a fix(hooks): address review — close bypasses, surface failures, add test suite
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.
2026-09-08 12:38:29 +05:30

117 lines
4.6 KiB
Python
Executable File

#!/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.
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
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 _warn(msg: str) -> None:
sys.stderr.write(f"[ecc-config-protection] {msg}\n")
def main() -> int:
raw = sys.stdin.read()
try:
payload = json.loads(raw) if raw.strip() else {}
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 "")
if tool not in EDIT_TOOLS:
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"):
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:
_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.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
if __name__ == "__main__":
sys.exit(main())