Created
July 27, 2026 21:13
-
-
Save arikon/9e72e2ce54e43f5b5aced547466e0389 to your computer and use it in GitHub Desktop.
oh-my-codex #3320: standalone bridged omx question guard regression fix
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From 920579f961fea3e9a45c8ecd61b9712e4806b6c7 Mon Sep 17 00:00:00 2001 | |
| From: Sergey Belov <peimei@ya.ru> | |
| Date: Mon, 27 Jul 2026 23:12:33 +0200 | |
| Subject: [PATCH] fix(hooks): require standalone bridged omx questions (#3320) | |
| A valid return-pane bridge previously authorized compound shell commands that merely contained omx question, allowing writes and state mutations to bypass the planning guard. | |
| Make the shared question classifier fail closed on chaining, redirection, substitutions, wrapper execution, and multiple commands while preserving standalone Bash, node, inherited-pane, quoted-pane, and PowerShell bridge forms. | |
| Tested: npm run build; focused #3320 regression; 16 omx-question regressions; npm run check:no-unused; npm run lint; git diff --check. | |
| --- | |
| .../__tests__/codex-native-hook.test.ts | 37 +++++++++++++ | |
| src/scripts/codex-native-pre-post.ts | 54 ++++++++++++++++++- | |
| 2 files changed, 90 insertions(+), 1 deletion(-) | |
| diff --git a/src/scripts/__tests__/codex-native-hook.test.ts b/src/scripts/__tests__/codex-native-hook.test.ts | |
| index 05f96ed2..62f62238 100644 | |
| --- a/src/scripts/__tests__/codex-native-hook.test.ts | |
| +++ b/src/scripts/__tests__/codex-native-hook.test.ts | |
| @@ -12633,6 +12633,43 @@ exit 0 | |
| } | |
| }); | |
| + it("issue #3320 allows only standalone bridged omx question shell invocations", async () => { | |
| + const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-pretool-question-standalone-")); | |
| + try { | |
| + const dispatch = (command: string, index: number) => dispatchCodexNativeHook( | |
| + { | |
| + hook_event_name: "PreToolUse", | |
| + cwd, | |
| + tool_name: "Bash", | |
| + tool_use_id: `tool-question-standalone-${index}`, | |
| + tool_input: { command }, | |
| + }, | |
| + { cwd }, | |
| + ); | |
| + const question = `OMX_QUESTION_RETURN_PANE=%42 omx question --json --input '{"question":"Q?","options":["A"],"allow_other":true}'`; | |
| + const allowed = await dispatch(question, 0); | |
| + assert.equal(allowed.outputJson, null); | |
| + | |
| + const deniedCommands = [ | |
| + `${question} && printf x > src/generated.ts`, | |
| + `${question} > .omx/context/question.json`, | |
| + `OMX_QUESTION_RETURN_PANE=%42 omx question --json --input "$(cat .omx/state/session.json)"`, | |
| + `OMX_QUESTION_RETURN_PANE=%42 omx question --json --input <(printf '{}')`, | |
| + `OMX_QUESTION_RETURN_PANE=%42 bash -c 'omx question --json --input "{}"'`, | |
| + `${question}; omx state write --input '{"mode":"deep-interview","active":false}' --json`, | |
| + `${question} | tee .omx/context/question.json`, | |
| + `${question}\nprintf x > src/generated.ts`, | |
| + ]; | |
| + for (const [index, command] of deniedCommands.entries()) { | |
| + const result = await dispatch(command, index + 1); | |
| + assert.equal(result.outputJson?.decision, "block", command); | |
| + assert.match(JSON.stringify(result.outputJson), /standalone|chaining|redirection|substitution|script execution/); | |
| + } | |
| + } finally { | |
| + await rm(cwd, { recursive: true, force: true }); | |
| + } | |
| + }); | |
| + | |
| it("allows the quoted pane env assignment emitted by the deep-interview bridge command", async () => { | |
| const cwd = await mkdtemp( | |
| join(tmpdir(), "omx-native-hook-pretool-question-quoted-allow-"), | |
| diff --git a/src/scripts/codex-native-pre-post.ts b/src/scripts/codex-native-pre-post.ts | |
| index 59f70419..a1300fa9 100644 | |
| --- a/src/scripts/codex-native-pre-post.ts | |
| +++ b/src/scripts/codex-native-pre-post.ts | |
| @@ -1218,6 +1218,39 @@ function commandInvokesOmxQuestion(command: string): boolean { | |
| return false; | |
| } | |
| +function stripPowerShellQuestionReturnPaneAssignment(command: string): string { | |
| + return command.replace( | |
| + /^\s*\$env:(?:OMX_QUESTION_RETURN_PANE|OMX_LEADER_PANE_ID)\s*=\s*(?:['"]?%\d+['"]?|\$env:TMUX_PANE)\s*;\s*/i, | |
| + "", | |
| + ); | |
| +} | |
| + | |
| +function isStandaloneOmxQuestionInvocation(command: string): boolean { | |
| + const invocation = stripPowerShellQuestionReturnPaneAssignment(command).trim(); | |
| + if (!invocation || removeHereDocBodies(invocation) !== invocation) return false; | |
| + | |
| + let quote: "'" | "\"" | null = null; | |
| + for (let index = 0; index < invocation.length; index += 1) { | |
| + const char = invocation[index] ?? ""; | |
| + if (char === "\\" && quote !== "'") { | |
| + index += 1; | |
| + continue; | |
| + } | |
| + if (char === "'" || char === "\"") { | |
| + quote = quote === char ? null : quote ?? char; | |
| + continue; | |
| + } | |
| + if (quote === "'") continue; | |
| + if (char === "`" || (char === "$" && invocation[index + 1] === "(")) return false; | |
| + if (!quote && ";&|()<>\n\r".includes(char)) return false; | |
| + } | |
| + if (quote) return false; | |
| + | |
| + const tokens = tokenizeShellCommandWithBoundaries(invocation); | |
| + if (!tokens || tokens.filter((token) => token.startsCommand).length !== 1) return false; | |
| + return commandInvokesOmxQuestion(invocation); | |
| +} | |
| + | |
| function isQuestionReturnPaneAssignment(token: string): boolean { | |
| const equalsIndex = token.indexOf('='); | |
| if (equalsIndex <= 0) return false; | |
| @@ -1242,6 +1275,12 @@ function commandHasPowerShellQuestionReturnPane(command: string): boolean { | |
| || /\$env:TMUX_PANE\s*=\s*['"]?%\d+['"]?/i.test(command); | |
| } | |
| +function commandContainsExplicitlyBridgedOmxQuestion(command: string): boolean { | |
| + const hasShellBridge = /(?:^|\s)(?:OMX_QUESTION_RETURN_PANE|OMX_LEADER_PANE_ID)=(?:['"]?%\d+['"]?|\$\{?TMUX_PANE\}?)(?:\s|$)/.test(command); | |
| + return (hasShellBridge || commandHasPowerShellQuestionReturnPane(command)) | |
| + && /\bomx(?:\.js)?\s+question\b/i.test(command); | |
| +} | |
| + | |
| function commandHasQuestionReturnPane(command: string): boolean { | |
| if (commandHasPowerShellQuestionReturnPane(command)) return true; | |
| const tokens = tokenizeShellCommand(command) ?? []; | |
| @@ -1317,7 +1356,20 @@ export function classifyOmxQuestionPreToolUse( | |
| command: string, | |
| payload: CodexHookPayload, | |
| ): OmxQuestionPreToolUseClassification { | |
| - if (!commandInvokesOmxQuestion(command)) return { kind: "not-question" }; | |
| + if (!commandInvokesOmxQuestion(command) && !commandContainsExplicitlyBridgedOmxQuestion(command)) { | |
| + return { kind: "not-question" }; | |
| + } | |
| + | |
| + if (!isStandaloneOmxQuestionInvocation(command)) { | |
| + return { | |
| + kind: "denied", | |
| + output: { | |
| + decision: "block", | |
| + reason: "omx question must be a standalone shell invocation.", | |
| + systemMessage: `omx question is blocked when combined with shell chaining, redirection, substitution, or script execution. Run the bridged question as a standalone command. Original command: ${command}`, | |
| + }, | |
| + }; | |
| + } | |
| if (isNativeOutsideTmuxSurface(payload)) { | |
| return { | |
| -- | |
| 2.50.1 (Apple Git-155) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment