Skip to content
Open
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
9 changes: 4 additions & 5 deletions cmd/gortex/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand All @@ -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)
}
45 changes: 25 additions & 20 deletions cmd/gortex/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -370,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 {
Expand Down
4 changes: 4 additions & 0 deletions cmd/gortex/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var (
installHooks = true
installNoHooks bool
installHookMode string
installCodexHookMode string
installClaudeMd = true
installNoClaudeMd bool
installClaudeConfigDir string
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(),
}
Expand Down
29 changes: 24 additions & 5 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,18 +197,23 @@ 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:

| 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. |
| `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
Expand All @@ -219,6 +224,20 @@ 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;
- 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
Expand Down
5 changes: 5 additions & 0 deletions internal/agents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 49 additions & 5 deletions internal/agents/codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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"
}
}
11 changes: 11 additions & 0 deletions internal/agents/codex/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions internal/hooks/bash_classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down
7 changes: 6 additions & 1 deletion internal/hooks/bash_classify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""},
}
Expand Down
Loading
Loading