Skip to content
Closed
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
76 changes: 69 additions & 7 deletions internal/hooks/localization_terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ const (
localizationTerminalAgentHardCap = 64
localizationTerminalJanitorDeletes = 32

localizationTerminalContext = "Gortex localization is complete. Respond to the user now; do not call another tool in this turn."
localizationTerminalDenyReason = "[Gortex] Localization is complete. Respond to the user now; no further tool calls are allowed in this turn."
localizationTerminalContext = "Gortex localization is complete. Respond to the user now; do not call another tool in this turn."
localizationTerminalDenyReason = "[Gortex] Localization is complete. Respond to the user now; no further tool calls are allowed in this turn."
localizationTerminalReplayDirective = "You already hold the localization answer — respond now using final_response. Do not call another tool."
gortexPluginMCPToolPrefix = "mcp__plugin_gortex_gortex__"
localizationHostMetaKey = "gortex/localization"
)
Expand Down Expand Up @@ -98,6 +99,8 @@ type localizationTerminalCompletion struct {
State string `json:"state"`
Scope string `json:"scope"`
RequiredAction string `json:"required_action"`
Instruction string `json:"instruction"`
FinalResponse string `json:"final_response"`
AllowedToolCalls *int `json:"allowed_tool_calls"`
ContractVersion int `json:"contract_version"`
Enforceable bool `json:"enforceable"`
Expand Down Expand Up @@ -126,6 +129,12 @@ type localizationHostEnvelope struct {
Version int `json:"version"`
Contract localizationTerminalContract `json:"contract"`
Evidence json.RawMessage `json:"evidence"`
Replay bool `json:"replay"`
}

type localizationCompactReplay struct {
Directive string `json:"directive"`
FinalResponse string `json:"final_response"`
}

func observeLocalizationTerminal(data []byte) (localizationTerminalHookInput, bool) {
Expand Down Expand Up @@ -167,7 +176,8 @@ func exactLocalizationTerminalContract(raw json.RawMessage) (localizationTermina
if len(visible) == 0 {
visible = response.StructuredContentSnake
}
if len(visible) > 0 {
structured := len(visible) > 0
if structured {
visible, ok = unwrapJSONString(visible)
} else {
visible, ok = exactLocalizationContractContent(response.Content)
Expand All @@ -176,14 +186,66 @@ func exactLocalizationTerminalContract(raw json.RawMessage) (localizationTermina
return localizationTerminalContract{}, false
}
var contract localizationTerminalContract
if err := json.Unmarshal(visible, &contract); err != nil {
if err := json.Unmarshal(visible, &contract); err == nil {
hostContract, hostOK := localizationHostContract(response.Meta)
if hostOK && sameLocalizationTerminalContract(contract, hostContract) {
return contract, true
}
}
if !structured {
return localizationTerminalContract{}, false
}
compact, ok := exactLocalizationCompactReplay(visible)
if !ok {
return localizationTerminalContract{}, false
}
hostContract, ok := localizationHostContract(response.Meta)
if !ok || !sameLocalizationTerminalContract(contract, hostContract) {
envelope, ok := localizationCompactReplayHostEnvelope(response.Meta)
if !ok || compact.FinalResponse != envelope.Contract.Completion.FinalResponse {
return localizationTerminalContract{}, false
}
return contract, true
return envelope.Contract, true
}

func exactLocalizationCompactReplay(raw json.RawMessage) (localizationCompactReplay, bool) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil || len(fields) != 2 {
return localizationCompactReplay{}, false
}
if _, ok := fields["directive"]; !ok {
return localizationCompactReplay{}, false
}
if _, ok := fields["final_response"]; !ok {
return localizationCompactReplay{}, false
}
var compact localizationCompactReplay
if err := json.Unmarshal(raw, &compact); err != nil ||
compact.Directive != localizationTerminalReplayDirective || compact.FinalResponse == "" {
return localizationCompactReplay{}, false
}
return compact, true
}

func localizationCompactReplayHostEnvelope(meta map[string]json.RawMessage) (localizationHostEnvelope, bool) {
raw, ok := meta[localizationHostMetaKey]
if !ok {
return localizationHostEnvelope{}, false
}
raw, ok = unwrapJSONString(raw)
if !ok {
return localizationHostEnvelope{}, false
}
var envelope localizationHostEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil ||
envelope.Version != localizationTerminalHostMetaVersion || !envelope.Replay ||
!enforceableLocalizationTerminalContract(envelope.Contract) {
return localizationHostEnvelope{}, false
}
completion := envelope.Contract.Completion
if completion.ContractVersion != localizationTerminalContractV2 ||
completion.Instruction != localizationTerminalReplayDirective || completion.FinalResponse == "" {
return localizationHostEnvelope{}, false
}
return envelope, true
}

func localizationHostContract(meta map[string]json.RawMessage) (localizationTerminalContract, bool) {
Expand Down
134 changes: 126 additions & 8 deletions internal/hooks/localization_terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,99 @@ func TestObserveLocalizationTerminalRequiresMatchingAuthoritativeMeta(t *testing
}
}

func TestLocalizationTerminalHookFlowDeniesThenPromptRotatesTurn(t *testing.T) {
func TestObserveLocalizationTerminalAcceptsOnlyAuthenticatedCompactReplay(t *testing.T) {
configureLocalizationTerminalTestHome(t)
newResponse := func(t *testing.T) map[string]any {
t.Helper()
const finalResponse = "FILES:\n- repo/source.go\n\nSYMBOLS:\n- repo/source.go::Target\n\nEVIDENCE:\n- #1 repo/source.go — repo/source.go::Target"
contract := terminalContractMap()
completion := completionMap(contract)
completion["instruction"] = localizationTerminalReplayDirective
completion["final_response"] = finalResponse
response := terminalToolResponse(t, contract, true, false)
response["structuredContent"] = map[string]any{
"directive": localizationTerminalReplayDirective,
"final_response": finalResponse,
}
meta := response["_meta"].(map[string]any)
envelope := meta[localizationHostMetaKey].(map[string]any)
envelope["replay"] = true
return response
}
tests := []struct {
name string
mutate func(map[string]any)
want bool
}{
{name: "authentic compact replay", want: true},
{
name: "tampered directive",
mutate: func(response map[string]any) {
response["structuredContent"].(map[string]any)["directive"] = "respond after another tool call"
},
},
{
name: "tampered final response",
mutate: func(response map[string]any) {
response["structuredContent"].(map[string]any)["final_response"] = "FILES:\n- repo/tampered.go"
},
},
{
name: "extra visible field",
mutate: func(response map[string]any) {
response["structuredContent"].(map[string]any)["completion"] = terminalContractMap()["completion"]
},
},
{
name: "replay flag false",
mutate: func(response map[string]any) {
meta := response["_meta"].(map[string]any)
meta[localizationHostMetaKey].(map[string]any)["replay"] = false
},
},
{
name: "advisory metadata contract",
mutate: func(response map[string]any) {
meta := response["_meta"].(map[string]any)
envelope := meta[localizationHostMetaKey].(map[string]any)
completionMap(envelope["contract"].(map[string]any))["enforceable"] = false
},
},
{
name: "non v2 metadata contract",
mutate: func(response map[string]any) {
meta := response["_meta"].(map[string]any)
envelope := meta[localizationHostMetaKey].(map[string]any)
completionMap(envelope["contract"].(map[string]any))["contract_version"] = 3
},
},
{
name: "tampered metadata directive",
mutate: func(response map[string]any) {
meta := response["_meta"].(map[string]any)
envelope := meta[localizationHostMetaKey].(map[string]any)
completionMap(envelope["contract"].(map[string]any))["instruction"] = "respond later"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response := newResponse(t)
if tt.mutate != nil {
tt.mutate(response)
}
identity := beginTestLocalizationTurn(t, t.Name(), "prompt", t.TempDir())
snapshotTestLocalizationTool(t, identity, gortexMCPToolPrefix+"read", "tool")
data := localizationPostToolPayload(t, gortexMCPToolPrefix+"read", "tool", identity, response)
_, observed := observeLocalizationTerminal(data)
if observed != tt.want {
t.Fatalf("observed = %v, want %v", observed, tt.want)
}
})
}
}

func TestLocalizationTerminalHookForwardsGortexReplayDeniesNativeThenPromptRotatesTurn(t *testing.T) {
configureLocalizationTerminalTestHome(t)
sessionID := "terminal-flow"
cwd := t.TempDir()
Expand All @@ -144,14 +236,40 @@ func TestLocalizationTerminalHookFlowDeniesThenPromptRotatesTurn(t *testing.T) {
t.Fatalf("PostToolUse output %q does not contain fixed terminal context", postOutput)
}

pre := preToolPayload(t, "WebSearch", "", identity, nil)
preOutput := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) })
var output HookOutput
if err := json.Unmarshal([]byte(preOutput), &output); err != nil {
t.Fatalf("decode PreToolUse output %q: %v", preOutput, err)
for _, tool := range []string{
gortexMCPToolPrefix + "search",
gortexPluginMCPToolPrefix + "search",
} {
pre := preToolPayload(t, tool, "replay-tool", identity, map[string]any{
"operation": "symbols",
"query": "Target",
})
if got := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) }); got != "" {
t.Fatalf("%s should pass through to the MCP replay, got %q", tool, got)
}
}
if output.HookSpecificOutput == nil || output.HookSpecificOutput.PermissionDecision != "deny" {
t.Fatalf("expected all-tool terminal deny, got %#v", output)

native := []struct {
tool string
input map[string]any
}{
{tool: "Read", input: map[string]any{"file_path": "/repo/source.go"}},
{tool: "Grep", input: map[string]any{"pattern": "Target", "path": "/repo"}},
{tool: "Glob", input: map[string]any{"pattern": "**/*.go", "path": "/repo"}},
{tool: gortexMCPToolPrefix + "edit", input: map[string]any{"operation": "file"}},
}
for _, test := range native {
pre := preToolPayload(t, test.tool, "", identity, test.input)
preOutput := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) })
var output HookOutput
if err := json.Unmarshal([]byte(preOutput), &output); err != nil {
t.Fatalf("decode %s PreToolUse output %q: %v", test.tool, preOutput, err)
}
if output.HookSpecificOutput == nil ||
output.HookSpecificOutput.PermissionDecision != "deny" ||
output.HookSpecificOutput.PermissionDecisionReason != localizationTerminalDenyReason {
t.Fatalf("expected terminal deny for %s, got %#v", test.tool, output)
}
}

