Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 8 additions & 26 deletions internal/hooks/localization_advised_call_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@ package hooks

import (
"encoding/json"
"strings"
"testing"
)

// A refusal must never be the answer to advice this same hook just gave. When
// a localization marker is live, the access policy's "call a Gortex graph tool
// instead" would be met by the marker refusing exactly that call — two turns
// spent to learn nothing. These fixtures pin that the marker answers the host
// tool directly, and that it hands back the retained answer when it does.
func TestAdvisoryMarkerAnswersTheHostToolsItWouldOtherwiseRedirect(t *testing.T) {
// A non-enforceable localization conclusion is advice, not a termination
// boundary. Host coding/navigation tools may still receive the ordinary access
// policy guidance, but the advisory marker itself must never deny them.
func TestAdvisoryMarkerNeverBlocksHostTools(t *testing.T) {
const answer = "LOCALIZATION (UNCONFIRMED):\n- PRIMARY — storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load"
for _, tool := range []string{"Read", "Grep", "Glob"} {
t.Run(tool, func(t *testing.T) {
Expand All @@ -24,27 +21,14 @@ func TestAdvisoryMarkerAnswersTheHostToolsItWouldOtherwiseRedirect(t *testing.T)
pre := preToolPayload(t, tool, "advised-tool", identity, input)
encoded := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeEnrich) })
if encoded == "" {
t.Fatalf("%s produced no decision under a live marker", tool)
return
}
var output HookOutput
if err := json.Unmarshal([]byte(encoded), &output); err != nil {
t.Fatalf("decode PreToolUse output %q: %v", encoded, err)
}
hso := output.HookSpecificOutput
if hso == nil || hso.PermissionDecision != "deny" {
t.Fatalf("%s was left to the access policy under a live marker: %#v", tool, hso)
}
if !strings.HasPrefix(hso.PermissionDecisionReason, localizationAdvisoryDenyReason) {
t.Fatalf("%s deny reason = %q, want the advisory reason", tool, hso.PermissionDecisionReason)
}
// The whole point of answering here is that the caller gets the
// answer, not another instruction.
if !strings.Contains(hso.PermissionDecisionReason, answer) {
t.Fatalf("%s deny did not carry the retained answer: %q", tool, hso.PermissionDecisionReason)
}
if strings.Contains(hso.PermissionDecisionReason, "instead") &&
strings.Contains(hso.PermissionDecisionReason, "explore") {
t.Fatalf("%s deny still prescribes a call the gate would refuse: %q", tool, hso.PermissionDecisionReason)
if hso := output.HookSpecificOutput; hso != nil && hso.PermissionDecision == "deny" {
t.Fatalf("advisory marker blocked %s: %#v", tool, hso)
}
})
}
Expand All @@ -70,9 +54,7 @@ func TestAdvisoryMarkerStillPassesThroughUnrelatedTools(t *testing.T) {
if err := json.Unmarshal([]byte(encoded), &output); err != nil {
t.Fatalf("decode PreToolUse output %q: %v", encoded, err)
}
if output.HookSpecificOutput != nil &&
output.HookSpecificOutput.PermissionDecision == "deny" &&
strings.HasPrefix(output.HookSpecificOutput.PermissionDecisionReason, localizationAdvisoryDenyReason) {
if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" {
t.Fatalf("advisory marker denied an unrelated tool %s: %#v", tool, output.HookSpecificOutput)
}
})
Expand Down
52 changes: 29 additions & 23 deletions internal/hooks/localization_terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ const (
localizationTerminalAgentHardCap = 64
localizationTerminalJanitorDeletes = 32

localizationTerminalContext = "[Gortex] Localization for this task is complete. Answer now from completion.final_response, naming the files and symbols you rely on; if its evidence does not fit the request, say so and name what does. Either way, do not call another tool."
localizationTerminalDenyReason = "[Gortex] Localization for this task is complete, so this tool call is blocked. Answer now from the retained evidence below, naming what you rely on; if it does not fit the request, say so in your answer."
localizationAdvisoryDenyReason = "[Gortex] Localization for this task is complete, so this additional Gortex navigation call was not run. Answer now from the retained evidence below, naming what you rely on; if it does not fit the request, say so in your answer."
gortexPluginMCPToolPrefix = "mcp__plugin_gortex_gortex__"
localizationHostMetaKey = "gortex/localization"
localizationTerminalContext = "[Gortex] The bounded localization search completed and the retained result is included below. For a localization-only request, answer from the PRIMARY file/symbol tuples. For diagnosis, implementation, or contradictory evidence, continue with the appropriate tools; this conclusion does not block the session."
gortexPluginMCPToolPrefix = "mcp__plugin_gortex_gortex__"
localizationHostMetaKey = "gortex/localization"
)

