From 45922d293a10341d31d86a398a5ca65765ff30c0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 11 Jul 2026 15:18:11 +0200 Subject: [PATCH 1/5] feat(codex): add hook effectiveness telemetry --- docs/agents.md | 19 ++++- internal/hooks/bash_classify.go | 13 ++++ internal/hooks/bash_classify_test.go | 7 +- internal/hooks/codex.go | 70 ++++++++++++++---- internal/hooks/codex_test.go | 106 ++++++++++++++++++++------- internal/hooks/posttooluse.go | 11 +-- internal/hooks/pretooluse.go | 20 ++--- internal/hooks/telemetry.go | 44 +++++++++++ internal/hooks/userpromptsubmit.go | 9 ++- 9 files changed, 235 insertions(+), 64 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 8929e3ee3..0cf9a012c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -206,9 +206,9 @@ Current Codex hook coverage: | Surface | Coverage | | ------- | -------- | | `SessionStart` | Matches `startup|resume|clear|compact` and emits graph-tools orientation for new, resumed, cleared, and compacted sessions. | -| Bash `PreToolUse` | Soft graph guidance for shell search/read/list shapes. | +| Bash `PreToolUse` | Soft graph guidance for shell search and source-read shapes. | | Gortex MCP read-tool `PreToolUse` | `read_file` and `get_editing_context` guidance that nudges source reads toward `compress_bodies`. | -| Bash `PostToolUse` | Output graph enrichment for Bash-wrapped grep/search, source-read, and file-list shapes. | +| Bash `PostToolUse` | Output graph enrichment for Bash-wrapped grep/search, source-read (`cat`/`head`/`tail`/`sed`/`awk`), and file-list (`find`/`fd`/`ls`/`tree`/`git ls-files`) shapes. Unknown Bash remains a no-op. | | `gortex init --hooks-only` | Refreshes Codex hooks without rewriting the MCP server config, `AGENTS.md`, or other adapter surfaces. | We do not install separate Codex `PreCompact` or `PostCompact` hooks @@ -219,6 +219,21 @@ after compaction. We also intentionally do not install `Stop`; Codex should be tracked separately if needed. `apply_patch` remains out of scope for Codex hooks and should be handled by a dedicated follow-up. +### Codex hook follow-ups + +The intentionally unsupported Codex surfaces are tracked as follow-up work: + +- `Stop`, `PreCompact`, and `PostCompact` lifecycle behavior; +- `apply_patch` and other mutation-aware post-tool handling; +- hard deny, input rewrite, and output suppression modes. + +Codex hook effectiveness is written as privacy-safe JSONL alongside hook +decisions (`~/.gortex/cache/hook-decisions.jsonl`): one record per Codex event +with event/tool, whether context was emitted, daemon-check status, alternation +segment count, and latency. Commands, prompts, paths, and tool output are not +recorded. Aggregate by `event` and `emitted_context` to detect adoption or +daemon-connectivity regressions. + ### continue Continue.dev still accepts JSON block files under diff --git a/internal/hooks/bash_classify.go b/internal/hooks/bash_classify.go index 3af5245fa..0aa65dc02 100644 --- a/internal/hooks/bash_classify.go +++ b/internal/hooks/bash_classify.go @@ -17,6 +17,9 @@ const ( // BashActionReadSource means the command reads an indexed-looking source // file (cat/head/tail of .go/.ts/…). Path holds the file path. BashActionReadSource + // BashActionFileList is a primary command whose stdout is a file list. It + // is used only for PostToolUse enrichment; PreToolUse remains silent. + BashActionFileList ) // BashClassification is the result of classifyBashCommand. @@ -72,6 +75,16 @@ func classifyBashCommand(cmd string) BashClassification { if path, ok := extractReadFile(tokens); ok { return BashClassification{Action: BashActionReadSource, Path: path, Primary: tokens[0]} } + case "sed", "awk": + if path, ok := extractReadFile(tokens); ok { + return BashClassification{Action: BashActionReadSource, Path: path, Primary: tokens[0]} + } + case "fd", "fdfind", "ls", "tree": + return BashClassification{Action: BashActionFileList, Primary: tokens[0]} + case "git": + if len(tokens) >= 2 && tokens[1] == "ls-files" { + return BashClassification{Action: BashActionFileList, Primary: "git ls-files"} + } } } return BashClassification{Action: BashActionPassthrough} diff --git a/internal/hooks/bash_classify_test.go b/internal/hooks/bash_classify_test.go index 46a687783..42f9e2d88 100644 --- a/internal/hooks/bash_classify_test.go +++ b/internal/hooks/bash_classify_test.go @@ -52,7 +52,12 @@ func TestClassifyBashCommand(t *testing.T) { // --- passthroughs --- {"empty", ``, BashActionPassthrough, ""}, {"whitespace only", ` `, BashActionPassthrough, ""}, - {"ls", `ls /repo`, BashActionPassthrough, ""}, + {"ls file list", `ls /repo`, BashActionFileList, ""}, + {"fd file list", `fd '\\.go$' internal`, BashActionFileList, ""}, + {"tree file list", `tree internal`, BashActionFileList, ""}, + {"git ls-files", `git ls-files '*.go'`, BashActionFileList, ""}, + {"sed source", `sed -n '1,20p' internal/a.go`, BashActionReadSource, "internal/a.go"}, + {"awk source", `awk '{print}' internal/a.go`, BashActionReadSource, "internal/a.go"}, {"go build", `go build ./...`, BashActionPassthrough, ""}, {"echo", `echo hello`, BashActionPassthrough, ""}, } diff --git a/internal/hooks/codex.go b/internal/hooks/codex.go index 7fb2dfe50..f0bee3f05 100644 --- a/internal/hooks/codex.go +++ b/internal/hooks/codex.go @@ -4,6 +4,9 @@ import ( "encoding/json" "io" "os" + "time" + + "github.com/zzet/gortex/internal/daemon" ) // RunCodex handles the Codex hook wire shape. Codex support is deliberately @@ -27,21 +30,37 @@ func runCodex(data []byte, port int) { return } + started := time.Now() + emitted, reachability, alternations := false, "not_checked", 0 switch { case peek.HookEventName == "PreToolUse" && peek.ToolName == "Bash": - runPreToolUse(data, port, ModeEnrich) + emitted = runPreToolUse(data, port, ModeEnrich) + reachability = codexDaemonReachability() + alternations = codexAlternationSegments(data) case peek.HookEventName == "PreToolUse" && codexMCPReadPreToolUseTool(peek.ToolName): - runCodexMCPReadPreToolUse(data) + emitted = runCodexMCPReadPreToolUse(data) case peek.HookEventName == "PostToolUse" && peek.ToolName == "Bash": - runCodexPostToolUse(data) + emitted = runCodexPostToolUse(data) + reachability = codexDaemonReachability() case peek.HookEventName == "UserPromptSubmit": // Re-surface graph symbols relevant to the prompt on every turn. // Codex forgets MCP tools as context grows, so a SessionStart // orientation alone fades; this lands a fresh, prompt-specific // nudge at the top of each turn (the wire shape is shared with // Claude Code — hookSpecificOutput.additionalContext). - runUserPromptSubmit(data) + emitted = runUserPromptSubmit(data) + reachability = codexDaemonReachability() + default: + return } + logCodexHookEffect(peek.HookEventName, peek.ToolName, emitted, reachability, alternations, time.Since(started)) +} + +func codexDaemonReachability() string { + if daemon.IsRunning() { + return "reachable" + } + return "unreachable" } func codexMCPReadPreToolUseTool(toolName string) bool { @@ -53,20 +72,20 @@ func codexMCPReadPreToolUseTool(toolName string) bool { } } -func runCodexMCPReadPreToolUse(data []byte) { +func runCodexMCPReadPreToolUse(data []byte) bool { var input HookInput if err := json.Unmarshal(data, &input); err != nil { - return + return false } if input.HookEventName != "PreToolUse" || !codexMCPReadPreToolUseTool(input.ToolName) { - return + return false } ctx := gortexReadNudge(input.ToolName, input.ToolInput) if ctx == "" { - return + return false } - emitPreToolUse(HookOutput{ + return emitPreToolUse(HookOutput{ HookSpecificOutput: &HookSpecificOutput{ HookEventName: "PreToolUse", AdditionalContext: ctx, @@ -74,13 +93,13 @@ func runCodexMCPReadPreToolUse(data []byte) { }) } -func runCodexPostToolUse(data []byte) { +func runCodexPostToolUse(data []byte) bool { var input postHookInput if err := json.Unmarshal(data, &input); err != nil { - return + return false } if input.HookEventName != "PostToolUse" || input.ToolName != "Bash" { - return + return false } cmd, _ := input.ToolInput["command"].(string) @@ -95,20 +114,39 @@ func runCodexPostToolUse(data []byte) { input.ToolName = "Glob" case BashActionReadSource: if classification.Path == "" { - return + return false } if input.ToolInput == nil { input.ToolInput = make(map[string]any) } input.ToolName = "Read" input.ToolInput["file_path"] = classification.Path + case BashActionFileList: + input.ToolName = "Glob" default: - return + return false } normalized, err := json.Marshal(input) if err != nil { - return + return false + } + return runPostToolUse(normalized) +} + +func codexAlternationSegments(data []byte) int { + var input HookInput + if json.Unmarshal(data, &input) != nil { + return 0 + } + command, _ := input.ToolInput["command"].(string) + c := classifyBashCommand(command) + if c.Action != BashActionGrepLike { + return 0 + } + segments := splitAlternation(c.Pattern) + if len(segments) <= 1 { + return 0 } - runPostToolUse(normalized) + return len(segments) } diff --git a/internal/hooks/codex_test.go b/internal/hooks/codex_test.go index 730eaf8bf..25d74b070 100644 --- a/internal/hooks/codex_test.go +++ b/internal/hooks/codex_test.go @@ -1,6 +1,9 @@ package hooks import ( + "bufio" + "encoding/json" + "os" "strconv" "strings" "testing" @@ -60,6 +63,43 @@ func TestRunCodexPreToolUseBashSoftAdditionalContext(t *testing.T) { } } +func TestRunCodexLogsEffectivenessWithoutCommandContent(t *testing.T) { + logPath := redirectTelemetry(t) + stubProbe(t, nil, errDaemonUnreachable) + + data := codexBashPayload("rg 'place_edges|location_edge|normalize'") + _ = captureStdout(t, func() { runCodex(data, 0) }) + + f, err := os.Open(logPath) + if err != nil { + t.Fatalf("open telemetry: %v", err) + } + defer f.Close() + var found *codexHookEffect + s := bufio.NewScanner(f) + for s.Scan() { + var rec codexHookEffect + if json.Unmarshal(s.Bytes(), &rec) == nil && rec.Kind == "codex_hook_effectiveness" { + found = &rec + } + } + if err := s.Err(); err != nil { + t.Fatalf("scan telemetry: %v", err) + } + if found == nil { + t.Fatal("missing Codex hook effectiveness record") + } + if found.Event != "PreToolUse" || found.Tool != "Bash" || !found.EmittedContext { + t.Fatalf("unexpected effectiveness record: %#v", found) + } + if (found.DaemonReachability != "reachable" && found.DaemonReachability != "unreachable") || found.AlternationSegments != 3 { + t.Fatalf("missing daemon/alternation dimensions: %#v", found) + } + if found.DurationMS < 0 { + t.Fatalf("negative latency: %#v", found) + } +} + func TestRunCodexPreToolUseGortexMCPReadSoftAdditionalContext(t *testing.T) { withForceCompress(t, false) tests := []struct { @@ -273,11 +313,12 @@ func TestRunCodexPostToolUseBashReadSourceAdditionalContext(t *testing.T) { func TestRunCodexPostToolUseBashFindNameFileListAdditionalContext(t *testing.T) { tests := []struct { - name string - command string - response string - indexed map[string]int - want []string + name string + command string + response string + wantContext bool + indexed map[string]int + want []string }{ { name: "find name", @@ -343,9 +384,10 @@ func TestRunCodexPostToolUseBashFindNameFileListAdditionalContext(t *testing.T) func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { tests := []struct { - name string - command string - response string + name string + command string + response string + wantContext bool }{ { name: "grep with no path line output stays quiet", @@ -373,9 +415,10 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { response: "internal/a.go:7:type MyType struct{}\n", }, { - name: "sed source read stays quiet", - command: `sed -n '1,20p' internal/a.go`, - response: "package hooks\n", + name: "sed source read is enriched", + command: `sed -n '1,20p' internal/a.go`, + response: "package hooks\n", + wantContext: true, }, { name: "unsupported awk source scan stays quiet", @@ -383,29 +426,34 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { response: "internal/a.go:7:type MyType struct{}\n", }, { - name: "awk source read stays quiet", - command: `awk '{print}' internal/a.go`, - response: "package hooks\n", + name: "awk source read is enriched", + command: `awk '{print}' internal/a.go`, + response: "package hooks\n", + wantContext: true, }, { - name: "ls stays quiet", - command: "ls /repo", - response: "internal/a.go\n", + name: "ls file list is enriched", + command: "ls /repo", + response: "internal/a.go\n", + wantContext: true, }, { - name: "fd stays quiet", - command: `fd '\.go$' internal`, - response: "internal/a.go\n", + name: "fd file list is enriched", + command: `fd '\.go$' internal`, + response: "internal/a.go\n", + wantContext: true, }, { - name: "tree stays quiet", - command: "tree internal", - response: "internal/a.go\n", + name: "tree file list is enriched", + command: "tree internal", + response: "internal/a.go\n", + wantContext: true, }, { - name: "git ls-files stays quiet", - command: "git ls-files '*.go'", - response: "internal/a.go\n", + name: "git ls-files is enriched", + command: "git ls-files '*.go'", + response: "internal/a.go\n", + wantContext: true, }, } @@ -418,6 +466,12 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { data := codexPostBashPayload(tt.command, tt.response) out := captureStdout(t, func() { runCodex(data, port) }) + if tt.wantContext { + if out == "" { + t.Fatal("expected graph context, got empty output") + } + return + } if out != "" { t.Fatalf("expected silent no-op, got %q", out) } diff --git a/internal/hooks/posttooluse.go b/internal/hooks/posttooluse.go index 0cb58c6ec..256375c4e 100644 --- a/internal/hooks/posttooluse.go +++ b/internal/hooks/posttooluse.go @@ -43,13 +43,13 @@ type postHookInput struct { // probe uses. An earlier revision hit an HTTP :8765 /api/graph/* API that // was removed when the web surface migrated to the daemon, so these // lookups silently returned nothing regardless of configuration (#241). -func runPostToolUse(data []byte) { +func runPostToolUse(data []byte) bool { var input postHookInput if err := json.Unmarshal(data, &input); err != nil { - return + return false } if input.HookEventName != "PostToolUse" { - return + return false } var ctx string @@ -62,7 +62,7 @@ func runPostToolUse(data []byte) { ctx = postRead(input) } if ctx == "" { - return + return false } output := HookOutput{ @@ -73,9 +73,10 @@ func runPostToolUse(data []byte) { } out, err := json.Marshal(output) if err != nil { - return + return false } fmt.Print(string(out)) + return true } // grepHitLineRe matches the leading ":" of a ripgrep-style diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 605ba7b88..1aab1765d 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -75,14 +75,14 @@ const gortexMCPToolPrefix = "mcp__gortex__" // to an additionalContext message — the agent is informed about the graph // alternative but the original call still runs and PostToolUse can layer // graph context on the actual output. -func runPreToolUse(data []byte, gortexPort int, mode Mode) { +func runPreToolUse(data []byte, gortexPort int, mode Mode) bool { var input HookInput if err := json.Unmarshal(data, &input); err != nil { - return + return false } if input.HookEventName != "PreToolUse" { - return + return false } isGortexMCP := strings.HasPrefix(input.ToolName, gortexMCPToolPrefix) @@ -106,8 +106,7 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { if adv := gortexReadNudge(input.ToolName, input.ToolInput); adv != "" { hso.AdditionalContext = adv } - emitPreToolUse(HookOutput{HookSpecificOutput: hso}) - return + return emitPreToolUse(HookOutput{HookSpecificOutput: hso}) } // Consult-unlock handshake: any Gortex MCP tool call records that @@ -116,13 +115,13 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { // call itself is a no-op pass-through — nothing to enrich. if mode == ModeConsultUnlock && isGortexMCP { markGraphConsulted(input.SessionID) - return + return false } result := applyMode(input, isGortexMCP, mode, enrich(input, gortexPort)) if result.context == "" && !result.deny { - return + return false } output := HookOutput{ @@ -138,7 +137,7 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { output.HookSpecificOutput.AdditionalContext = result.context } - emitPreToolUse(output) + return emitPreToolUse(output) } // applyMode adjusts a raw enrich result according to the active posture. @@ -188,12 +187,13 @@ func markGraphConsulted(sessionID string) { // emitPreToolUse marshals a PreToolUse HookOutput to stdout. A marshal // failure is swallowed — a hook must never block Claude Code's flow. -func emitPreToolUse(output HookOutput) { +func emitPreToolUse(output HookOutput) bool { out, err := json.Marshal(output) if err != nil { - return + return false } fmt.Print(string(out)) + return true } // downgradeReason picks the human text to surface when a deny is diff --git a/internal/hooks/telemetry.go b/internal/hooks/telemetry.go index 8a7e7003b..f3f76a1de 100644 --- a/internal/hooks/telemetry.go +++ b/internal/hooks/telemetry.go @@ -31,6 +31,22 @@ type hookDecision struct { DurationMS int64 `json:"duration_ms,omitempty"` } +// codexHookEffect records one Codex lifecycle-hook invocation without +// retaining the prompt, command, paths, or source output. Keeping this +// separate from hookDecision makes it possible to calculate an emitted-context +// rate per event and spot a regression before it becomes another large bucket +// of skipped probes. +type codexHookEffect struct { + Timestamp string `json:"ts"` + Kind string `json:"kind"` + Event string `json:"event"` + Tool string `json:"tool,omitempty"` + EmittedContext bool `json:"emitted_context"` + DaemonReachability string `json:"daemon_reachability"` + AlternationSegments int `json:"alternation_segments,omitempty"` + DurationMS int64 `json:"duration_ms"` +} + // hookDecisionsPath returns the telemetry file path. Respects GORTEX_HOOK_LOG // so tests can redirect writes. Defaults to ~/.gortex/cache (or the // $XDG_CACHE_HOME equivalent when that variable is set). @@ -75,3 +91,31 @@ func logHookDecision(tool, pattern string, decision DecisionKind, hits int, dur defer f.Close() _, _ = f.Write(append(line, '\n')) } + +func logCodexHookEffect(event, tool string, emitted bool, reachability string, alternations int, dur time.Duration) { + path := hookDecisionsPath() + if path == "" { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + if reachability == "" { + reachability = "not_checked" + } + rec := codexHookEffect{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), Kind: "codex_hook_effectiveness", + Event: event, Tool: tool, EmittedContext: emitted, DaemonReachability: reachability, + AlternationSegments: alternations, DurationMS: dur.Milliseconds(), + } + line, err := json.Marshal(rec) + if err != nil { + return + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + _, _ = f.Write(append(line, '\n')) +} diff --git a/internal/hooks/userpromptsubmit.go b/internal/hooks/userpromptsubmit.go index ef9506510..77bd2fd15 100644 --- a/internal/hooks/userpromptsubmit.go +++ b/internal/hooks/userpromptsubmit.go @@ -36,14 +36,14 @@ var userPromptProbe grepProbeFn = probeViaDaemon // trivial / non-code prompt) is a silent no-op so the turn is never blocked or // polluted, and no warning is emitted (SessionStart already warns once when the // daemon is down; doing so every turn would be noise). -func runUserPromptSubmit(data []byte) { +func runUserPromptSubmit(data []byte) bool { var input UserPromptSubmitInput if err := json.Unmarshal(data, &input); err != nil { - return + return false } block := buildUserPromptSubmitContext(input.HookEventName, input.Prompt) if block == "" { - return + return false } out, err := json.Marshal(HookOutput{ HookSpecificOutput: &HookSpecificOutput{ @@ -52,9 +52,10 @@ func runUserPromptSubmit(data []byte) { }, }) if err != nil { - return + return false } fmt.Print(string(out)) + return true } func buildUserPromptSubmitContext(eventName, prompt string) string { From 2798f778107fea71e43b07b31af5e81b19c79ce1 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 11 Jul 2026 15:37:41 +0200 Subject: [PATCH 2/5] feat(codex): add opt-in hook enforcement --- cmd/gortex/hook.go | 9 ++-- cmd/gortex/init.go | 30 +++++++------ cmd/gortex/install.go | 4 ++ docs/agents.md | 12 ++++-- internal/agents/agents.go | 5 +++ internal/agents/codex/adapter.go | 54 ++++++++++++++++++++--- internal/agents/codex/adapter_test.go | 11 +++++ internal/hooks/codex.go | 62 ++++++++++++++++++++------- internal/hooks/codex_test.go | 34 ++++++++++++++- 9 files changed, 178 insertions(+), 43 deletions(-) diff --git a/cmd/gortex/hook.go b/cmd/gortex/hook.go index 64e69f545..b1c4b5438 100644 --- a/cmd/gortex/hook.go +++ b/cmd/gortex/hook.go @@ -35,10 +35,9 @@ var hookCmd = &cobra.Command{ hooks.RunPi(hookPort, hooks.ParseMode(hookMode)) return case "codex": - // Codex support is intentionally soft-only: the adapter installs - // Bash PreToolUse/PostToolUse plus a UserPromptSubmit hook that emit - // additionalContext without ever denying the tool call. - hooks.RunCodex(hookPort) + // Codex defaults to advisory-only enrichment. An explicit + // --codex-hook-mode=deny installation opts into enforcement. + hooks.RunCodex(hookPort, hooks.ParseMode(hookMode)) return case "kimi": // Kimi Code CLI: UserPromptSubmit / PreToolUse / Stop / @@ -62,6 +61,6 @@ func init() { hookCmd.Flags().StringVar(&hookMode, "mode", "deny", "hook posture: 'deny' (redirect Grep/Glob/Read of indexed source), 'enrich' (never deny; PostToolUse appends graph context), 'consult-unlock' (deny fallback reads until the graph is queried once this session), or 'nudge' (soft-deny once per burst of non-symbolic calls)") hookCmd.Flags().StringVar(&hookAgent, "agent", "", - "hook wire protocol: empty/'claude' (Claude Code PreToolUse/UserPromptSubmit), 'codex' (Codex Bash PreToolUse/PostToolUse + UserPromptSubmit soft context), 'kimi' (Kimi Code CLI UserPromptSubmit/PreToolUse/Stop/SubagentStart; plain-stdout context, permissionDecision deny for indexed reads), 'hermes' (NousResearch hermes-agent pre_tool_call/pre_llm_call), 'pi' (earendil-works/pi extension bridge — normalized PiEvent envelope in, PiDecision out), or 'gemini'/'antigravity' (emits hookSpecificOutput.additionalContext). Default (empty) is the Claude Code format.") + "hook wire protocol: empty/'claude' (Claude Code PreToolUse/UserPromptSubmit), 'codex' (Codex Bash PreToolUse/PostToolUse + UserPromptSubmit; advisory by default, enforcement when installed with --codex-hook-mode=deny), 'kimi' (Kimi Code CLI UserPromptSubmit/PreToolUse/Stop/SubagentStart; plain-stdout context, permissionDecision deny for indexed reads), 'hermes' (NousResearch hermes-agent pre_tool_call/pre_llm_call), 'pi' (earendil-works/pi extension bridge — normalized PiEvent envelope in, PiDecision out), or 'gemini'/'antigravity' (emits hookSpecificOutput.additionalContext). Default (empty) is the Claude Code format.") rootCmd.AddCommand(hookCmd) } diff --git a/cmd/gortex/init.go b/cmd/gortex/init.go index a94b8778d..284a9724c 100644 --- a/cmd/gortex/init.go +++ b/cmd/gortex/init.go @@ -51,11 +51,12 @@ import ( // the wizard and the orchestrator read them without cross-pollution. var ( // Core behaviour - initAnalyze bool - initInstallHooks = true - initNoHooks bool - initHooksOnly bool - initHookMode string + initAnalyze bool + initInstallHooks = true + initNoHooks bool + initHooksOnly bool + initHookMode string + initCodexHookMode string // Community skills generation (replaces the old `gortex skills`). initSkills = true @@ -95,6 +96,8 @@ func init() { initCmd.Flags().StringVar(&initHookMode, "hook-mode", "deny", "hook posture: 'deny' (PreToolUse redirects Grep/Glob/Read of indexed source) or 'enrich' "+ "(PreToolUse never denies; PostToolUse appends graph context after the tool runs)") + initCmd.Flags().StringVar(&initCodexHookMode, "codex-hook-mode", "enrich", + "Codex hook posture: 'enrich' (default, advisory only), 'deny', 'consult-unlock', or 'nudge'") initCmd.Flags().BoolVar(&initSkills, "skills", true, "generate per-community routing + SKILL.md files; use --no-skills to skip") initCmd.Flags().BoolVar(&initNoSkills, "no-skills", false, "skip community-skill generation (inverse of --skills)") @@ -228,14 +231,15 @@ func runInit(cmd *cobra.Command, args []string) (err error) { home, _ := os.UserHomeDir() env := agents.Env{ - Root: absRoot, - Home: home, - HookCommand: claudecode.ResolveHookCommand(cmd.ErrOrStderr()), - Mode: agents.ModeProject, - InstallHooks: initInstallHooks, - HookMode: initHookMode, - AnalyzeRepo: initAnalyze, - Stderr: cmd.ErrOrStderr(), + Root: absRoot, + Home: home, + HookCommand: claudecode.ResolveHookCommand(cmd.ErrOrStderr()), + Mode: agents.ModeProject, + InstallHooks: initInstallHooks, + HookMode: initHookMode, + CodexHookMode: initCodexHookMode, + AnalyzeRepo: initAnalyze, + Stderr: cmd.ErrOrStderr(), } defer func() { if err != nil { diff --git a/cmd/gortex/install.go b/cmd/gortex/install.go index 97a611147..a37a0737f 100644 --- a/cmd/gortex/install.go +++ b/cmd/gortex/install.go @@ -35,6 +35,7 @@ var ( installHooks = true installNoHooks bool installHookMode string + installCodexHookMode string installClaudeMd = true installNoClaudeMd bool installClaudeConfigDir string @@ -72,6 +73,8 @@ func init() { "(PreToolUse never denies; PostToolUse appends graph context after the tool runs — easier onboarding), "+ "'consult-unlock' (deny fallback reads until the Gortex graph is queried once this session, then downgrade to soft context), "+ "or 'nudge' (soft-deny once per burst of consecutive non-symbolic calls, then let the next call proceed)") + installCmd.Flags().StringVar(&installCodexHookMode, "codex-hook-mode", "enrich", + "Codex hook posture: 'enrich' (default, advisory only), 'deny', 'consult-unlock', or 'nudge'") installCmd.Flags().BoolVar(&installClaudeMd, "claude-md", true, "merge Gortex rule block into ~/.claude/CLAUDE.md; use --no-claude-md to skip") installCmd.Flags().BoolVar(&installNoClaudeMd, "no-claude-md", false, "skip the ~/.claude/CLAUDE.md rule block (inverse of --claude-md)") installCmd.Flags().StringVar(&installClaudeConfigDir, "claude-config-dir", "", "Claude Code config root to write into (skills/commands/agents/settings/CLAUDE.md/.claude.json); overrides $CLAUDE_CONFIG_DIR, defaults to ~/.claude. Useful for installing into a non-active profile or CI sandbox") @@ -211,6 +214,7 @@ func runInstall(cmd *cobra.Command, _ []string) (err error) { Mode: agents.ModeGlobal, InstallHooks: installHooks, HookMode: installHookMode, + CodexHookMode: installCodexHookMode, InstallGlobalInstructions: installClaudeMd, Stderr: cmd.ErrOrStderr(), } diff --git a/docs/agents.md b/docs/agents.md index 0cf9a012c..3b0f9f969 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -197,9 +197,13 @@ directory that exists. Auto-approval field is `alwaysAllow` (not OpenAI Codex CLI stores config in `~/.codex/config.toml`. We upsert a `[mcp_servers.gortex]` table there. When hooks are enabled -(the default), Codex receives user-level hooks that keep the integration -soft-only: they add graph context or read-shaping guidance without -denying tools, rewriting input, or suppressing output. +(the default), Codex receives user-level hooks in the soft `enrich` posture: +they add graph context or read-shaping guidance without denying tools, +rewriting input, or suppressing output. Opt into enforcement with +`gortex install --codex-hook-mode=deny` (or the corresponding `init` flag). +That posture blocks indexed fallback search/source reads and directs the agent +to the already-configured Gortex MCP tools; it does not require a shell CLI +fallback. Current Codex hook coverage: @@ -209,6 +213,7 @@ Current Codex hook coverage: | Bash `PreToolUse` | Soft graph guidance for shell search and source-read shapes. | | Gortex MCP read-tool `PreToolUse` | `read_file` and `get_editing_context` guidance that nudges source reads toward `compress_bodies`. | | Bash `PostToolUse` | Output graph enrichment for Bash-wrapped grep/search, source-read (`cat`/`head`/`tail`/`sed`/`awk`), and file-list (`find`/`fd`/`ls`/`tree`/`git ls-files`) shapes. Unknown Bash remains a no-op. | +| `apply_patch` `PostToolUse` | Advisory post-change diagnostics: changed symbols, test targets, guards, dead-code and contract checks. The patch is never rewritten, suppressed, or rolled back. | | `gortex init --hooks-only` | Refreshes Codex hooks without rewriting the MCP server config, `AGENTS.md`, or other adapter surfaces. | We do not install separate Codex `PreCompact` or `PostCompact` hooks @@ -224,7 +229,6 @@ scope for Codex hooks and should be handled by a dedicated follow-up. The intentionally unsupported Codex surfaces are tracked as follow-up work: - `Stop`, `PreCompact`, and `PostCompact` lifecycle behavior; -- `apply_patch` and other mutation-aware post-tool handling; - hard deny, input rewrite, and output suppression modes. Codex hook effectiveness is written as privacy-safe JSONL alongside hook diff --git a/internal/agents/agents.go b/internal/agents/agents.go index e3562dad8..69f4e7ec2 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -88,6 +88,11 @@ type Env struct { // adapter currently honours it; other adapters ignore the field. HookMode string + // CodexHookMode is the Codex-specific posture. Empty defaults to + // "enrich" even when HookMode is "deny", preserving Codex's historical + // advisory-only behavior until an operator explicitly opts into deny. + CodexHookMode string + // InstallGlobalInstructions toggles whether `gortex install` // merges the rule block into ~/.claude/CLAUDE.md. Only honoured // in ModeGlobal; ignored elsewhere. Default true so a fresh diff --git a/internal/agents/codex/adapter.go b/internal/agents/codex/adapter.go index 598bba27e..72f5c6408 100644 --- a/internal/agents/codex/adapter.go +++ b/internal/agents/codex/adapter.go @@ -33,7 +33,7 @@ const codexSessionStartCommand = "printf '%s\\n' '" + codexSessionStartMessage + const codexSessionStartWindowsCommand = "powershell -NoProfile -Command \"Write-Output '" + codexSessionStartMessage + "'\"" const codexPreToolUseMatcher = "^Bash$" const codexMCPReadPreToolUseMatcher = "^mcp__gortex__(read_file|get_editing_context)$" -const codexPostToolUseMatcher = "^Bash$" +const codexPostToolUseMatcher = "^(Bash|apply_patch)$" const codexHookTimeoutSeconds = 5 type Adapter struct{} @@ -201,10 +201,10 @@ func upsertCodexHook(root map[string]any, event string, isGortex func(any) bool, kept := make([]any, 0, len(entries)+1) for _, entry := range entries { if isGortex(entry) { - found = true - if opts.Force { + if opts.Force || !codexHookEntryMatchesDesired(entry, desired) { continue } + found = true } kept = append(kept, entry) } @@ -244,6 +244,12 @@ func upsertCodexHookSet(root map[string]any, event string, isGortex func(any) bo break } } + // This is a Gortex-owned entry whose matcher is no longer one + // of the desired entries (for example, its hook mode changed). + // Reconcile it rather than preserving stale posture forever. + if !codexHookEntryMatchesAnyDesired(entry, desired) { + continue + } } kept = append(kept, entry) } @@ -266,7 +272,36 @@ func upsertCodexHookSet(root map[string]any, event string, isGortex func(any) bo func codexHookEntryMatchesDesired(entry any, desired map[string]any) bool { matcher, _ := desired["matcher"].(string) - return codexHookEntryHasMatcher(entry, matcher) && codexHookEntryInvokesCodexHook(entry) + if !codexHookEntryHasMatcher(entry, matcher) { + return false + } + return codexHookEntryCommand(entry) == codexHookEntryCommand(desired) +} + +func codexHookEntryMatchesAnyDesired(entry any, desired []map[string]any) bool { + for _, want := range desired { + if codexHookEntryMatchesDesired(entry, want) { + return true + } + } + return false +} + +func codexHookEntryCommand(entry any) string { + group, ok := entry.(map[string]any) + if !ok { + return "" + } + handlers, ok := codexHookList(group["hooks"]) + if !ok || len(handlers) != 1 { + return "" + } + handler, ok := handlers[0].(map[string]any) + if !ok { + return "" + } + cmd, _ := handler["command"].(string) + return cmd } func codexHookEntryHasMatcher(entry any, matcher string) bool { @@ -449,5 +484,14 @@ func codexHookCommand(env agents.Env) string { if base == "" { base = "gortex hook" } - return base + " --agent=codex --mode=enrich" + return base + " --agent=codex --mode=" + codexHookMode(env) +} + +func codexHookMode(env agents.Env) string { + switch strings.TrimSpace(env.CodexHookMode) { + case "deny", "consult-unlock", "nudge", "adaptive-nudge": + return strings.TrimSpace(env.CodexHookMode) + default: + return "enrich" + } } diff --git a/internal/agents/codex/adapter_test.go b/internal/agents/codex/adapter_test.go index c9a377c4b..5345d578e 100644 --- a/internal/agents/codex/adapter_test.go +++ b/internal/agents/codex/adapter_test.go @@ -423,6 +423,17 @@ func TestCodexPreToolUseCommandFallsBackToGortexHook(t *testing.T) { } } +func TestCodexHookModeDefaultsSoftAndHonoursExplicitDeny(t *testing.T) { + env := codexGlobalEnv(t) + if got := codexHookCommand(env); !strings.Contains(got, "--mode=enrich") { + t.Fatalf("default Codex hook command=%q, want soft enrich", got) + } + env.CodexHookMode = "deny" + if got := codexHookCommand(env); !strings.Contains(got, "--mode=deny") { + t.Fatalf("explicit Codex deny command=%q", got) + } +} + func TestCodexSessionStartHookIdempotent(t *testing.T) { env := codexGlobalEnv(t) a := New() diff --git a/internal/hooks/codex.go b/internal/hooks/codex.go index f0bee3f05..f78990e58 100644 --- a/internal/hooks/codex.go +++ b/internal/hooks/codex.go @@ -2,6 +2,7 @@ package hooks import ( "encoding/json" + "fmt" "io" "os" "time" @@ -9,39 +10,47 @@ import ( "github.com/zzet/gortex/internal/daemon" ) -// RunCodex handles the Codex hook wire shape. Codex support is deliberately -// soft-only: PreToolUse is forced through ModeEnrich, PostToolUse only emits -// additionalContext, and UserPromptSubmit re-surfaces prompt-relevant graph -// symbols on every turn. No branch ever denies a tool call. -func RunCodex(port int) { +// RunCodex handles the Codex hook wire shape. Codex defaults to ModeEnrich, +// but callers may explicitly opt into a stricter posture. +func RunCodex(port int, mode Mode) { data, err := io.ReadAll(os.Stdin) if err != nil { return } - runCodex(data, port) + runCodexWithMode(data, port, mode) } func runCodex(data []byte, port int) { + runCodexWithMode(data, port, ModeEnrich) +} + +func runCodexWithMode(data []byte, port int, mode Mode) { var peek struct { HookEventName string `json:"hook_event_name"` ToolName string `json:"tool_name"` + CWD string `json:"cwd"` } if err := json.Unmarshal(data, &peek); err != nil { return } + setHookCWD(peek.CWD) + defer setHookCWD("") started := time.Now() emitted, reachability, alternations := false, "not_checked", 0 switch { case peek.HookEventName == "PreToolUse" && peek.ToolName == "Bash": - emitted = runPreToolUse(data, port, ModeEnrich) + emitted = runPreToolUse(data, port, mode) reachability = codexDaemonReachability() alternations = codexAlternationSegments(data) case peek.HookEventName == "PreToolUse" && codexMCPReadPreToolUseTool(peek.ToolName): - emitted = runCodexMCPReadPreToolUse(data) + emitted = runCodexMCPReadPreToolUse(data, mode) case peek.HookEventName == "PostToolUse" && peek.ToolName == "Bash": emitted = runCodexPostToolUse(data) reachability = codexDaemonReachability() + case peek.HookEventName == "PostToolUse" && peek.ToolName == "apply_patch": + emitted = runCodexMutationPostToolUse(data, port) + reachability = codexDaemonReachability() case peek.HookEventName == "UserPromptSubmit": // Re-surface graph symbols relevant to the prompt on every turn. // Codex forgets MCP tools as context grows, so a SessionStart @@ -56,6 +65,29 @@ func runCodex(data []byte, port int) { logCodexHookEffect(peek.HookEventName, peek.ToolName, emitted, reachability, alternations, time.Since(started)) } +// runCodexMutationPostToolUse runs the same graph diagnostics as a terminal +// post-task check after Codex's native apply_patch tool succeeds. It is +// advisory-only: a failed/empty daemon response is silent and never changes +// the applied patch or asks Codex to repeat it. +func runCodexMutationPostToolUse(data []byte, port int) bool { + var input postHookInput + if json.Unmarshal(data, &input) != nil || input.HookEventName != "PostToolUse" || input.ToolName != "apply_patch" { + return false + } + briefing := buildPostTaskBriefing(port) + if briefing == "" { + return false + } + out, err := json.Marshal(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PostToolUse", AdditionalContext: briefing, + }}) + if err != nil { + return false + } + fmt.Print(string(out)) + return true +} + func codexDaemonReachability() string { if daemon.IsRunning() { return "reachable" @@ -72,7 +104,7 @@ func codexMCPReadPreToolUseTool(toolName string) bool { } } -func runCodexMCPReadPreToolUse(data []byte) bool { +func runCodexMCPReadPreToolUse(data []byte, mode Mode) bool { var input HookInput if err := json.Unmarshal(data, &input); err != nil { return false @@ -85,12 +117,12 @@ func runCodexMCPReadPreToolUse(data []byte) bool { if ctx == "" { return false } - return emitPreToolUse(HookOutput{ - HookSpecificOutput: &HookSpecificOutput{ - HookEventName: "PreToolUse", - AdditionalContext: ctx, - }, - }) + hso := &HookSpecificOutput{HookEventName: "PreToolUse", AdditionalContext: ctx} + if mode == ModeDeny { + hso.PermissionDecision = "deny" + hso.PermissionDecisionReason = "[Gortex] BLOCKED: use the already-available Gortex MCP read tools with compress_bodies:true instead of a full-body source read." + } + return emitPreToolUse(HookOutput{HookSpecificOutput: hso}) } func runCodexPostToolUse(data []byte) bool { diff --git a/internal/hooks/codex_test.go b/internal/hooks/codex_test.go index 25d74b070..a5287c4c8 100644 --- a/internal/hooks/codex_test.go +++ b/internal/hooks/codex_test.go @@ -42,7 +42,7 @@ func TestRunCodexPreToolUseBashSoftAdditionalContext(t *testing.T) { data := codexBashPayload("rg Foo") out := captureStdout(t, func() { - withStdin(t, data, func() { RunCodex(0) }) + withStdin(t, data, func() { RunCodex(0, ModeEnrich) }) }) if out == "" { t.Fatal("expected Codex Bash PreToolUse guidance, got empty output") @@ -142,6 +142,19 @@ func TestRunCodexPreToolUseGortexMCPReadSoftAdditionalContext(t *testing.T) { } } +func TestRunCodexDenyModeBlocksUncompressedMCPRead(t *testing.T) { + withForceCompress(t, false) + data := codexPreToolPayload(gortexReadFileTool, `{"path":"internal/a.go"}`) + out := captureStdout(t, func() { runCodexWithMode(data, 0, ModeDeny) }) + hso := decodeHookOutput(t, out).HookSpecificOutput + if hso == nil || hso.PermissionDecision != "deny" { + t.Fatalf("deny posture must block the uncompressed MCP source read: %#v", hso) + } + if !strings.Contains(hso.PermissionDecisionReason, "MCP read tools") { + t.Fatalf("deny must direct the agent back to MCP, got %q", hso.PermissionDecisionReason) + } +} + func TestRunCodexPreToolUseGortexMCPReadPermissiveModeStaysAdditionalContext(t *testing.T) { withForceCompress(t, true) tests := []struct { @@ -260,6 +273,25 @@ func TestRunCodexPostToolUseBashGrepOutputAdditionalContext(t *testing.T) { } } +func TestRunCodexPostToolUseApplyPatchRunsAdvisoryDiagnostics(t *testing.T) { + changedJSON := `{"changed_files":["internal/foo.go"],"changed_symbols":[{"id":"internal/foo.go::Foo","name":"Foo","kind":"function"}],"risk":"MEDIUM"}` + srv := newFakeServer(map[string]string{ + "detect_changes": changedJSON, + "get_test_targets": "internal/foo_test.go::TestFoo", + "check_guards": "no guard violations\n", + "analyze": "no dead code\n", + "contracts": "no contract mismatches\n", + }) + defer srv.Close() + + data := []byte(`{"hook_event_name":"PostToolUse","tool_name":"apply_patch","cwd":"/repo","tool_input":{},"tool_response":"Done"}`) + out := captureStdout(t, func() { runCodex(data, portFromURL(t, srv.URL)) }) + hso := decodeHookOutput(t, out).HookSpecificOutput + if hso == nil || hso.HookEventName != "PostToolUse" || !strings.Contains(hso.AdditionalContext, "Post-Task Diagnostics") { + t.Fatalf("apply_patch should receive advisory graph diagnostics, got %q", out) + } +} + func TestRunCodexPostToolUseBashReadSourceAdditionalContext(t *testing.T) { tests := []struct { name string From f225295592b08c68df4a4041b35c6e9ef0896bf3 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 11 Jul 2026 15:45:42 +0200 Subject: [PATCH 3/5] fix(codex): preserve hook posture on refresh --- cmd/gortex/init.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/gortex/init.go b/cmd/gortex/init.go index 284a9724c..3aafa939d 100644 --- a/cmd/gortex/init.go +++ b/cmd/gortex/init.go @@ -374,13 +374,14 @@ func runInitHooksOnly(cmd *cobra.Command, absRoot string) error { return nil } env := agents.Env{ - Root: absRoot, - Home: home, - HookCommand: claudecode.ResolveHookCommand(cmd.ErrOrStderr()), - Mode: agents.ModeProject, - InstallHooks: true, - HookMode: initHookMode, - Stderr: cmd.ErrOrStderr(), + Root: absRoot, + Home: home, + HookCommand: claudecode.ResolveHookCommand(cmd.ErrOrStderr()), + Mode: agents.ModeProject, + InstallHooks: true, + HookMode: initHookMode, + CodexHookMode: initCodexHookMode, + Stderr: cmd.ErrOrStderr(), } detected, _ := codex.New().Detect(env) if !detected { From 14b05a6d1988827bfcd1301ed2637eb94542fd88 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 11 Jul 2026 15:58:59 +0200 Subject: [PATCH 4/5] fix(codex): publish read_file eagerly --- cmd/gortex/testdata/agent-render/codex.txt | 1 + docs/agents.md | 5 +++++ internal/agents/codex/adapter.go | 24 +++++++++++++++++++++- internal/agents/codex/adapter_test.go | 7 ++++++- internal/mcp/agent_preset_test.go | 2 ++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/cmd/gortex/testdata/agent-render/codex.txt b/cmd/gortex/testdata/agent-render/codex.txt index 4108b53d2..9b05e8bac 100644 --- a/cmd/gortex/testdata/agent-render/codex.txt +++ b/cmd/gortex/testdata/agent-render/codex.txt @@ -6,6 +6,7 @@ command = 'gortex' [mcp_servers.gortex.env] GORTEX_INDEX_WORKERS = '8' +GORTEX_TOOLS = 'agent,+read_file' === root/AGENTS.md === - [example-community](.claude/skills/example/SKILL.md) — example routing block diff --git a/docs/agents.md b/docs/agents.md index 3b0f9f969..e9759b160 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -205,6 +205,11 @@ That posture blocks indexed fallback search/source reads and directs the agent to the already-configured Gortex MCP tools; it does not require a shell CLI fallback. +The adapter pins `GORTEX_TOOLS=agent,+read_file` for its own MCP server entry +unless that environment key was already set by the operator. This makes +`read_file` eager even when a host delays or omits Codex client identity during +MCP initialization; it must never require `tools_search` discovery. + Current Codex hook coverage: | Surface | Coverage | diff --git a/internal/agents/codex/adapter.go b/internal/agents/codex/adapter.go index 72f5c6408..704a8ac25 100644 --- a/internal/agents/codex/adapter.go +++ b/internal/agents/codex/adapter.go @@ -35,6 +35,7 @@ const codexPreToolUseMatcher = "^Bash$" const codexMCPReadPreToolUseMatcher = "^mcp__gortex__(read_file|get_editing_context)$" const codexPostToolUseMatcher = "^(Bash|apply_patch)$" const codexHookTimeoutSeconds = 5 +const codexToolsEnvValue = "agent,+read_file" type Adapter struct{} @@ -98,16 +99,37 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, if !ok { servers = make(map[string]any) } - if _, exists := servers["gortex"]; !exists || opts.Force { + server, exists := servers["gortex"].(map[string]any) + if !exists || opts.Force { servers["gortex"] = map[string]any{ "command": "gortex", "args": []string{"mcp"}, "env": map[string]any{ "GORTEX_INDEX_WORKERS": "8", + // Pin the agent surface explicitly: some MCP hosts defer + // tools before clientInfo is available, but read_file is a + // mandatory Codex source-reading primitive. + "GORTEX_TOOLS": codexToolsEnvValue, }, } root["mcp_servers"] = servers changed = true + } else { + // Preserve an operator's explicit surface choice, but make fresh + // and older Gortex-owned entries deterministic: without this pin a + // host that delays/omits clientInfo can fall back to a deferred + // catalogue and strand read_file behind tools_search. + envMap, ok := server["env"].(map[string]any) + if !ok { + envMap = make(map[string]any) + } + if _, pinned := envMap["GORTEX_TOOLS"]; !pinned { + envMap["GORTEX_TOOLS"] = codexToolsEnvValue + server["env"] = envMap + servers["gortex"] = server + root["mcp_servers"] = servers + changed = true + } } if env.InstallHooks { diff --git a/internal/agents/codex/adapter_test.go b/internal/agents/codex/adapter_test.go index 5345d578e..770ff0e66 100644 --- a/internal/agents/codex/adapter_test.go +++ b/internal/agents/codex/adapter_test.go @@ -44,8 +44,13 @@ func TestCodexWritesMcpServersTOMLTable(t *testing.T) { if !strings.Contains(got, "gortex") { t.Fatalf("expected gortex entry: %s", got) } - cfg := readCodexConfig(t, env) + servers := cfg["mcp_servers"].(map[string]any) + gortex := servers["gortex"].(map[string]any) + envVars := gortex["env"].(map[string]any) + if envVars["GORTEX_TOOLS"] != codexToolsEnvValue { + t.Fatalf("GORTEX_TOOLS=%v want %q", envVars["GORTEX_TOOLS"], codexToolsEnvValue) + } if count := gortexSessionStartHookCount(t, cfg); count != 1 { t.Fatalf("expected one Gortex SessionStart hook, got %d: %#v", count, cfg["hooks"]) } diff --git a/internal/mcp/agent_preset_test.go b/internal/mcp/agent_preset_test.go index 66dd2bb01..eaa792025 100644 --- a/internal/mcp/agent_preset_test.go +++ b/internal/mcp/agent_preset_test.go @@ -13,6 +13,7 @@ func TestAgentPreset_Membership(t *testing.T) { require.Equal(t, "agent", p.preset) require.True(t, p.lean, "the agent preset is lean") require.True(t, p.allows("search_symbols")) + require.True(t, p.allows("read_file"), "source reads must never require deferred discovery") require.True(t, p.allows("edit_file")) require.True(t, p.allows("tool_profile")) // always kept require.True(t, p.allows(LazyToolsSearchName)) @@ -39,6 +40,7 @@ func TestAgentPreset_ClientAwareDefault(t *testing.T) { cc := listToolNamesForSession(t, srv, "sess_cc") require.True(t, cc["search_symbols"]) require.True(t, cc["edit_file"]) + require.True(t, cc["read_file"], "Codex's agent surface must publish read_file eagerly") require.False(t, cc["analyze"], "analyze is not in the lean agent surface") // Editor / unknown client → the server's global core default (analyze in). From 9b517a5c83817de14123389f1598ec128146f604 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 11 Jul 2026 16:20:53 +0200 Subject: [PATCH 5/5] Revert "fix(codex): publish read_file eagerly" This reverts commit 14b05a6d1988827bfcd1301ed2637eb94542fd88. --- cmd/gortex/testdata/agent-render/codex.txt | 1 - docs/agents.md | 5 ----- internal/agents/codex/adapter.go | 24 +--------------------- internal/agents/codex/adapter_test.go | 7 +------ internal/mcp/agent_preset_test.go | 2 -- 5 files changed, 2 insertions(+), 37 deletions(-) diff --git a/cmd/gortex/testdata/agent-render/codex.txt b/cmd/gortex/testdata/agent-render/codex.txt index 9b05e8bac..4108b53d2 100644 --- a/cmd/gortex/testdata/agent-render/codex.txt +++ b/cmd/gortex/testdata/agent-render/codex.txt @@ -6,7 +6,6 @@ command = 'gortex' [mcp_servers.gortex.env] GORTEX_INDEX_WORKERS = '8' -GORTEX_TOOLS = 'agent,+read_file' === root/AGENTS.md === - [example-community](.claude/skills/example/SKILL.md) — example routing block diff --git a/docs/agents.md b/docs/agents.md index e9759b160..3b0f9f969 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -205,11 +205,6 @@ That posture blocks indexed fallback search/source reads and directs the agent to the already-configured Gortex MCP tools; it does not require a shell CLI fallback. -The adapter pins `GORTEX_TOOLS=agent,+read_file` for its own MCP server entry -unless that environment key was already set by the operator. This makes -`read_file` eager even when a host delays or omits Codex client identity during -MCP initialization; it must never require `tools_search` discovery. - Current Codex hook coverage: | Surface | Coverage | diff --git a/internal/agents/codex/adapter.go b/internal/agents/codex/adapter.go index 704a8ac25..72f5c6408 100644 --- a/internal/agents/codex/adapter.go +++ b/internal/agents/codex/adapter.go @@ -35,7 +35,6 @@ const codexPreToolUseMatcher = "^Bash$" const codexMCPReadPreToolUseMatcher = "^mcp__gortex__(read_file|get_editing_context)$" const codexPostToolUseMatcher = "^(Bash|apply_patch)$" const codexHookTimeoutSeconds = 5 -const codexToolsEnvValue = "agent,+read_file" type Adapter struct{} @@ -99,37 +98,16 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, if !ok { servers = make(map[string]any) } - server, exists := servers["gortex"].(map[string]any) - if !exists || opts.Force { + if _, exists := servers["gortex"]; !exists || opts.Force { servers["gortex"] = map[string]any{ "command": "gortex", "args": []string{"mcp"}, "env": map[string]any{ "GORTEX_INDEX_WORKERS": "8", - // Pin the agent surface explicitly: some MCP hosts defer - // tools before clientInfo is available, but read_file is a - // mandatory Codex source-reading primitive. - "GORTEX_TOOLS": codexToolsEnvValue, }, } root["mcp_servers"] = servers changed = true - } else { - // Preserve an operator's explicit surface choice, but make fresh - // and older Gortex-owned entries deterministic: without this pin a - // host that delays/omits clientInfo can fall back to a deferred - // catalogue and strand read_file behind tools_search. - envMap, ok := server["env"].(map[string]any) - if !ok { - envMap = make(map[string]any) - } - if _, pinned := envMap["GORTEX_TOOLS"]; !pinned { - envMap["GORTEX_TOOLS"] = codexToolsEnvValue - server["env"] = envMap - servers["gortex"] = server - root["mcp_servers"] = servers - changed = true - } } if env.InstallHooks { diff --git a/internal/agents/codex/adapter_test.go b/internal/agents/codex/adapter_test.go index 770ff0e66..5345d578e 100644 --- a/internal/agents/codex/adapter_test.go +++ b/internal/agents/codex/adapter_test.go @@ -44,13 +44,8 @@ func TestCodexWritesMcpServersTOMLTable(t *testing.T) { if !strings.Contains(got, "gortex") { t.Fatalf("expected gortex entry: %s", got) } + cfg := readCodexConfig(t, env) - servers := cfg["mcp_servers"].(map[string]any) - gortex := servers["gortex"].(map[string]any) - envVars := gortex["env"].(map[string]any) - if envVars["GORTEX_TOOLS"] != codexToolsEnvValue { - t.Fatalf("GORTEX_TOOLS=%v want %q", envVars["GORTEX_TOOLS"], codexToolsEnvValue) - } if count := gortexSessionStartHookCount(t, cfg); count != 1 { t.Fatalf("expected one Gortex SessionStart hook, got %d: %#v", count, cfg["hooks"]) } diff --git a/internal/mcp/agent_preset_test.go b/internal/mcp/agent_preset_test.go index eaa792025..66dd2bb01 100644 --- a/internal/mcp/agent_preset_test.go +++ b/internal/mcp/agent_preset_test.go @@ -13,7 +13,6 @@ func TestAgentPreset_Membership(t *testing.T) { require.Equal(t, "agent", p.preset) require.True(t, p.lean, "the agent preset is lean") require.True(t, p.allows("search_symbols")) - require.True(t, p.allows("read_file"), "source reads must never require deferred discovery") require.True(t, p.allows("edit_file")) require.True(t, p.allows("tool_profile")) // always kept require.True(t, p.allows(LazyToolsSearchName)) @@ -40,7 +39,6 @@ func TestAgentPreset_ClientAwareDefault(t *testing.T) { cc := listToolNamesForSession(t, srv, "sess_cc") require.True(t, cc["search_symbols"]) require.True(t, cc["edit_file"]) - require.True(t, cc["read_file"], "Codex's agent surface must publish read_file eagerly") require.False(t, cc["analyze"], "analyze is not in the lean agent surface") // Editor / unknown client → the server's global core default (analyze in).