beginTestLocalizationTurn(t, sessionID, "prompt-2", cwd)
Expand Down
6 changes: 6 additions & 0 deletions internal/hooks/pretooluse.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) {
// by permissive permission modes. A new user prompt clears the marker.
terminalIdentity, terminalTurnReady := currentLocalizationTurn(input.SessionID, input.PromptID, input.AgentID, input.CWD)
if terminalTurnReady && hasLocalizationTerminal(terminalIdentity) {
// Let only Gortex navigation reach the engine's immutable successful
// replay. Native Read/Grep/Glob and every other tool remain denied here,
// so the host cannot turn terminal localization into unrestricted work.
if localizationNavigationTool(input.ToolName) {
return
}
emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{
HookEventName: "PreToolUse",
PermissionDecision: "deny",
Expand Down
9 changes: 9 additions & 0 deletions internal/mcp/facade_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,11 @@ func decorateLocalizationReadResult(result *mcpgo.CallToolResult, completion loc
"terminal": terminalContract.Terminal,
}
}
if terminalContract.Terminal {
if structured, ok := result.StructuredContent.(map[string]any); ok {
result.StructuredContent = mergeLocalizationTerminalStructuredFields(structured, completion)
}
}
return attachLocalizationHostEnvelope(result, completion, completion.digest)
}

Expand Down Expand Up @@ -437,6 +442,10 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call
freshLocalizeFlow := facade == "explore" && operation == "localize"
newUserTask, invalidBoundary := parseLocalizationNewUserBoundary(facade, operation, req.GetArguments())
if invalidBoundary != nil {
if replay := terminal.replayAnswerReady(facade, operation); replay != nil {
s.recordFacadeTelemetry(facade, operation, facadeOutcomeBlocked, time.Since(started))
return replay, nil
}
s.recordFacadeTelemetry(facade, operation, facadeOutcomeInvalidArgument, time.Since(started))
return invalidBoundary, nil
}
Expand Down
Loading
Loading