Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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)
}
}
}