mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 12:15:29 +02:00
merge: preserve tested Hermes hooks source 3019
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# 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
|
||||
|
||||
Preferred — through the ECC installer (module `hermes-hooks`):
|
||||
|
||||
```bash
|
||||
./install.sh --target hermes --modules hermes-hooks --enable-hooks
|
||||
# or: node scripts/install-apply.js --target hermes --modules hermes-hooks --enable-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`):
|
||||
|
||||
```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 <<EOF
|
||||
hooks:
|
||||
pre_tool_call:
|
||||
- matcher: "patch|write_file|edit|write|apply_patch|str_replace_editor"
|
||||
command: "python3 $HOOK_DIR/config-protection.py"
|
||||
timeout: 10
|
||||
post_tool_call:
|
||||
- matcher: "patch|write_file|edit|write|apply_patch|str_replace_editor"
|
||||
command: "python3 $HOOK_DIR/check-console-log.py"
|
||||
timeout: 10
|
||||
EOF
|
||||
```
|
||||
|
||||
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 — create a protected file first, so the block case has a
|
||||
real target (the hook only blocks edits to configs that already exist):
|
||||
|
||||
```bash
|
||||
T=$(mktemp -d) && touch "$T/eslint.config.mjs"
|
||||
echo "{\"hook_event_name\":\"pre_tool_call\",\"tool_name\":\"patch\",\"tool_input\":{\"path\":\"$T/eslint.config.mjs\",\"old_string\":\"a\",\"new_string\":\"b\"}}" \
|
||||
| python3 ~/.hermes/hooks/hermes/config-protection.py
|
||||
# {"decision": "block", "reason": "BLOCKED: modifying eslint.config.mjs ..."}
|
||||
```
|
||||
|
||||
Or run the committed suite, which exercises block, allow, case variants,
|
||||
dangling symlinks, multi-edit inputs, and fail-open diagnostics:
|
||||
|
||||
```bash
|
||||
node tests/hermes-hooks.test.js
|
||||
```
|
||||
|
||||
## 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.
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/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)
|
||||
|
||||
Error handling: fail-open by design (observers must not brick the agent),
|
||||
but never silent — every swallowed path writes a one-line stderr diagnostic.
|
||||
"""
|
||||
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 _warn(msg: str) -> None:
|
||||
sys.stderr.write(f"[ecc-check-console-log] {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 — skipping")
|
||||
return 0
|
||||
except ValueError as exc:
|
||||
_warn(f"unparseable payload ({exc}) — skipping")
|
||||
return 0
|
||||
|
||||
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 — 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:
|
||||
_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 as exc:
|
||||
_warn(f"could not read {path} ({exc.strerror or exc}) — skipping")
|
||||
return 0
|
||||
|
||||
if "console.log(" in content:
|
||||
_warn(
|
||||
f"console.log() present in {path} — remove it before committing "
|
||||
"(use the project logger instead)."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/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())
|
||||
@@ -109,6 +109,23 @@
|
||||
"cost": "medium",
|
||||
"stability": "stable"
|
||||
},
|
||||
{
|
||||
"id": "hermes-hooks",
|
||||
"kind": "hooks",
|
||||
"description": "Hermes-native guard hooks (config protection, console.log warning) plus install guide.",
|
||||
"paths": [
|
||||
"hooks/hermes",
|
||||
"docs/HERMES-HOOKS.md"
|
||||
],
|
||||
"targets": [
|
||||
"hermes",
|
||||
"openclaw"
|
||||
],
|
||||
"dependencies": [],
|
||||
"defaultInstall": true,
|
||||
"cost": "light",
|
||||
"stability": "stable"
|
||||
},
|
||||
{
|
||||
"id": "platform-configs",
|
||||
"kind": "platform",
|
||||
|
||||
@@ -100,7 +100,8 @@
|
||||
"devops-infra",
|
||||
"machine-learning",
|
||||
"supply-chain-domain",
|
||||
"document-processing"
|
||||
"document-processing",
|
||||
"hermes-hooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
|
||||
|
||||
// --- 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);
|
||||
Reference in New Issue
Block a user