var localizationNavigationOperations = map[string]struct{}{
Expand All @@ -39,22 +37,27 @@ var localizationNavigationOperations = map[string]struct{}{
"relations": {},
"trace": {},
"analyze": {},
}

// localizationRedirectedHostTools are the host tools whose access-policy deny
// answers with "call a Gortex graph tool instead". While a localization marker
// is live that advice walks the caller straight into a refusal — measured as
// advise-then-refuse pairs, one wasted turn each — so the marker answers these
// tools itself rather than prescribing a call it will not honour.
var localizationRedirectedHostTools = map[string]struct{}{
"Read": {},
"Grep": {},
"Glob": {},
}

func localizationRedirectedHostTool(tool string) bool {
_, redirected := localizationRedirectedHostTools[tool]
return redirected
// Legacy navigation names are normally hidden behind the compact facade,
// but the hook keeps the same boundary as defense in depth for promoted or
// older host tool catalogs.
"smart_context": {}, "context_closure": {}, "get_repo_outline": {},
"plan_turn": {}, "prefetch_context": {}, "suggest_queries": {}, "gortex_wakeup": {},
"search_artifacts": {}, "search_ast": {}, "graph_completion_search": {}, "find_files": {},
"search_symbols": {}, "search_text": {}, "winnow_symbols": {},
"get_artifact": {}, "get_editing_context": {}, "read_file": {}, "get_symbol_history": {},
"get_symbol_source": {}, "get_file_summary": {}, "batch_symbols": {}, "get_symbol": {},
"get_callers": {}, "get_cluster": {}, "find_declaration": {}, "get_dependencies": {},
"get_dependents": {}, "get_class_hierarchy": {}, "find_implementations": {},
"find_import_path": {}, "find_overrides": {}, "check_references": {}, "find_usages": {},
"get_call_chain": {}, "get_cfg": {}, "flow_between": {}, "graph_query": {},
"trace_path": {}, "taint_paths": {}, "walk_graph": {},
"audit_agent_config": {}, "get_architecture": {}, "verify_citation": {}, "find_clones": {},
"find_co_changing_symbols": {}, "get_communities": {}, "contracts": {},
"get_coupling_metrics": {}, "get_extraction_candidates": {}, "audit_health": {},
"run_inspections": {}, "list_inspections": {}, "get_knowledge_gaps": {}, "lint_file": {},
"get_processes": {}, "get_recent_changes": {}, "replay_episode": {},
"get_surprising_connections": {}, "get_untested_symbols": {}, "why": {}, "get_churn_rate": {},
}

var preToolUsePolicyTools = map[string]struct{}{
Expand Down Expand Up @@ -638,13 +641,16 @@ func localizationTerminalIdentityCurrent(identity localizationTerminalIdentity)
}

func markLocalizationTerminal(identity localizationTerminalIdentity, contractVersion int) bool {
return markLocalizationTerminalWithStrength(identity, contractVersion, false, "")
return markLocalizationTerminalWithStrength(identity, contractVersion, true, "")
}

func markLocalizationTerminalReceipt(
identity localizationTerminalIdentity, contractVersion int, enforceable bool, finalResponse string,
identity localizationTerminalIdentity, contractVersion int, _ bool, finalResponse string,
) bool {
return markLocalizationTerminalWithStrength(identity, contractVersion, !enforceable, finalResponse)
// Retained terminal context is always advisory. A response contract may
// classify its evidence as enforceable for replay quality, but that field is
// never permission authority for host tools.
return markLocalizationTerminalWithStrength(identity, contractVersion, true, finalResponse)
}

func markLocalizationTerminalWithStrength(
Expand Down
121 changes: 69 additions & 52 deletions internal/hooks/localization_terminal_receipt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ func TestLocalizationReceiptSurvivesStrippedClaudeWire(t *testing.T) {
}
post := localizationPostToolPayload(t, tt.tool, "tool-use", identity, response)
output := captureHookStdout(t, func() { runPostToolUse(post) })
if !tt.enforceable {
if output != "" {
t.Fatalf("advisory receipt emitted localization-only context: %s", output)
}
if hasLocalizationTerminal(identity) {
t.Fatal("advisory receipt armed hard terminal state")
}
return
}

var decoded HookOutput
if err := json.Unmarshal([]byte(output), &decoded); err != nil {
Expand All @@ -64,16 +73,21 @@ func TestLocalizationReceiptSurvivesStrippedClaudeWire(t *testing.T) {
t.Fatalf("additionalContext changed final_response bytes\n got: %q\nwant: %q", gotContext, wantContext)
}
for _, required := range []string{
"Localization for this task is complete",
"completion.final_response",
"do not call another tool",
"bounded localization search completed",
"retained result",
"PRIMARY file/symbol tuples",
"continue with the appropriate tools",
"does not block the session",
} {
if !strings.Contains(gotContext, required) {
t.Fatalf("additionalContext %q does not contain %q", gotContext, required)
}
}
if got := hasLocalizationTerminal(identity); got != tt.enforceable {
t.Fatalf("hard marker = %v, want %v", got, tt.enforceable)
if strings.Contains(gotContext, "call no tool") {
t.Fatalf("advisory context retained hard-stop wording: %q", gotContext)
}
if hasLocalizationTerminal(identity) {
t.Fatal("terminal context armed a hard marker")
}
})
}
Expand All @@ -100,19 +114,23 @@ func TestLocalizationReceiptMarkerStrengthControlsPreToolUse(t *testing.T) {
t.Fatal("server receipt publish failed")
}
post := localizationPostToolPayload(t, tool, "terminal-tool", identity, strippedToolResponse())
if output := captureHookStdout(t, func() { runPostToolUse(post) }); output == "" {
t.Fatal("authenticated answer_ready did not emit terminal context")
postOutput := captureHookStdout(t, func() { runPostToolUse(post) })
if tt.enforceable && postOutput == "" {
t.Fatal("enforceable answer_ready did not emit terminal context")
}
if !tt.enforceable && postOutput != "" {
t.Fatalf("advisory answer_ready emitted localization-only context: %s", postOutput)
}

marker, marked := localizationTerminalMarkerFor(identity)
if !marked {
t.Fatal("authenticated answer_ready did not persist a marker")
}
if marker.Advisory != !tt.enforceable {
t.Fatalf("marker advisory = %v, want %v", marker.Advisory, !tt.enforceable)
if !marker.Advisory {
t.Fatal("localization receipt created a hard marker")
}
if got := hasLocalizationTerminal(identity); got != tt.enforceable {
t.Fatalf("hard marker = %v, want %v", got, tt.enforceable)
if hasLocalizationTerminal(identity) {
t.Fatal("localization receipt armed hard terminal state")
}

checks := []struct {
Expand All @@ -127,6 +145,8 @@ func TestLocalizationReceiptMarkerStrengthControlsPreToolUse(t *testing.T) {
}{
{name: "direct navigation", tool: gortexMCPToolPrefix + "read", input: map[string]any{"operation": "source"}, navigation: true},
{name: "plugin navigation", tool: gortexPluginMCPToolPrefix + "search", input: map[string]any{"operation": "symbols"}, navigation: true},
{name: "direct legacy search", tool: gortexMCPToolPrefix + "search_symbols", input: map[string]any{"query": "Load"}, navigation: true},
{name: "plugin legacy read", tool: gortexPluginMCPToolPrefix + "read_file", input: map[string]any{"path": "storage.go"}, navigation: true},
{name: "host read", tool: "Read", input: map[string]any{"file_path": "README.md"}, redirected: true},
{name: "host grep", tool: "Grep", input: map[string]any{"pattern": "load"}, redirected: true},
{name: "host glob", tool: "Glob", input: map[string]any{"pattern": "**/*.go"}, redirected: true},
Expand All @@ -144,29 +164,8 @@ func TestLocalizationReceiptMarkerStrengthControlsPreToolUse(t *testing.T) {
t.Fatalf("decode PreToolUse output %q: %v", encoded, err)
}
}
wantDenied := tt.enforceable || check.navigation || check.redirected
if !wantDenied {
if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" {
t.Fatalf("advisory marker denied pass-through tool: %#v", output.HookSpecificOutput)
}
return
}
if output.HookSpecificOutput == nil || output.HookSpecificOutput.PermissionDecision != "deny" {
t.Fatalf("terminal marker did not deny tool: %#v", output)
}
wantReason := localizationAdvisoryDenyReason
if tt.enforceable {
wantReason = localizationTerminalDenyReason
}
got := output.HookSpecificOutput.PermissionDecisionReason
if !strings.HasPrefix(got, wantReason) {
t.Fatalf("terminal deny reason = %q, want prefix %q", got, wantReason)
}
// The refusal hands back the answer, so the caller has
// something to act on instead of another tool to try.
if answer := strings.TrimSpace(marker.FinalResponse); answer != "" &&
!strings.Contains(got, answer) {
t.Fatalf("terminal deny reason %q does not carry the retained answer %q", got, answer)
if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" {
t.Fatalf("advisory marker denied %s: %#v", check.tool, output.HookSpecificOutput)
}
})
}
Expand Down Expand Up @@ -194,27 +193,33 @@ func TestLocalizationTerminalMarkerStrengthIsMonotonic(t *testing.T) {
}

marker, marked := localizationTerminalMarkerFor(identity)
if !marked || marker.Advisory || !hasLocalizationTerminal(identity) {
t.Fatalf("advisory receipt downgraded hard terminal marker: marker=%#v marked=%v", marker, marked)
if !marked || !marker.Advisory || hasLocalizationTerminal(identity) {
t.Fatalf("receipt strength escaped advisory-only policy: marker=%#v marked=%v", marker, marked)
}
raw, err := os.ReadFile(localizationTerminalMarkerPath(identity))
raw, err := os.ReadFile(localizationTerminalAdvisoryMarkerPath(identity))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"advisory"`) {
t.Fatalf("legacy-compatible hard marker unexpectedly encoded advisory field: %s", raw)
if !strings.Contains(string(raw), `"advisory":true`) {
t.Fatalf("terminal marker did not persist advisory strength: %s", raw)
}
for _, tool := range []string{"Read", "WebSearch"} {
for _, tool := range []string{"Read", "WebSearch", gortexMCPToolPrefix + "change"} {
input := map[string]any{"operation": "impact"}
if tool == "Read" {
input = map[string]any{"file_path": "README.md"}
}
encoded := captureHookStdout(t, func() {
runPreToolUse(preToolPayload(t, tool, "", identity, map[string]any{"file_path": "README.md"}), 0, ModeEnrich)
runPreToolUse(preToolPayload(t, tool, "", identity, input), 0, ModeEnrich)
})
if encoded == "" {
continue
}
var output HookOutput
if err := json.Unmarshal([]byte(encoded), &output); err != nil || output.HookSpecificOutput == nil {
t.Fatalf("invalid hard terminal deny for %s: %v\n%s", tool, err, encoded)
if err := json.Unmarshal([]byte(encoded), &output); err != nil {
t.Fatalf("invalid PreToolUse output for %s: %v\n%s", tool, err, encoded)
}
if output.HookSpecificOutput.PermissionDecision != "deny" ||
!strings.HasPrefix(output.HookSpecificOutput.PermissionDecisionReason, localizationTerminalDenyReason) {
t.Fatalf("hard marker did not remain enforceable for %s: %#v", tool, output.HookSpecificOutput)
if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" {
t.Fatalf("advisory marker denied %s: %#v", tool, output.HookSpecificOutput)
}
}
}
Expand Down Expand Up @@ -577,8 +582,11 @@ func TestLocalizationProblemRewriteDirectAndPluginIsIdempotent(t *testing.T) {
if err := json.Unmarshal([]byte(terminalOutput), &terminalDecoded); err != nil || terminalDecoded.HookSpecificOutput == nil {
t.Fatalf("invalid terminal PreToolUse output: %v\n%s", err, terminalOutput)
}
if terminalDecoded.HookSpecificOutput.PermissionDecision != "deny" || terminalDecoded.HookSpecificOutput.UpdatedInput != nil {
t.Fatalf("terminal deny must precede task rewrite: %#v", terminalDecoded.HookSpecificOutput)
if terminalDecoded.HookSpecificOutput.PermissionDecision == "deny" {
t.Fatalf("advisory terminal marker blocked task rewrite: %#v", terminalDecoded.HookSpecificOutput)
}
if got := terminalDecoded.HookSpecificOutput.UpdatedInput["task"]; got != "model paraphrase" {
t.Fatalf("advisory terminal marker changed the continuing task: %#v", terminalDecoded.HookSpecificOutput)
}
})
}
Expand Down Expand Up @@ -814,15 +822,24 @@ func TestSessionStartNamesMountedExploreAndPreservesCompleteTask(t *testing.T) {
"preserves the recovery allowance",
"the rejected request does not count as the accepted recovery",
"Do not call host Read, Grep, Glob, or Bash",
"intentionally not executed and replays the same retained terminal payload",
"not stale or canned output or an integration failure",
"bounded localization is complete and retained",
"do not call another tool merely to repeat the same search",
"An identical `explore.localize` call replays the retained terminal payload",
"Ordinary Gortex tools remain live",
"for contradictory evidence or a diagnosis/change workflow",
} {
if !strings.Contains(briefing, required) {
t.Fatalf("SessionStart rule is missing %q:\n%s", required, briefing)
}
}
if strings.Contains(briefing, "call `explore(operation") {
t.Fatalf("SessionStart still suggests a bare explore call:\n%s", briefing)
for _, forbidden := range []string{
"call `explore(operation",
"Any later Gortex navigation call for that same task is intentionally not executed",
"not stale or canned output or an integration failure",
} {
if strings.Contains(briefing, forbidden) {
t.Fatalf("SessionStart still suggests blocked ordinary navigation %q:\n%s", forbidden, briefing)
}
}
}

Expand Down
Loading
Loading