mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-19 14:31:50 +02:00
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.
117 lines
4.6 KiB
Python
Executable File
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()) |