feat: add OpenAI Codex platform support#13
Conversation
Codex hooks use the same JSON wire format as Claude Code hooks (see https://developers.openai.com/codex/hooks), so this change introduces: - New codex/ package re-exporting the relevant claude types and helpers with Codex-specific documentation (Stop, PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, SessionStart). - PlatformCodex constant and codex- handler registrations on every unified handler (OnStop, OnBeforeExecution, OnAfterFileEdit, OnPromptSubmit). StopContext.ShouldSkip() now also honors StopHookActive for Codex. - OnAfterFileEdit's codex-post-tool-use registration recognizes apply_patch in addition to Write and Edit, matching the upstream tool surface. - hookshot install --codex writes ~/.codex/hooks.json with appropriate matchers (Bash|apply_patch for PreToolUse, apply_patch|Edit|Write for PostToolUse). The installer reminds users that codex_hooks = true under [features] in ~/.codex/config.toml is still required. - Tests verify that the codex handlers are registered alongside the existing platforms and that PlatformCodex behaves like Claude/Droid for ShouldSkip(). - README, doc.go, docs/reference-unified.md, examples/multi-hook, and a new docs/reference-codex.md document the platform end-to-end. Co-authored-by: Ashwin Ramaswami <ashwin@corridor.dev>
There was a problem hiding this comment.
Security Issues
- Codex
askdecisions fall open: The unified Codex handler returns anaskpermission decision even though Codex currently does not enforce it, allowing policies that require user confirmation to be bypassed. Thecodex/types.goandcodex/helpers.gopackage documentation explicitly acknowledges this:"allow"and"ask"values are "parsed but fall open today." AnyOnBeforeExecutionpolicy that returnsAskfor a risky Bash orapply_patchcall will not surface the approval prompt and the tool will execute immediately. - Codex installer does not hook MCP tools: The generated Codex
PreToolUsematcher ("Bash|apply_patch") excludes themcp__*tool-name pattern. Whileunified.go'scodex-pre-tool-usehandler correctly classifies MCP tools (lines 464–465), Codex never invokes the hook binary for MCP calls because the matcher never matches. AnyOnBeforeExecutionpolicy meant to enforce MCP allowlists or SSRF controls is therefore silently bypassed for all Codex MCP tool calls. - Codex
apply_patchedits are not parsed for file-edit policies: TheOnAfterFileEditCodex handler unmarshalsapply_patchtool input using a struct that expectsfile_pathandcontentfields, butapply_patchactually carries acommandfield (perreference-codex.mdline 125 and the Codex upstream spec). Forapply_patchcalls, bothtoolInput.FilePathandtoolInput.Contentare empty after unmarshalling, so theFileEditContextpassed to handlers has an emptyFilePathand an empty edit payload, causing path- and secret-based policies inOnAfterFileEditto evaluate against blank values and fail to detect policy violations.
Summary: This PR adds OpenAI Codex hook support and installer configuration, expanding security enforcement to a new AI agent platform.
Risk: High risk. The added Codex integration contains three protection-bypass paths: approval decisions fail open, MCP tool calls bypass pre-execution policies entirely, and apply_patch file-edit metadata is lost before file-edit policies can evaluate it.
Recommendations:
- Fail closed for Codex
Askdecisions until Codex enforces them (substituteclaude.Denyuntil the platform supportsask). - Update the generated Codex
PreToolUsematcher from"Bash|apply_patch"to"Bash|apply_patch|mcp__.*"so MCP tool calls reach the hook handler. - Parse
apply_patchtool input separately to extract the target file path and patch content before constructingFileEditContext, or fail closed (block) forapply_patchuntil proper parsing is implemented.
… tools Addresses three review findings on the Codex platform integration: 1. codex-post-tool-use was unmarshalling apply_patch tool_input with the Write/Edit schema (file_path/content/old_string/new_string), but apply_patch actually carries a unified-diff envelope under tool_input.command. The handler now parses that envelope (Add / Update / Delete File sections with hunked diff bodies) via the new parseCodexApplyPatch helper and invokes OnAfterFileEdit handlers once per file with a fully populated FileEditContext. If any per-file invocation returns FileEditBlock, the reasons are concatenated and emitted as a single PostToolBlock so Codex still sees feedback. The parser is tolerant: malformed patches fall back to a single invocation with the raw command text in Edits[0].NewString so events never disappear silently. 2. codex-pre-tool-use was returning claude.Ask(...) for AskExecution decisions, but Codex parses but does not enforce permissionDecision 'ask' today. Any policy that required user confirmation before risky Bash or apply_patch operations was silently failing open. The unified bridge now rewrites Ask -> Deny on Codex so the same policy that asks on Claude/Cursor/Droid/Cascade fails closed on Codex. 3. The installer was generating a PreToolUse matcher of 'Bash|apply_patch' that excluded mcp__* tool names, so the codex-pre-tool-use handler was never invoked for MCP calls and MCP allowlist/SSRF policies were structurally bypassed. The matcher is now 'Bash|apply_patch|mcp__.*' for PreToolUse and 'apply_patch|Edit|Write|mcp__.*' for PostToolUse. apply_patch tool_input.command is also exposed via ExecutionContext.Command in the pre-tool-use bridge so policies can inspect the patch directly. Adds parseCodexApplyPatch with eight unit tests covering Add, Update, Delete, multi-hunk Update, multi-file patches, Move to renames, leading here-doc wrappers, and malformed input. Adds five integration tests covering the rewritten codex-pre-tool-use and codex-post-tool-use handlers (Ask fails closed, apply_patch populates Command, MCP is classified as ExecutionMCP, apply_patch parses files+edits, and block propagation across multi-file patches). Updates docs/reference-codex.md, docs/reference-unified.md, README.md, codex/doc.go, and examples/multi-hook to reflect the new matchers, the apply_patch parsing semantics, and the fail-closed treatment of Ask on Codex. Co-authored-by: Ashwin Ramaswami <ashwin@corridor.dev>
There was a problem hiding this comment.
Security Issues
- Codex apply_patch move destination is not exposed to file-edit policies
The Codexapply_patchparser correctly captures*** Move to:destinations inf.NewFilePath, but theOnAfterFileEditbridge (unified.go:793-808) only populatesFileEditContext.FilePathwith the source path. BecauseFileEditContexthas no destination-path field, any registered handler that enforces path-based policies (e.g., restricting edits toctx.Cwd, blocking.envor SSH paths) will evaluate only the benign source path and silently approve the move. A crafted patch such as*** Update File: safe/file.txtfollowed by*** Move to: ../../.ssh/authorized_keyswill pass every path check while writing attacker-controlled content to an unrestricted destination. The fix requires adding aNewFilePath(orDestinationPath) field toFileEditContextand populating it in the loop, then invoking the handler (or an additional handler call) for the destination path so policies can validate both endpoints of the move operation.
…Edit
The codex-post-tool-use bridge was reading the *** Move to: destination
into f.NewFilePath but never surfacing it to handlers. FileEditContext
only carried FilePath (the source), so a FilePath-only path allowlist
would approve a patch like:
*** Update File: app/config.go
*** Move to: ../../.ssh/authorized_keys
even though the actual destination is outside the allowed directory.
Adds a NewFilePath field to FileEditContext and rewrites the codex
apply_patch loop to invoke the user's OnAfterFileEdit handler twice for
rename operations:
1. once with FilePath = source, NewFilePath = destination
2. once with FilePath = destination, NewFilePath = destination
Existing policies that only inspect ctx.FilePath now see the
destination as a separate event and can block it. Policies that want
to detect the rename relationship can check
ctx.NewFilePath != "" && ctx.NewFilePath != ctx.FilePath.
Block decisions across all invocations (per-file + per-rename-side)
flow through the existing block-reason aggregation, so a single
PostToolBlock with the combined reasons is emitted.
NewFilePath defaults to "" on every other platform; in-place Codex
edits also leave it empty, so this is a strictly additive change.
Tests:
- TestCodexPostToolUse_ApplyPatchMoveToInvokesHandlerForDestination
asserts the handler is invoked once with FilePath=source and once
with FilePath=destination, and that NewFilePath is populated on both.
- TestCodexPostToolUse_ApplyPatchMoveToBlockOnDestinationPropagates
asserts a FilePath-only block targeting the destination results in
a PostToolBlock with the destination's reason.
Docs:
- docs/reference-unified.md and docs/reference-codex.md describe the
new field, the double-invocation semantics for renames, and the
recommended check for rename detection.
Co-authored-by: Ashwin Ramaswami <ashwin@corridor.dev>
There was a problem hiding this comment.
Security Issues
- Fail-open Codex PreToolUse helpers
codex.AllowWithInputandcodex.Askare exported helpers that the accompanying comments explicitly acknowledge are not enforced by Codex. A hook author writing a platform-specific Codex handler (i.e., using thecodexpackage directly rather than the unifiedhookshot.OnBeforeExecutionbridge) who callscodex.AllowWithInputto sanitize a Bash/apply_patch payload will have theupdatedInputsilently ignored — Codex executes the original, unsanitized input. Likewise, a hook usingcodex.Askto gate execution on user confirmation will fall open: Codex parsespermissionDecision: "ask"but does not surface a prompt, so the tool runs without confirmation. The unified bridge (unified.go) does convertAskExecution()toDenyfor Codex, but that protection only covers hooks written against the unified API — direct consumers of thecodexpackage helpers remain exposed.
Recommendation
- Remove or replace the fail-open helpers in
codex/helpers.go. ForAsk, replace the re-export with a wrapper that returnsclaude.Deny(reason), matching the unified bridge's behaviour. ForAllowWithInput, either remove the export entirely until Codex enforcesupdatedInput, or replace it with a wrapper that returnsclaude.Deny(reason)so callers fail closed rather than silently losing the input rewrite.
…fied via codex package
Codex's PreToolUse rejects hook output containing suppressOutput,
updatedInput, continue:false, stopReason — the CLI emits
"PreToolUse hook returned unsupported <field>" rather than ignoring the
field (despite the upstream docs claiming fail-open). Two changes:
1. Make codex.AllowSilent and codex.AllowWithInput actually diverge from
the claude.* aliases instead of inheriting the broken JSON shape.
AllowSilent now emits {} (no suppressOutput); AllowWithInput drops
the updatedInput map and preserves the reason via permissionDecision:
"allow".
2. Switch every unified Codex registration (codex-stop,
codex-pre-tool-use, codex-post-tool-use, codex-user-prompt-submit) to
call codex.* helpers instead of claude.* so future Codex quirks live
in the codex package, not the unified bridge.
Updates docs/reference-codex.md to correctly mark the four fields as
fail-closed. Adds regression tests in codex/helpers_test.go and
unified_test.go.
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Security Issues
- Codex MCP deny can fail open
The new Codex unifiedPreToolUsebridge inunified.goclassifiesmcp__*tools asExecutionMCPand routes all deny/ask decisions throughcodex.Deny(...). However, thecodex/types.godoc comment,codex/helpers.go, anddocs/reference-codex.mdall explicitly confirm that Codex only enforcespermissionDecision: "deny"forBashandapply_patchtools — MCP tools are not mentioned. A hook that returnsDenyExecution()for an MCP tool on Codex will have its deny output parsed but silently ignored, and the MCP tool will execute anyway. This allows an agent running under Codex to bypass any MCP-level policy — including SSRF allowlists or network-access controls — that a hookshot hook was intended to enforce.
Recommendation
In the codex-pre-tool-use handler (unified.go:508–516), add a guard that detects ExecutionMCP + a non-allow decision and falls back to a Codex-enforced blocking mechanism. Options include: returning an error/exit-code-2 via RunE if Codex honors that for MCP tools; using the PermissionRequest event handler (which does have enforced deny support) as the primary gate for MCP approval on Codex; or explicitly refusing to register the codex-pre-tool-use handler for MCP tools until Codex enforces the deny. Document the current limitation clearly in both codex/helpers.go and docs/reference-codex.md, and add an integration test that verifies a denied mcp__* call is actually blocked on Codex.
There was a problem hiding this comment.
Security Issues
- Codex PostToolUse Hook Bypass (Confirmed)
The Codex installer config incmd/hookshot/main.gosets thePostToolUsematcher to"apply_patch|mcp__.*", omittingBash. Codex routes file edits through Bashapply_patchheredocs at least as often as nativeapply_patchtool calls — evidenced by production session observations and an explicit code comment noting that "the SecurityScanResults dash stayed empty for every Codex session" prior to the Bash bridging logic being added. Thecodex-post-tool-usehandler (unified.go:874–889) contains a fully-implementedcase "Bash"branch that callscodex.ParseApplyPatchFromBashand dispatches per-file throughOnAfterFileEdit, but this branch is dead code because the missingBashmatcher prevents the hook from ever firing for Bash tool calls. All file/path/content security policies registered viaOnAfterFileEditare silently bypassed for every Codex Bash-wrapped patch, including path blocklists, content scanning, and audit telemetry.
Recommendations
- Add
Bashto the CodexPostToolUsematcher: change"apply_patch|mcp__.*"to"Bash|apply_patch|mcp__.*"atcmd/hookshot/main.go:505. The handler already safely short-circuits non-apply_patchBash commands viacodex.ParseApplyPatchFromBashreturningfalse.
b7889bc to
496112b
Compare
There was a problem hiding this comment.
Security Issues
- Codex PostToolUse hook bypass via missing Bash matcher (
cmd/hookshot/main.go:505):installCodexconfigurescodex-post-tool-usewith"matcher": "apply_patch|mcp__.*"for PostToolUse. The hook binary'scase "Bash"handler inunified.go:874— which callsParseApplyPatchFromBashand dispatchesOnAfterFileEditfor heredoc-style edits — is never reached because Codex never fires the PostToolUse hook for Bash tool events. All file edits Codex routes through Bash heredocs (apply_patch <<'PATCH' ...) therefore bypass everyOnAfterFileEditsecurity policy silently. Fix: addBashto the PostToolUse matcher. - Codex Bash apply_patch detector bypass via prepended marker (
codex/apply_patch.go:197):ParseApplyPatchFromBashusesstrings.Indexto find the first*** Begin Patchmarker, then checks only the text before that marker for anapply_patch <<invocation. A Codex-controlled Bash command can prepend a decoy marker (e.g.printf '*** Begin Patch\n' >/dev/null) so thatstrings.Indexanchors to the decoy, the realapply_patchinvocation falls after it, the prefix check fails, and the function returnsok=false— causing the actual patch to bypass allOnAfterFileEditpolicies. Fix: search for theapply_patch <<HEREDOCinvocation first, then locate the*** Begin Patchenvelope that follows it.
OnPromptSubmit was constructing the PromptContext without Cwd for the claude-user-prompt-submit and droid-user-prompt-submit handlers, even though input.Cwd is always populated by Claude Code and Droid when running in a project directory. Without this, ctx.Cwd was empty in every OnPromptSubmit handler for these two platforms, forcing callers to fall back to os.Getwd() or env-var heuristics (CLAUDE_PROJECT_DIR). The Codex handler already forwarded Cwd correctly. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Security Issues
-
Codex PostToolUse hook bypass (cmd/hookshot/main.go:505)
The default Codex installer configuresPostToolUsewith matcher"apply_patch|mcp__.*", omittingBash. Thecodex-post-tool-usehandler explicitly handlesToolName == "Bash"to detect heredoc-wrappedapply_patchedits — but this branch is never reached because the hook is never triggered for Bash tool calls. Codex routes file edits through Bash heredocs at least as often as through first-classapply_patchcalls. Any such edit silently bypassesOnAfterFileEditpolicies (secret scanning, path allowlists, etc.).Fix: Change line 505 in
cmd/hookshot/main.go:"matcher": "Bash|apply_patch|mcp__.*" -
Incomplete Bash apply_patch detection (codex/apply_patch.go:14)
The regex(?:^|[/\s;&|])apply_patch[ \t]+<<-?requires mandatory whitespace betweenapply_patchand<<, and accepts no whitespace after the operator. Shell-valid formsapply_patch<<'PATCH'(no leading space) andapply_patch << 'PATCH'(space after<<) both execute but returnok=falsefromParseApplyPatchFromBash, silently skippingOnAfterFileEditchecks for those edits.Fix: Broaden the regex to accept optional whitespace on both sides of the heredoc operator:
(?:^|[/\s;&|])apply_patch(?:[ \t]+|(?=<<))<<-?[ \t]*
Codex 0.130.0+ routes greenfield file writes through plain Bash
`cat <<'EOF' > FILE … EOF` (and `tee FILE <<'EOF' … EOF`) rather than
the apply_patch tool or any Write/Edit alias. ParseApplyPatchFromBash
correctly rejects these shapes, so the unified codex-post-tool-use
bridge silently no-op'd every "create a file" Bash invocation — no
afterFileEdit fired, no security scan ran, no PostToolUse telemetry
was emitted. End-to-end probe captured Codex emitting:
tool_name: "Bash"
tool_input.command: "cat <<'EOF' > greet.txt\nhello world\nEOF"
Add codex.ParseBashRedirectWrite, paralleling ParseApplyPatchFromBash:
- Detects cat / tee heredocs with `>`/`>>` redirects.
- Handles quoted (`<<'EOF'`, `<<"EOF"`) and unquoted (`<<EOF`)
delimiters plus `<<-DELIM` tab-stripping.
- Path is preserved verbatim so downstream policies can reject
suspicious paths (e.g. `../../etc/passwd`) themselves.
- Bails out (ok=false) on missing terminator rather than guessing.
Wire it into codex-post-tool-use as a fallback after the apply_patch
check, so edits keep their higher-fidelity per-hunk shape while
greenfield writes now flow through the same dispatchPatch reducer.
Tests: - codex/bash_write_test.go: 13 unit tests covering every variant.
- unified_test.go: integration tests for dispatch + block propagation.
Co-authored-by: Cursor <cursoragent@cursor.com>
The hookshot installer's PostToolUse matcher was `apply_patch|mcp__.*`, which never matches anything Codex 0.130.0+ actually emits for file edits — Codex routes both edits (`apply_patch <<'PATCH' …`) and writes (`cat <<'EOF' > FILE …`) through `tool_name="Bash"`, not the first-class apply_patch tool. Without Bash in the matcher, Codex never hands the events to the hook binary, so the freshly-added ParseBashRedirectWrite dispatch never gets a chance to fire and afterFileEdit telemetry stays empty for every Codex session. Update the installer to write `Bash|apply_patch|mcp__.*` for both PreToolUse and PostToolUse, matching what hookshot's unified bridge parses. Sync README, codex/doc.go, docs/reference-codex.md, docs/reference-unified.md, and examples/multi-hook to the new recommended matcher so copy-pasted configs stay correct. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Security Issues
- Security Hook Bypass (Validated)
The new Codex Bash heredoc write parser (codex/bash_write.go) only processes the first matchingcat/teeheredoc redirect in a Bash command.matchWriteRedirectcallsre.FindStringSubmatchIndex(line 107), which returns only the first regex match;ParseBashRedirectWritereturns immediately after the first successful match at line 96. A Codex Bash command containing a benign first heredoc write followed by one or more sensitive writes causesOnAfterFileEditto dispatch aFileEditContextonly for the first file. All subsequent writes — to paths such as.env,~/.ssh/authorized_keys, or any content-scanned target — are silently skipped by the post-tool bridge, bypassing all path-deny rules and secret/content scanners registered viaOnAfterFileEdit.
Recommendations
- In
matchWriteRedirect, replacere.FindStringSubmatchIndexwithre.FindAllStringSubmatchIndexand iterate over every match, building aPatchFilefor each well-formed heredoc write. Accumulate and return all results rather than returning after the first. - If a match is found but the heredoc body cannot be parsed (missing terminator, ambiguous delimiter), fail closed: return
(nil, false)so the caller inunified.gofalls through to the raw-command fallback path, which surfaces the full command to policy handlers rather than silently skipping it. - Add a test case covering a command that contains two
cat <<EOF >writes with distinct targets and verify bothPatchFileentries are returned.
Adds a dedicated 'Bash bridge: file edits routed through Bash' section to docs/reference-codex.md that explains why Codex 0.130.0+ sessions route file edits through plain Bash invocations rather than the native apply_patch / Write / Edit tools, and how OnAfterFileEdit reduces all four shapes to one per-file FileEditContext. The new section covers: - The four shapes the bridge recognizes today (Write/Edit native, apply_patch native, Bash apply_patch <<'PATCH' heredoc, Bash cat/tee <<'EOF' > FILE heredoc) with the parser used for each. - Why the PostToolUse matcher must include 'Bash' — the symptom of not including it is zero afterFileEdit events for any Codex session that edits files. - Detection rules for apply_patch via Bash heredoc, including the per-session shim-path variant. - How greenfield writes synthesize a single PatchEdit so they flow through the same dispatch path as diff-based edits, and which cat/tee variants (quoted/unquoted/<<- delimiters, >/>>, cd-prefix) are recognized. - Public API for handlers that want to bypass the unified bridge: ParseApplyPatch, ParseApplyPatchFromBash, ParseBashRedirectWrite, PatchFile, PatchEdit — with an example handler. - Known limitations: echo/printf > FILE not recognized, only the first cat/tee heredoc in a single Bash command is surfaced, and paths are passed through verbatim without normalization. Each limitation comes with a concrete defence-in-depth suggestion (OnBeforeExecution grep, filepath.Clean) so policy authors can close the gap themselves until the parsers grow stricter. The existing PostToolUse events-table row now points at both the new 'Bash bridge' section and the existing 'apply_patch on the unified API' section so readers land on the right page regardless of which shape they're investigating. Co-authored-by: Ashwin Ramaswami <ashwin@corridor.dev>
jake-corridor
left a comment
There was a problem hiding this comment.
mostly nits; but please see my comment on installCodex, that is probably worth addressing
| // "mcp__.*" so MCP tool calls reach the hook binary. "apply_patch" | ||
| // alone covers Codex file edits — Codex emits "Edit" and "Write" as | ||
| // matcher aliases for apply_patch, so they're redundant here. | ||
| hooks := map[string]any{ |
There was a problem hiding this comment.
Elsewhere, we are using strongly-typed structs when working JSON that has a know schema: claude/types.go, codex/types.go, etc.
There was a problem hiding this comment.
There was a problem hiding this comment.
Security Issues
-
Path Traversal Policy Bypass via Quoted Paths
The Codex Bash heredoc parser captures redirection paths using the regex(?P<path>[^\s;&|]+), which treats shell quote characters as ordinary path characters. A quoted traversal target like'../../.ssh/authorized_keys'is captured verbatim — including the surrounding single-quotes — and stored inFileEditContext.FilePath. Downstream hook policies applyingfilepath.Clean/filepath.Relcontainment checks operate on the quoted string (e.g.'../../.ssh/authorized_keys'), which starts with a quote character, not.., causing those checks to silently pass while Bash still writes to the actual unquoted path. The same rawpathcapture group affects both thecatRedirectHeredocREandteeRedirectHeredocREpatterns. -
File Edit Hook Bypass — Multiple Heredoc Writes
ThematchWriteRedirectfunction usesregexp.FindStringSubmatchIndex, which returns only the first match in a command string. A Bash command can chain multiple heredoc writes with;or&&separators; only the first file is dispatched toOnAfterFileEdit, leaving every subsequent write invisible to path/content security policies. The documentation acknowledges this as a known gap and recommends registering anOnBeforeExecutionpolicy as defense-in-depth, but implementations that rely solely onOnAfterFileEditremain vulnerable.
There was a problem hiding this comment.
Security Issues
-
Path policy bypass via shell-expanded redirection targets
wordTextreturns the raw shell source text (e.g.,"$HOME/.ssh/authorized_keys") for redirection targets that contain variable expansions, command substitutions, or globs. While this is documented as intentional so that downstream policy handlers can still inspect the surface form, it creates a systematic footgun: handlers performing afilepath.Clean/filepath.Relcontainment check againstctx.Cwdwill treat the unexpanded string as a literal relative path inside the project root, causing the check to pass silently even though Bash will write the file to an expanded, potentially out-of-tree location. Any hook author who writes a standard cwd-containment policy without specially handling non-literal paths is vulnerable. The fix is forwordText(or its callers) to signal when a path contains shell expansions, allowing the bridge to fail closed rather than forwarding a misleading path. -
Path policy bypass via multi-target
teewrites
teeRedirectWriteunconditionally breaks after recording the first non-option argument, butteewrites to every positional file operand. A command such astee allowed.txt ../../.ssh/authorized_keys <<'EOF'\n...\nEOFproduces two real files, yet theOnAfterFileEditbridge only fires forallowed.txt. Path-based deny rules for every subsequent operand are silently skipped. The fix is to iterate all non-option arguments and return onePatchFileper target, failing closed if any target contains an unresolvable shell expansion.
| continue | ||
| } | ||
| path = wordText(command, arg) | ||
| break |
There was a problem hiding this comment.
teeRedirectWrite stops after the first non-option argument:
for _, arg := range call.Args[1:] {
lit := arg.Lit()
if strings.HasPrefix(lit, "-") {
continue
}
path = wordText(command, arg)
break
}However, tee writes to every file operand. A command such as tee allowed.txt ../../.ssh/authorized_keys <<'EOF'\n...\nEOF creates both files, but the OnAfterFileEdit bridge only invokes the policy handler for allowed.txt. Any path-based deny rule for the second (and every subsequent) target is silently skipped.
Unlike the shell-expansion footgun, this bypass requires no special trick: all paths are plain literals that would pass wordToLiteral, so even a handler with a correct cwd-containment policy is bypassed because the dangerous path is never delivered to it.
Remediation: Remove the break and collect all non-option positional arguments into a []string. Adjust bashRedirectWriteFromStmt / ParseBashRedirectWrite to return one PatchFile per target path. Fail closed if any target is an unparseable shell expansion. Add a regression test covering tee FILE1 FILE2 <<'EOF'.
Attack Path
-
Codex emits:
tee allowed.txt ../../.ssh/authorized_keys <<'EOF'\nattacker-key\nEOF -
ParseBashRedirectWriteparses successfully;teeRedirectWriteis called viabashRedirectWriteFromStmt. -
teeRedirectWriterecordsallowed.txtaspathandbreaks —../../.ssh/authorized_keysis never visited. -
A single
PatchFile{FilePath: "allowed.txt"}is returned;dispatchPatchcalls the handler once forallowed.txt, which passes the containment check. -
Codex receives
PostToolOK(); Bash writes bothallowed.txtand../../.ssh/authorized_keys. The sensitive write bypassed every registered policy.
For more details, see the finding in Corridor.
Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.
There was a problem hiding this comment.
Thanks for the quick fix. I’ve marked this finding as a true positive and noted it as resolved in 059943e.
There was a problem hiding this comment.
Security Issues
-
Codex
catheredoc write target confusion:catRedirectWriterecords the last redirect matchingsyntax.RdrOut/syntax.AppOutwithout inspecting the fd-number fieldr.N. Inmvdan.cc/sh/v3, stderr redirects (2>) use the sameRdrOutoperator as stdout redirects (>), distinguished only byr.Nbeing set to"2". So a Bash command likecat <<EOF > .env 2> allowed.logcauses the parser to overwriteoutwith the2> allowed.logredirect (because it appears later instmt.Redirs) and reportsallowed.logtoOnAfterFileEditinstead of.env. Any path or secret policy that would block writes to.envnever fires. -
Codex
teemulti-target bypass:teeRedirectWritebreaks out of the argument loop after the first non-option filename.teewrites the heredoc body to every positional filename argument. A command liketee allowed.txt .env <<'EOF'writes to both files but onlyallowed.txtis dispatched toOnAfterFileEdit, leaving the.envwrite invisible to all security policies.
Recommendations
- For
cat: Only treat a redirect as the stdout write target whenr.Nis nil, empty, or"1". Redirects withr.N.Value == "2"(or any other non-stdout fd) should be ignored when selecting the heredoc body destination. - For
tee: Collect all non-option target filenames and dispatch aPatchFilefor each one, or fail closed when multiple targets are present.
There was a problem hiding this comment.
Security Issues
-
Codex Bash file-write monitoring bypass
The Codex bridge (unified.go:910-913) falls through toPostToolOK()for any Bash command that bothParseApplyPatchFromBashandParseBashRedirectWritefail to match.ParseBashRedirectWriteitself only detects heredoc writes (<<) whose primary command is the literalcatortee(bash_write.go:115-121). Any other file-write form —echo ... > .env,printf ... > .env,/bin/cat <<EOF > .env,command cat <<EOF > .env, or any write lacking a<<— returns(nil, false)from both parsers and therefore bypassesOnAfterFileEditentirely, silently skipping path-deny rules and secret scanners. TheTestParseBashRedirectWrite_NoHeredoc_PlainBashtest explicitly documents that plain-redirect forms such asecho hello > greet.txtandprintf 'hi' > greet.txtare out of scope and will trigger this bypass. -
Path containment bypass for shell-expanded redirect targets
When a heredoc redirect target contains shell metacharacters (parameter expansion such as$HOME, command substitution, arithmetic expansion, or tilde),wordToLiteral(bash_write.go:294-315) returns("", false).wordTextthen falls back to the verbatim source slice (bash_write.go:282-287), returning a string such as"$HOME/outside.txt"(with the surrounding quotes included). This non-empty string is stored inPatchFile.FilePathand surfaced asctx.FilePathinFileEditContext. A policy that evaluatesfilepath.Rel(ctx.Cwd, ctx.FilePath)will see a string beginning with"rather than.., so astrings.HasPrefix(rel, "..")containment check incorrectly passes while Bash actually expands$HOMEat runtime and writes outside the workspace.
Recommendations
- For the write-monitoring bypass: either extend
ParseBashRedirectWriteto detect all Bash write-redirect forms (including non-heredoc>/>>redirections and absolute-path or wrapped command variants), or fail closed by detecting any stdout-targeting redirect in the AST and dispatching a syntheticFileEditContextwith an emptyFilePathso policies are forced to make an explicit decision rather than silently allowing the write. - For the path containment bypass: reject or explicitly mark redirect targets that
wordToLiteralcannot fully resolve as unsafe/unknown — return(nil, false)fromcatRedirectWrite/teeRedirectWritewhenwordTextreturns a raw expansion-containing string — so policies never receive actx.FilePaththat cannot be reliably compared againstctx.Cwd.
| return nil, false | ||
| } | ||
| switch call.Args[0].Lit() { | ||
| case "cat": |
There was a problem hiding this comment.
This parser only recognizes heredoc writes when the primary command is the literal cat or tee:
switch call.Args[0].Lit() {
case "cat":
return catRedirectWrite(command, stmt)
case "tee":
return teeRedirectWrite(command, stmt, call)
}
return nil, falseIn the Codex bridge (unified.go:910-913), when both ParseApplyPatchFromBash and ParseBashRedirectWrite return ok=false, the handler falls through to codex.PostToolOK() without invoking OnAfterFileEdit. An agent can therefore write files using other valid Bash forms:
printf '%s\n' '[REDACTED_SECRET]' > .env(no heredoc, non-cat/tee command)echo ... > .env(no heredoc)/bin/cat <<EOF > .env(absolute path:call.Args[0].Lit()returns"")command cat <<EOF > .env(builtin wrapper: primary token iscommand)
The TestParseBashRedirectWrite_NoHeredoc_PlainBash test (bash_write_test.go:126-141) explicitly documents that echo hello > greet.txt and printf 'hi' > greet.txt are out-of-scope and return ok=false. This bypasses all path-deny rules and secret scanners implemented on the unified file-edit hook.
Remediation: Add a fallback redirect detector after both parsers fail. Walk the Bash AST for any RdrOut/AppOut/RdrAll/AppAll redirect on stdout; if found, dispatch a synthetic FileEditContext with FilePath: "" (or a <unknown-bash-write> sentinel) so policies are forced to make an explicit allow/block decision rather than silently passing the write.
Attack Path
-
Codex agent issues a
Bashtool call:printf 'STRIPE_KEY=[REDACTED_API_KEY]\n' > .env -
codex-post-tool-usehook fires; bridge atunified.go:907callsParseApplyPatchFromBash→(nil, false)(no patch marker). -
Bridge at
unified.go:910callsParseBashRedirectWrite→(nil, false)(no<<in command, pre-check atbash_write.go:78short-circuits). -
Bridge falls through to
return codex.PostToolOK()atunified.go:913—OnAfterFileEditis never invoked. -
Path-deny rules not evaluated; secret scanners do not run; no
SecurityScanResulttelemetry emitted. File write proceeds on disk.
For more details, see the finding in Corridor.
Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.
There was a problem hiding this comment.
Thanks for the quick reply. To make sure I handle this correctly, can you confirm whether you want to: (a) accept the risk and override the gate for this PR, (b) mark this as a false positive/out of scope, or (c) treat it as a valid issue and keep the gate until a fix? Today, commands like echo/printf > file bypass OnAfterFileEdit in the bridge (unified.go:910–913), which skips path-deny rules and secret scanning (CWE-693). If you’d like a lightweight mitigation, I can propose a patch to add a fallback redirect detector (scan AST for >, >>, &>, &>>) and dispatch a synthetic FileEditContext so policies are forced to decide (fail closed per OWASP best practices). Let me know which option you prefer and I’ll proceed accordingly.
| // fallback matters for paths like `$HOME/.env` where downstream policy | ||
| // still needs to see the surface text even though Bash would expand it. | ||
| func wordText(command string, w *syntax.Word) string { | ||
| if w == nil { |
There was a problem hiding this comment.
wordText falls back to returning the raw shell source for redirect targets that require expansion:
start := int(w.Pos().Offset())
end := int(w.End().Offset())
if start < 0 || end > len(command) || start >= end {
return ""
}
return command[start:end]For a target like "$HOME/outside.txt", wordToLiteral returns ("", false) because the DblQuoted word contains a ParamExp node. wordText then returns command[start:end] = "$HOME/outside.txt" including the outer double-quote characters (the AST offsets span the full quoted token). This non-empty string passes the path == "" guard at bash_write.go:164 and is stored in PatchFile.FilePath, then surfaced as ctx.FilePath in FileEditContext (unified.go:804).
A policy performing filepath.Rel(ctx.Cwd, ctx.FilePath) receives a path that starts with " (a double-quote character) rather than / or ... A strings.HasPrefix(rel, "..") containment check silently passes while Bash actually expands $HOME at runtime and writes to an absolute path outside the workspace.
This is structurally identical to the prior regex-based bypass documented in bash_write_test.go:311-333, where single-quoted paths caused the same '-prefix confusion — fixed for literal-quoted paths by wordToLiteral, but reintroduced by the expansion fallback in wordText.
Remediation: In catRedirectWrite and teeRedirectWrite, fail closed when wordToLiteral returns ok=false: return (nil, false) instead of calling wordText. This causes ParseBashRedirectWrite to return (nil, false) for expansion-containing targets, causing the bridge to invoke the dispatchPatch fallback with len(files)==0 (raw command as edit content, empty FilePath) — giving policies an explicit opportunity to block.
Attack Path
-
Codex agent issues:
cat <<'EOF' > "$HOME/.ssh/authorized_keys"\nattacker-key\nEOF -
ParseBashRedirectWriteparses the AST;catRedirectWritecallswordTextfor the redirect target. -
wordToLiteralfails (DblQuoted contains ParamExp).wordTextfalls back tocommand[start:end]="$HOME/.ssh/authorized_keys"(with surrounding quotes). -
Non-empty path passes the
path == ""guard;PatchFile.FilePath="$HOME/.ssh/authorized_keys". -
ctx.FilePath="$HOME/.ssh/authorized_keys".filepath.Rel(ctx.Cwd, ctx.FilePath)returns a string starting with", not... Containment check passes. -
Handler allows the write. Bash expands
$HOMEat runtime and writes to the real absolute path outside the workspace.
For more details, see the finding in Corridor.
Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.
There was a problem hiding this comment.
Thanks for the reply. To make sure we handle this correctly, can you confirm whether you want this finding marked as a true positive or a false positive? As a reminder, the current behavior lets expansion-containing targets like "$HOME/foo" bypass path containment checks that rely on filepath.Rel + HasPrefix(".."), which is a CWE-22 style risk. If you’d like to proceed with a fix, the safest change is to fail closed in catRedirectWrite/teeRedirectWrite: when wordToLiteral returns ok=false, return (nil, false) so the bridge falls back to dispatching an edit with an empty FilePath and policy can explicitly decide. I can mark this as true positive and note the remediation, or mark it as a false positive based on your decision.
Summary
Adds first-class support for OpenAI Codex hooks to hookshot. Codex hooks use the same JSON wire format as Claude Code hooks, so the implementation re-uses the existing
claude.*types while exposing the Codex-specific differences (theapply_patchtool with its unified-diff envelope, themcp__*matcher convention, thecodex_hooks/hooksfeature flag, and theAsk/updatedInput/suppressOutputfail-closed quirks) where they matter.Also fixes
OnPromptSubmitfor Claude Code and Droid:ctx.Cwdwas previously empty for both platforms because the bridge wasn't forwardinginput.Cwdinto thePromptContext. Codex was already correct; this PR aligns Claude and Droid.What's new
Bug fixes
OnPromptSubmitnow populatesctx.Cwdfor Claude Code and Droid. Both platforms sendcwdin the hook stdin, but theclaude-user-prompt-submitanddroid-user-prompt-submithandlers were constructingPromptContextwithout forwarding it. This meant callers had to fall back toos.Getwd()orCLAUDE_PROJECT_DIRheuristics — neither of which is reliable when hooks are launched as subprocesses by a plugin from a cache directory. Thecodex-user-prompt-submithandler already forwardedCwdcorrectly; the two missing cases are fixed here.updatedInputandsuppressOutputare rejected by the Codex runtime withunsupported <field>errors (the upstream doc currently claims fail-open; runtime behavior is fail-closed). The codex package's helper output now omits both fields and the unified bridge routes through the codex package's PostTool* helpers so this stays correct without per-caller workarounds.AskExecutionis rewritten toDenyon Codex. Codex parses but does not enforcepermissionDecision: "ask"today, so an Ask decision would silently fail open. The unified bridge fails closed; on every other platformAskcontinues to surface an approval prompt.Bashfor both PreToolUse and PostToolUse (Bash|apply_patch|mcp__.*). The old PostToolUse matcher (apply_patch|mcp__.*) never matches anything Codex 0.130.0+ actually emits for file edits.Unified API
PlatformCodexconstant inunified.go.codex-*entry:OnStop→codex-stopOnBeforeExecution→codex-pre-tool-use(classifiesBash,mcp__*, andapply_patchaccordingly; surfacesapply_patch's patch text viactx.Command)OnAfterFileEdit→codex-post-tool-use(handles all four file-edit shapes — see "Bash bridge" below)OnPromptSubmit→codex-user-prompt-submitStopContext.ShouldSkip()honorsStopHookActivefor Codex (same as Claude Code / Droid).FileEditContextgains aNewFilePathfield populated for rename operations. For Codex apply_patch*** Move to:, the unified bridge invokes the handler twice — once withFilePathset to the source and once withFilePathset to the destination — and populatesNewFilePathon both invocations. This means aFilePath-only allowlist that permits the benign source still receives a separate event for the destination so it can deny moves to sensitive paths like../../.ssh/authorized_keys. Handlers that want to detect a rename should checkctx.NewFilePath != "" && ctx.NewFilePath != ctx.FilePath.Codex Bash bridge (the production fix)
codex-post-tool-usenow handles all four shapes Codex emits for file operations and reduces them to the sameOnAfterFileEditpipeline:tool_nametool_input.commandshapeWrite/Editapply_patchcodex.ParseApplyPatchBash(edit)apply_patch <<'PATCH' … *** End Patch … PATCHcodex.ParseApplyPatchFromBashBash(write)cat <<'EOF' > FILE … EOF(andtee)codex.ParseBashRedirectWriteParseBashRedirectWritehandles:<<'EOF',<<"EOF") and unquoted (<<EOF) heredoc delimiters,<<-tab-stripping,>(overwrite) and>>(append) redirects,cat …andtee [-a] …invocations,cd … && cat …cwd-prefixed forms.Paths are preserved verbatim so downstream policies can reject suspicious paths (e.g.
../../etc/passwd) themselves. The parser bails out (ok=false) on missing heredoc terminator rather than guessing — a well-formed Codex command always terminates the heredoc.For Codex apply_patch envelopes (cases 2 & 3) we run them through
codex.ParseApplyPatchand invoke the user's handler exactly once per file. For greenfield writes (case 4) we synthesize a singleFileEdit{OldString: "", NewString: body}so the samedispatchPatchreducer drives every shape. Per-file decisions reduce as: anyFileEditBlockwins (reasons concatenated), otherwise context strings are concatenated.Platform package
codex/package (types.go,helpers.go,doc.go,apply_patch.go,bash_write.go,helpers_test.go) that re-exports the relevantclaudetypes and helpers with Codex-flavored documentation, plus the parsers used by the unified bridge.Installer
hookshot install --codex --binary …writes~/.codex/hooks.jsonwith matchers that includeBash|apply_patch|mcp__.*for both PreToolUse and PostToolUse so the bridge actually sees the events.[features].hooks = truefeature flag in~/.codex/config.tomlmust be enabled separately (the legacycodex_hooks = truealias only loads the legacy hook subset on 0.130.0+).hookshot installwithout flags now installs to Codex as well.Docs
docs/reference-codex.mdcovering every Codex event, the helpers, the fail-closed fields, theAsk→Denyrewrite, theapply_patchparsing semantics (including the rename double-invocation), theBashbridge, and why the matcher needsBash|apply_patch|mcp__.*.README.md,doc.go,hookshot.go,docs/reference-unified.md,codex/doc.go, andexamples/multi-hookupdated to reference the new bridge, the newcodex-*handler names, the updated matchers, and the newNewFilePathfield.Tests
codex/bash_write_test.go— 13 unit tests covering every variant ofcat/teeheredoc writes (quoted/unquoted/<<-delimiters, append, cd-prefix, empty-line bodies, missing-terminator bail-out, fake-delimiter-in-body, path-traversal preservation).codex/apply_patch_test.go— parser tests covering Add, Update, Delete, multi-hunk Update, multi-file patches,Move to:renames, leading here-doc wrappers, malformed input, and Bash-heredoc envelope detection (with the apply_patch invocation regex requiring an actual<<operator to avoid false positives on filenames or doc strings).unified_test.go— integration tests for the unified bridge:TestStopContext_ShouldSkip_Codex+ expandedTestPlatformConstants.TestCodexPreToolUse_AskFailsClosed— Ask decision must emitpermissionDecision: "deny", not"ask".TestCodexPreToolUse_ApplyPatchPopulatesCommand—apply_patchtool_input.command flows through toExecutionContext.Command.TestCodexPreToolUse_MCPClassifiedAsMCP— MCP tool names hit the handler asExecutionMCP.TestCodexPostToolUse_ApplyPatchParsesFilesAndEdits— multi-file Add/Update/Delete patch invokes the handler once per file with the rightFilePathandEdits.TestCodexPostToolUse_ApplyPatchMoveToInvokesHandlerForDestination—*** Move to:invokes the handler twice (source + destination) and populatesNewFilePathon both.TestCodexPostToolUse_ApplyPatchMoveToBlockOnDestinationPropagates— a FilePath-only policy blocking.ssh/authorized_keyscorrectly blocks even when the source path is benign.TestCodexPostToolUse_BashApplyPatchHeredocDispatches—apply_patch <<'PATCH'shape on Bash routes throughOnAfterFileEditidentically to the native path.TestCodexPostToolUse_BashApplyPatchAbsolutePathBinaryDispatches— per-session shim path prefix still recognized.TestCodexPostToolUse_BashNonApplyPatchCommandSkipped— ordinary Bash commands don't trigger file-edit handler.TestCodexPostToolUse_BashCatHeredocWriteDispatches—cat <<'EOF' > FILE … EOFgreenfield writes now route throughOnAfterFileEditwith the correctFilePath+ synthesizedEdits.TestCodexPostToolUse_BashCatHeredocBlockPropagates— a FilePath-suffix-based block policy (*.env) correctly results inPostToolBlockfor cat-heredoc writes.TestCodexPostToolUse_BashApplyPatchBlockPropagates— any per-fileFileEditBlockresults in aPostToolBlockwith the combined reason.Verification
Plus an end-to-end check via the probe harness: asking
codex execto create a new file now produces anafterFileEditevent with the correct file path and body (previously zero events; see commit8378bdfbody for the captured trace).Notes
apply_patchis classified asExecutionTool(not Shell or MCP) in the unifiedOnBeforeExecution, so handlers can detect it viactx.ToolName == "apply_patch". The patch text is also exposed viactx.Commandso policies can inspect it without re-parsingToolInput.BaseInput(model,turn_id,last_assistant_message). They remain accessible viahookshot.ReadRawInputand are documented indocs/reference-codex.md.[features].hooksfeature flag — that's still the user's responsibility, and the installer prints a reminder. On Codex 0.130.0+ the deprecatedcodex_hooks = truealias does NOT enable shell-tool routing for Bash PreToolUse/PostToolUse, so users with that flag set will only see SessionStart/UserPromptSubmit/Stop + MCP tool calls fire.codex.Ask(the platform-level helper) still emits"ask"for forward-compat / round-trip testing; only the unifiedOnBeforeExecutionbridge rewrites it toDeny.FileEditContext.NewFilePathis empty for in-place edits and on every non-Codex platform; this addition is strictly additive for existing handlers.ParseBashRedirectWritedoes not currently coverecho … > FILEorprintf … > FILEshapes — those are uncommon in Codex's output (it prefers heredocs for any multi-line content) and would require a slightly different parser. Easy to add as a follow-up if real sessions surface them.