a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
269 lines
10 KiB
Go
269 lines
10 KiB
Go
package hooks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
)
|
|
|
|
// decodeHermes decodes a Hermes hook stdout payload, failing the test on
|
|
// invalid JSON. An empty string decodes to the zero decision.
|
|
func decodeHermes(t *testing.T, out string) hermesDecision {
|
|
t.Helper()
|
|
var d hermesDecision
|
|
if strings.TrimSpace(out) == "" {
|
|
return d
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &d); err != nil {
|
|
t.Fatalf("invalid Hermes decision JSON: %v\n%s", err, out)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func TestRunHermes_IgnoresUnknownEvent(t *testing.T) {
|
|
// runHermesPreToolCall / runHermesPreLLMCall both guard on the event
|
|
// name, so a mismatched payload is a clean no-op.
|
|
out := captureStdout(t, func() {
|
|
runHermesPreToolCall([]byte(`{"hook_event_name":"post_tool_call","tool_name":"read_file"}`), 0, ModeDeny)
|
|
})
|
|
if out != "" {
|
|
t.Errorf("non pre_tool_call should be a no-op, got %q", out)
|
|
}
|
|
out = captureStdout(t, func() {
|
|
runHermesPreLLMCall([]byte(`{"hook_event_name":"on_session_start"}`))
|
|
})
|
|
if out != "" {
|
|
t.Errorf("non pre_llm_call should be a no-op, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_ReadFileIndexed_Blocks(t *testing.T) {
|
|
port := newIndexedBridge(t, 12)
|
|
data := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"read_file","tool_input":{"path":"/repo/handler.go"}}`)
|
|
out := captureStdout(t, func() { runHermesPreToolCall(data, port, ModeDeny) })
|
|
|
|
d := decodeHermes(t, out)
|
|
if d.Action != "block" {
|
|
t.Fatalf("expected action=block, got %+v", d)
|
|
}
|
|
if !strings.Contains(d.Message, "/repo/handler.go") {
|
|
t.Errorf("message should name the file: %q", d.Message)
|
|
}
|
|
if !strings.Contains(d.Message, "get_symbol_source") {
|
|
t.Errorf("message should redirect to graph tools: %q", d.Message)
|
|
}
|
|
// Canonical Hermes shape — never Claude's envelope.
|
|
if strings.Contains(out, "hookSpecificOutput") || strings.Contains(out, "permissionDecision") {
|
|
t.Errorf("output leaked the Claude shape: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_ReadFileFilePathKey(t *testing.T) {
|
|
// Claude-style file_path key is also accepted, not just Hermes' path.
|
|
port := newIndexedBridge(t, 3)
|
|
data := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"read_file","tool_input":{"file_path":"/repo/a.go"}}`)
|
|
out := captureStdout(t, func() { runHermesPreToolCall(data, port, ModeDeny) })
|
|
if d := decodeHermes(t, out); d.Action != "block" {
|
|
t.Fatalf("expected block for file_path key, got %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_ReadFileNotIndexed_NoBlock(t *testing.T) {
|
|
// port 0 → bridge unreachable → not indexed → no block in deny mode.
|
|
data := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"read_file","tool_input":{"path":"/tmp/x.go"}}`)
|
|
out := captureStdout(t, func() { runHermesPreToolCall(data, 0, ModeDeny) })
|
|
if out != "" {
|
|
t.Errorf("unindexed read should not block (no soft channel on pre_tool_call), got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_ReadNonSource_NoBlock(t *testing.T) {
|
|
port := newIndexedBridge(t, 99)
|
|
data := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"read_file","tool_input":{"path":"/repo/README.md"}}`)
|
|
out := captureStdout(t, func() { runHermesPreToolCall(data, port, ModeDeny) })
|
|
if out != "" {
|
|
t.Errorf("non-source read should pass through, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_TerminalGrepHit_Blocks(t *testing.T) {
|
|
redirectTelemetry(t)
|
|
stubProbe(t, []grepSymbolHit{
|
|
{Name: "handleFoo", Kind: "function", FilePath: "internal/a.go", Line: 42},
|
|
}, nil)
|
|
|
|
data := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"grep -rn handleFoo ."}}`)
|
|
out := captureStdout(t, func() { runHermesPreToolCall(data, 0, ModeDeny) })
|
|
|
|
d := decodeHermes(t, out)
|
|
if d.Action != "block" {
|
|
t.Fatalf("expected block for terminal grep hit, got %+v", d)
|
|
}
|
|
if !strings.Contains(d.Message, "handleFoo") {
|
|
t.Errorf("message should mention the matched symbol: %q", d.Message)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_TerminalUnrelated_NoBlock(t *testing.T) {
|
|
stubProbe(t, nil, nil)
|
|
data := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"terminal","tool_input":{"command":"go build ./..."}}`)
|
|
out := captureStdout(t, func() { runHermesPreToolCall(data, 0, ModeDeny) })
|
|
if out != "" {
|
|
t.Errorf("unrelated terminal command should pass through, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_EnrichNeverBlocks(t *testing.T) {
|
|
// Indexed read that would block under deny must pass through under
|
|
// enrich — pre_tool_call has no soft-context channel.
|
|
port := newIndexedBridge(t, 20)
|
|
data := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"read_file","tool_input":{"path":"/repo/x.go"}}`)
|
|
out := captureStdout(t, func() { runHermesPreToolCall(data, port, ModeEnrich) })
|
|
if out != "" {
|
|
t.Errorf("enrich mode must never block, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_ConsultUnlock(t *testing.T) {
|
|
t.Setenv(hookSessionDirEnvVar, t.TempDir())
|
|
port := newIndexedBridge(t, 7)
|
|
read := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"read_file","session_id":"s1","tool_input":{"path":"/repo/x.go"}}`)
|
|
|
|
// Before consulting the graph: indexed read is blocked, with the
|
|
// unlock hint appended.
|
|
out := captureStdout(t, func() { runHermesPreToolCall(read, port, ModeConsultUnlock) })
|
|
d := decodeHermes(t, out)
|
|
if d.Action != "block" {
|
|
t.Fatalf("expected block before graph consulted, got %+v", d)
|
|
}
|
|
if !strings.Contains(d.Message, "consult-unlock") {
|
|
t.Errorf("block should carry the unlock hint: %q", d.Message)
|
|
}
|
|
|
|
// A Gortex graph tool call flips the marker (pass-through, no output).
|
|
gortexCall := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"gortex.search_symbols","session_id":"s1","tool_input":{}}`)
|
|
if got := captureStdout(t, func() { runHermesPreToolCall(gortexCall, port, ModeConsultUnlock) }); got != "" {
|
|
t.Errorf("gortex tool call should be a silent pass-through, got %q", got)
|
|
}
|
|
|
|
// After consulting: the same read is no longer blocked.
|
|
if got := captureStdout(t, func() { runHermesPreToolCall(read, port, ModeConsultUnlock) }); got != "" {
|
|
t.Errorf("read should be unlocked after a graph call, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreToolCall_NudgeFiresOncePerBurst(t *testing.T) {
|
|
t.Setenv(hookSessionDirEnvVar, t.TempDir())
|
|
redirectTelemetry(t)
|
|
stubProbe(t, []grepSymbolHit{{Name: "Foo", Kind: "type", FilePath: "a.go", Line: 1}}, nil)
|
|
grep := []byte(`{"hook_event_name":"pre_tool_call","tool_name":"terminal","session_id":"n1","tool_input":{"command":"grep -rn Foo ."}}`)
|
|
|
|
// Below the threshold: no block.
|
|
for i := range nudgeThreshold - 1 {
|
|
if got := captureStdout(t, func() { runHermesPreToolCall(grep, 0, ModeAdaptiveNudge) }); got != "" {
|
|
t.Fatalf("call %d under nudge threshold should not block, got %q", i+1, got)
|
|
}
|
|
}
|
|
// Threshold crossed: one soft-deny.
|
|
out := captureStdout(t, func() { runHermesPreToolCall(grep, 0, ModeAdaptiveNudge) })
|
|
d := decodeHermes(t, out)
|
|
if d.Action != "block" {
|
|
t.Fatalf("expected one nudge block at the threshold, got %+v", d)
|
|
}
|
|
// Streak reset: the next call proceeds.
|
|
if got := captureStdout(t, func() { runHermesPreToolCall(grep, 0, ModeAdaptiveNudge) }); got != "" {
|
|
t.Errorf("post-nudge call should proceed, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreLLMCall_FirstTurnInjectsOrientation(t *testing.T) {
|
|
withFakeStatus(t, func() (*daemon.StatusResponse, error) {
|
|
return nil, errDaemonUnreachable // briefing is emitted even when down
|
|
})
|
|
data := []byte(`{"hook_event_name":"pre_llm_call","is_first_turn":true,"cwd":"/tmp/x"}`)
|
|
out := captureStdout(t, func() { runHermesPreLLMCall(data) })
|
|
|
|
d := decodeHermes(t, out)
|
|
if d.Context == "" {
|
|
t.Fatal("first turn should inject an orientation context")
|
|
}
|
|
if !strings.Contains(d.Context, "Gortex Session Orientation") {
|
|
t.Errorf("context should be the orientation briefing: %q", d.Context)
|
|
}
|
|
if d.Action != "" {
|
|
t.Errorf("pre_llm_call must never block: %+v", d)
|
|
}
|
|
}
|
|
|
|
func TestHermesPreLLMCall_FirstTurnInExtra(t *testing.T) {
|
|
withFakeStatus(t, func() (*daemon.StatusResponse, error) {
|
|
return nil, errDaemonUnreachable
|
|
})
|
|
// Some Hermes builds carry is_first_turn / cwd inside `extra`.
|
|
data := []byte(`{"hook_event_name":"pre_llm_call","extra":{"is_first_turn":true,"cwd":"/tmp/x"}}`)
|
|
out := captureStdout(t, func() { runHermesPreLLMCall(data) })
|
|
if d := decodeHermes(t, out); d.Context == "" {
|
|
t.Fatal("is_first_turn in extra should still inject orientation")
|
|
}
|
|
}
|
|
|
|
func TestHermesPreLLMCall_LaterTurnTrivialPrompt_NoOp(t *testing.T) {
|
|
// Not the first turn, and a trivial message → no probe, no output.
|
|
for _, data := range []string{
|
|
`{"hook_event_name":"pre_llm_call","is_first_turn":false,"user_message":"ok"}`,
|
|
`{"hook_event_name":"pre_llm_call","user_message":"/clear"}`,
|
|
`{"hook_event_name":"pre_llm_call","extra":{"user_message":""}}`,
|
|
} {
|
|
if got := captureStdout(t, func() { runHermesPreLLMCall([]byte(data)) }); got != "" {
|
|
t.Errorf("trivial later-turn prompt should be a no-op, got %q for %s", got, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHermesNormalizeReadInput(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in map[string]any
|
|
want string
|
|
}{
|
|
{"path key", map[string]any{"path": "/a.go"}, "/a.go"},
|
|
{"file_path wins", map[string]any{"file_path": "/b.go", "path": "/a.go"}, "/b.go"},
|
|
{"filename alias", map[string]any{"filename": "/c.go"}, "/c.go"},
|
|
{"file alias", map[string]any{"file": "/d.go"}, "/d.go"},
|
|
{"none", map[string]any{"offset": 10}, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, _ := hermesNormalizeReadInput(tc.in)["file_path"].(string)
|
|
if got != tc.want {
|
|
t.Errorf("file_path = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
// The original keys must survive so enrichRead's narrow-read detection
|
|
// still sees offset/limit.
|
|
out := hermesNormalizeReadInput(map[string]any{"path": "/a.go", "offset": 5, "limit": 10})
|
|
if out["offset"] != 5 || out["limit"] != 10 {
|
|
t.Errorf("narrow-read hints dropped: %#v", out)
|
|
}
|
|
}
|
|
|
|
func TestIsHermesGortexTool(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"read_file": false,
|
|
"terminal": false,
|
|
"gortex.search_symbols": true,
|
|
"gortex__find_usages": true,
|
|
"mcp__gortex__get_symbol": true,
|
|
"web_search": false,
|
|
"": false,
|
|
}
|
|
for name, want := range cases {
|
|
if got := isHermesGortexTool(name); got != want {
|
|
t.Errorf("isHermesGortexTool(%q) = %v, want %v", name, got, want)
|
|
}
|
|
}
|
|
}
|