Skip to content

Commit 98e5a1f

Browse files
NagyViktNagyViktOmX
authored
Prove mutating tool actions before they run (#9)
Native hook dispatch now records mutating tool lifecycle context before edit-capable tools execute and pairs the post event by trace id after the tool returns. The trace state is best-effort so write failures warn and continue instead of blocking the tool path. Constraint: Codex native hooks must stay non-blocking by default when event persistence fails Rejected: Post-only detection | cannot prove the event preceded the mutation Confidence: high Scope-risk: moderate Tested: npm run build Tested: node --test dist/scripts/__tests__/codex-native-hook.test.js dist/config/__tests__/codex-hooks.test.js Tested: node --test dist/hooks/extensibility/__tests__/dispatcher.test.js Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 5c568d1 commit 98e5a1f

6 files changed

Lines changed: 210 additions & 14 deletions

File tree

docs/codex-native-hooks.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ OMX only owns the wrapper entries that invoke `dist/scripts/codex-native-hook.js
3131
| `session-start` | `SessionStart` | `session-start` | native | Native adapter refreshes leader session bookkeeping, preserves the canonical leader scope when a native subagent `SessionStart` is detected from rollout `session_meta`, restores startup developer context, and ensures `.omx/` is gitignored at the repo root |
3232
| wiki startup context | `SessionStart` | `session-start` | native | Wiki session-start context can append a compact `.omx/wiki/` summary when wiki pages exist; startup writes stay config-gated |
3333
| `keyword-detector` | `UserPromptSubmit` | `keyword-detector` | native | Persists skill activation state and can add prompt-side developer context; `$ralph` prompt routing seeds workflow state only and does not launch `omx ralph --prd ...` |
34-
| `pre-tool-use` | `PreToolUse` (`Bash`, `Edit`, `MultiEdit`, `Write`, `NotebookEdit`, `ApplyPatch`, `apply_patch`, `Patch`) | `pre-tool-use` | native | Native scope covers Bash plus file-mutating edit tools. Before the tool proceeds, the native adapter adds repo-relative `extracted_paths` when it can identify claimable targets, then best-effort calls `colony hook run pre-tool-use --ide codex` with session id, cwd, git repo/branch metadata, tool name, and compact tool input so Colony can auto-claim before edit. Valid Colony advisories are surfaced as allow-only hook context and do not block by default; Colony transport failures stay silent in Codex and are recorded under `.omx/logs/colony-bridge-failures-*.jsonl`. Built-in native behavior still cautions on `rm -rf dist`, blocks inspectable inline `git commit` commands until Lore-format structure + the required `Co-authored-by: OmX <omx@oh-my-codex.dev>` trailer are present, and emits non-blocking document-refresh warnings for mapped staged commit changes that lack rule-scoped docs/spec refresh evidence |
35-
| `post-tool-use` | `PostToolUse` (`Bash`) | `post-tool-use` | native-partial | Current native scope is Bash-only; built-in native behavior covers command-not-found / permission-denied / missing-path guidance only from stderr or non-zero Bash results, ignores failure-looking strings from successful source/log reads, and keeps MCP transport-death guidance scoped to MCP-like tool calls; document-refresh commit warnings use PreToolUse advisory output, with PostToolUse reserved as a future fallback if Codex advisory semantics change |
34+
| `pre-tool-use` | `PreToolUse` (`Bash`, `Edit`, `MultiEdit`, `Write`, `NotebookEdit`, `ApplyPatch`, `apply_patch`, `Patch`) | `pre-tool-use` | native | Native scope covers Bash plus file-mutating edit tools. Before the tool proceeds, the native adapter logs `pre_tool_use` trace context with `trace_id`, session/repo metadata, compact `tool_input`, and repo-relative `extracted_paths`, then best-effort calls `colony hook run pre-tool-use --ide codex` so Colony can auto-claim before edit. Valid Colony advisories are surfaced as allow-only hook context and do not block by default; Colony transport failures stay silent in Codex and are recorded under `.omx/logs/colony-bridge-failures-*.jsonl`. Built-in native behavior still cautions on `rm -rf dist`, blocks inspectable inline `git commit` commands until Lore-format structure + the required `Co-authored-by: OmX <omx@oh-my-codex.dev>` trailer are present, and emits non-blocking document-refresh warnings for mapped staged commit changes that lack rule-scoped docs/spec refresh evidence |
35+
| `post-tool-use` | `PostToolUse` (`Bash`, `Edit`, `MultiEdit`, `Write`, `NotebookEdit`, `ApplyPatch`, `apply_patch`, `Patch`, MCP/OMX parity tools) | `post-tool-use` | native | Mutating tool events are logged after the tool runs with the same `trace_id`; orphan mutating posts carry `missing_pre_tool_use: true`. Built-in Bash behavior covers command-not-found / permission-denied / missing-path guidance only from stderr or non-zero Bash results, ignores failure-looking strings from successful source/log reads, and keeps MCP transport-death guidance scoped to MCP-like tool calls |
3636
| Ralph/persistence stop handling | `Stop` | `stop` | native-partial | Native adapter uses the documented native Stop continuation contract (`decision: "block"` + `reason`) for active Ralph runs, emits a single JSON object on Stop stdout even for no-op Stop decisions, and emits deterministic JSON continuation output if Stop dispatch fails before normal handling |
3737
| Autopilot continuation | `Stop` | `stop` | native-partial | Native adapter continues non-terminal autopilot sessions from active session/root mode state |
3838
| Ultrawork continuation | `Stop` | `stop` | native-partial | Native adapter continues non-terminal ultrawork sessions from active session/root mode state |
@@ -43,7 +43,7 @@ OMX only owns the wrapper entries that invoke `dist/scripts/codex-native-hook.js
4343
| auto-nudge continuation | `Stop` | `stop` | native-partial | Native adapter continues turns that end in a permission/stall prompt, can re-fire for later fresh replies, and suppresses auto-nudge while interview / deep-interview state is active; explicit terminal lifecycle metadata should be authoritative when present, legacy `blocked_on_user` remains a suppress-continuation compatibility signal, and `cancelled` stays internal legacy-only for user-facing lifecycle summaries |
4444
| `ask-user-question` | none | runtime-only | runtime-fallback | No distinct Codex native hook today |
4545
| `PostToolUseFailure` | none | runtime-only | runtime-fallback | Fold into runtime/fallback handling until native support exists |
46-
| non-Bash tool interception | `PreToolUse` edit-family matchers | `pre-tool-use` | native-partial | Edit-family `PreToolUse` is native for Colony claim-before-edit and allow-only warnings. Tool-specific OMX policy logic beyond claim-before-edit still belongs in explicit native handlers or runtime fallbacks. |
46+
| non-mutating tool interception | none | runtime-only | runtime-fallback | Native hook coverage is intentionally scoped to mutating tools plus MCP/OMX post-failure parity tools; other non-mutating tool telemetry remains on runtime/plugin surfaces |
4747
| code simplifier stop follow-up | none | runtime-only | runtime-fallback | Cleanup follow-up stays on runtime/fallback surfaces, not native Stop |
4848
| `SubagentStop` | none | runtime-only | not-supported-yet | OMC-specific lifecycle extension |
4949
| `session-end` | none | `session-end` | runtime-fallback | Still emitted from runtime/notify path, not native Codex hooks |

src/config/__tests__/codex-hooks.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ describe("codex hooks helpers", () => {
1111
it("installs PreToolUse coverage for edit-capable tools", () => {
1212
const config = buildManagedCodexHooksConfig("/repo");
1313
const preToolUse = config.hooks.PreToolUse[0] as { matcher?: string };
14+
const postToolUse = config.hooks.PostToolUse[0] as { matcher?: string };
1415

1516
assert.equal(
1617
preToolUse.matcher,
1718
"Bash|Edit|MultiEdit|Write|NotebookEdit|ApplyPatch|apply_patch|Patch",
1819
);
20+
assert.equal(
21+
postToolUse.matcher,
22+
"Bash|Edit|MultiEdit|Write|NotebookEdit|ApplyPatch|apply_patch|Patch|mcp__.*|omx_.*|state_.*|project_memory_.*|notepad_.*|trace_.*",
23+
);
1924
});
2025

2126
it("merges managed wrappers without dropping user hooks", () => {

src/config/codex-hooks.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ export const MANAGED_HOOK_EVENTS = [
88
"Stop",
99
] as const;
1010

11+
const MUTATING_TOOL_HOOK_MATCHER =
12+
"Bash|Edit|MultiEdit|Write|NotebookEdit|ApplyPatch|apply_patch|Patch";
13+
1114
type ManagedHookEventName = (typeof MANAGED_HOOK_EVENTS)[number];
1215

1316
type JsonObject = Record<string, unknown>;
1417

15-
const POST_TOOL_USE_MATCHER =
16-
"Edit|Write|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|Patch|mcp__.*|omx_.*|state_.*|project_memory_.*|notepad_.*|trace_.*";
18+
const POST_TOOL_USE_MATCHER = `${MUTATING_TOOL_HOOK_MATCHER}|mcp__.*|omx_.*|state_.*|project_memory_.*|notepad_.*|trace_.*`;
1719

1820
export interface ManagedCodexHooksConfig {
1921
hooks: Record<ManagedHookEventName, Array<Record<string, unknown>>>;
@@ -75,8 +77,7 @@ export function buildManagedCodexHooksConfig(
7577
],
7678
PreToolUse: [
7779
buildCommandHook(command, {
78-
matcher:
79-
"Bash|Edit|MultiEdit|Write|NotebookEdit|ApplyPatch|apply_patch|Patch",
80+
matcher: MUTATING_TOOL_HOOK_MATCHER,
8081
}),
8182
],
8283
PostToolUse: [

src/hooks/extensibility/dispatcher.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,19 @@ export async function dispatchHookEvent(
357357
return summary;
358358
}
359359

360+
await appendHooksLog(cwd, {
361+
type: "hook_dispatch",
362+
event: event.event,
363+
source: event.source,
364+
enabled: true,
365+
reason: "dispatching",
366+
session_id: event.session_id || null,
367+
thread_id: event.thread_id || null,
368+
turn_id: event.turn_id || null,
369+
mode: event.mode || null,
370+
context: event.context,
371+
});
372+
360373
const plugins = await discoverHookPlugins(cwd);
361374
summary.plugin_count = plugins.length;
362375

src/scripts/__tests__/codex-native-hook.test.ts

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from "node:assert/strict";
22
import { execFileSync } from "node:child_process";
33
import { existsSync } from "node:fs";
4-
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
4+
import { appendFile, chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
55
import { tmpdir } from "node:os";
66
import { dirname, join } from "node:path";
77
import { pathToFileURL } from "node:url";
@@ -58,6 +58,20 @@ async function writeJson(path: string, value: unknown): Promise<void> {
5858
await writeFile(path, JSON.stringify(value, null, 2));
5959
}
6060

61+
function hooksLogPath(cwd: string): string {
62+
const day = new Date().toISOString().slice(0, 10);
63+
return join(cwd, ".omx", "logs", `hooks-${day}.jsonl`);
64+
}
65+
66+
async function readHooksLogEntries(cwd: string): Promise<Array<Record<string, unknown>>> {
67+
const content = await readFile(hooksLogPath(cwd), "utf-8");
68+
return content
69+
.split("\n")
70+
.map((line) => line.trim())
71+
.filter(Boolean)
72+
.map((line) => JSON.parse(line) as Record<string, unknown>);
73+
}
74+
6175
async function writeActiveAutopilotSession(cwd: string, sessionId: string): Promise<void> {
6276
await writeJson(join(cwd, ".omx", "state", "session.json"), {
6377
session_id: sessionId,
@@ -211,9 +225,8 @@ describe("codex native hook config", () => {
211225
};
212226
assert.equal(
213227
postToolUse.matcher,
214-
"Edit|Write|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|Patch|mcp__.*|omx_.*|state_.*|project_memory_.*|notepad_.*|trace_.*",
228+
"Bash|Edit|MultiEdit|Write|NotebookEdit|ApplyPatch|apply_patch|Patch|mcp__.*|omx_.*|state_.*|project_memory_.*|notepad_.*|trace_.*",
215229
);
216-
assert.doesNotMatch(postToolUse.matcher ?? "", /\bBash\b/);
217230
assert.match(
218231
String(postToolUse.hooks?.[0]?.command || ""),
219232
/codex-native-hook\.js"?$/,
@@ -1109,6 +1122,112 @@ export async function onHookEvent(event) {
11091122
}
11101123
});
11111124

1125+
it("emits hook dispatch pre_tool_use before mutation and post_tool_use after mutation", async () => {
1126+
const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-tool-order-"));
1127+
try {
1128+
execFileSync("git", ["init"], { cwd, stdio: "ignore" });
1129+
execFileSync("git", ["checkout", "-b", "tool-events"], { cwd, stdio: "ignore" });
1130+
const targetPath = join(cwd, "src", "output.txt");
1131+
const toolInput = { file_path: "src/output.txt", content: "next\n" };
1132+
1133+
await dispatchCodexNativeHook(
1134+
{
1135+
hook_event_name: "PreToolUse",
1136+
cwd,
1137+
session_id: "sess-tool-events",
1138+
agent: "agent5",
1139+
tool_name: "Write",
1140+
tool_use_id: "trace-write-1",
1141+
tool_input: toolInput,
1142+
},
1143+
{ cwd },
1144+
);
1145+
1146+
await mkdir(dirname(targetPath), { recursive: true });
1147+
await writeFile(targetPath, "next\n", "utf-8");
1148+
await appendFile(
1149+
hooksLogPath(cwd),
1150+
`${JSON.stringify({ type: "mutation_marker", path: targetPath })}\n`,
1151+
);
1152+
1153+
await dispatchCodexNativeHook(
1154+
{
1155+
hook_event_name: "PostToolUse",
1156+
cwd,
1157+
session_id: "sess-tool-events",
1158+
agent: "agent5",
1159+
tool_name: "Write",
1160+
tool_use_id: "trace-write-1",
1161+
tool_input: toolInput,
1162+
tool_response: { ok: true },
1163+
},
1164+
{ cwd },
1165+
);
1166+
1167+
const entries = await readHooksLogEntries(cwd);
1168+
const preIndex = entries.findIndex((entry) => entry.type === "hook_dispatch" && entry.event === "pre-tool-use");
1169+
const mutationIndex = entries.findIndex((entry) => entry.type === "mutation_marker");
1170+
const postIndex = entries.findIndex((entry) => entry.type === "hook_dispatch" && entry.event === "post-tool-use");
1171+
assert.equal(preIndex >= 0, true);
1172+
assert.equal(mutationIndex >= 0, true);
1173+
assert.equal(postIndex >= 0, true);
1174+
assert.equal(preIndex < mutationIndex, true);
1175+
assert.equal(mutationIndex < postIndex, true);
1176+
1177+
const preContext = entries[preIndex]?.context as Record<string, unknown>;
1178+
const postContext = entries[postIndex]?.context as Record<string, unknown>;
1179+
assert.equal(preContext.tool_lifecycle_event, "pre_tool_use");
1180+
assert.equal(postContext.tool_lifecycle_event, "post_tool_use");
1181+
assert.equal(preContext.trace_id, "omx:sess-tool-events:trace-write-1");
1182+
assert.equal(postContext.trace_id, "omx:sess-tool-events:trace-write-1");
1183+
assert.equal(preContext.session_id, "sess-tool-events");
1184+
assert.equal(preContext.agent, "agent5");
1185+
assert.equal(preContext.cwd, cwd);
1186+
assert.equal(preContext.repo_root, cwd);
1187+
assert.equal(preContext.branch, "tool-events");
1188+
assert.deepEqual(preContext.tool_input, {
1189+
operation: "write",
1190+
file_path: "src/output.txt",
1191+
extracted_paths: ["src/output.txt"],
1192+
paths: [{ path: "src/output.txt", role: "target", kind: "file" }],
1193+
file_count: 1,
1194+
});
1195+
assert.deepEqual(preContext.extracted_paths, ["src/output.txt"]);
1196+
assert.equal("missing_pre_tool_use" in postContext, false);
1197+
} finally {
1198+
await rm(cwd, { recursive: true, force: true });
1199+
}
1200+
});
1201+
1202+
it("marks mutating post_tool_use when no matching pre_tool_use trace exists", async () => {
1203+
const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-tool-missing-pre-"));
1204+
try {
1205+
const result = await dispatchCodexNativeHook(
1206+
{
1207+
hook_event_name: "PostToolUse",
1208+
cwd,
1209+
session_id: "sess-missing-pre",
1210+
agent: "agent5",
1211+
tool_name: "Write",
1212+
tool_use_id: "trace-orphan",
1213+
tool_input: { file_path: "src/orphan.txt", content: "orphan\n" },
1214+
tool_response: { ok: true },
1215+
},
1216+
{ cwd },
1217+
);
1218+
1219+
assert.equal(result.omxEventName, "post-tool-use");
1220+
const entries = await readHooksLogEntries(cwd);
1221+
const postEntry = entries.find((entry) => entry.type === "hook_dispatch" && entry.event === "post-tool-use");
1222+
const postContext = postEntry?.context as Record<string, unknown>;
1223+
assert.equal(postContext.tool_lifecycle_event, "post_tool_use");
1224+
assert.equal(postContext.trace_id, "omx:sess-missing-pre:trace-orphan");
1225+
assert.equal(postContext.missing_pre_tool_use, true);
1226+
} finally {
1227+
await rm(cwd, { recursive: true, force: true });
1228+
}
1229+
});
1230+
11121231
it("writes SessionStart state against the long-lived session owner pid and injects environment context", async () => {
11131232
const cwd = await mkdtemp(join(tmpdir(), "omx-native-hook-session-start-"));
11141233
try {

src/scripts/codex-native-hook.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -693,10 +693,16 @@ function buildColonyToolUsePayload(
693693
const eventId = `${traceId}:${eventName}`;
694694
const parentEventId = eventName === "post_tool_use" ? preEventId : undefined;
695695
const toolUseId = readPayloadToolUseId(payload);
696+
const branch = safeString(payload.branch).trim()
697+
|| tryReadGitValue(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
698+
|| tryReadGitValue(cwd, ["symbolic-ref", "--short", "HEAD"])
699+
|| "unknown";
700+
const agent = safeString(payload.agent).trim() || "codex";
696701
return {
697702
...payload,
698703
...extra,
699704
hook_event_name: hookEventName,
705+
tool_lifecycle_event: eventName,
700706
event_name: eventName,
701707
event_id: eventId,
702708
trace_id: traceId,
@@ -705,17 +711,21 @@ function buildColonyToolUsePayload(
705711
...(eventId ? { event_id: eventId } : {}),
706712
...(traceId ? { trace_id: traceId } : {}),
707713
...(toolUseId ? { tool_use_id: toolUseId } : {}),
708-
agent: "codex",
714+
agent,
709715
cwd,
716+
repo_root: repoRoot,
717+
branch,
710718
tool_name: toolName,
711719
tool_input: compactToolInput,
712720
extracted_paths: extractedPaths,
713721
source: "omx",
714722
metadata: {
715723
...metadata,
716724
source: "omx",
717-
agent: "codex",
725+
agent,
718726
cwd,
727+
repo_root: repoRoot,
728+
branch,
719729
event_id: eventId,
720730
event_name: eventName,
721731
event_type: eventName,
@@ -1071,6 +1081,20 @@ function buildBaseContext(
10711081
return context;
10721082
}
10731083

1084+
function warnNativeHookEventEmitFailure(
1085+
action: string,
1086+
error: unknown,
1087+
context: { hookEventName: CodexHookEventName | null; cwd: string; traceId?: string },
1088+
): void {
1089+
console.warn("[omx] warning: failed to emit native hook event", {
1090+
action,
1091+
hookEventName: context.hookEventName,
1092+
cwd: context.cwd,
1093+
...(context.traceId ? { trace_id: context.traceId } : {}),
1094+
error: error instanceof Error ? error.message : String(error),
1095+
});
1096+
}
1097+
10741098
async function readJsonIfExists(path: string): Promise<Record<string, unknown> | null> {
10751099
if (!existsSync(path)) return null;
10761100
try {
@@ -2987,7 +3011,30 @@ export async function dispatchCodexNativeHook(
29873011
}
29883012

29893013
if (omxEventName && !skipCanonicalSessionStartContext) {
2990-
const baseContext = buildBaseContext(cwd, payload, hookEventName!, canonicalSessionId);
3014+
let toolLifecycleContext: Record<string, unknown> | null = null;
3015+
if (hookEventName === "PreToolUse" || hookEventName === "PostToolUse") {
3016+
try {
3017+
const bridgeSessionId = canonicalSessionId || resolvedNativeSessionId || nativeSessionId || "unknown";
3018+
const toolUseBaseContext = buildColonyToolUsePayload(
3019+
payload,
3020+
cwd,
3021+
bridgeSessionId,
3022+
hookEventName,
3023+
);
3024+
const postTraceFields = hookEventName === "PostToolUse"
3025+
? await buildPostToolTraceLinkFields(cwd, toolUseBaseContext)
3026+
: {};
3027+
toolLifecycleContext = Object.keys(postTraceFields).length > 0
3028+
? buildColonyToolUsePayload(toolUseBaseContext, cwd, bridgeSessionId, hookEventName, postTraceFields)
3029+
: toolUseBaseContext;
3030+
} catch (error) {
3031+
warnNativeHookEventEmitFailure("build_tool_use_context", error, { hookEventName, cwd });
3032+
}
3033+
}
3034+
const baseContext: Record<string, unknown> = {
3035+
...buildBaseContext(cwd, payload, hookEventName!, canonicalSessionId),
3036+
...(toolLifecycleContext ?? {}),
3037+
};
29913038
if (resolvedNativeSessionId) {
29923039
baseContext.native_session_id = resolvedNativeSessionId;
29933040
baseContext.codex_session_id = resolvedNativeSessionId;
@@ -3005,7 +3052,18 @@ export async function dispatchCodexNativeHook(
30053052
mode: safeString(payload.mode).trim() || undefined,
30063053
},
30073054
);
3008-
await dispatchHookEvent(event, { cwd });
3055+
try {
3056+
await dispatchHookEvent(event, { cwd });
3057+
if (hookEventName === "PreToolUse" && toolLifecycleContext) {
3058+
await recordPreToolUseTrace(cwd, toolLifecycleContext);
3059+
}
3060+
} catch (error) {
3061+
warnNativeHookEventEmitFailure("dispatch_hook_event", error, {
3062+
hookEventName,
3063+
cwd,
3064+
traceId: safeString(toolLifecycleContext?.trace_id),
3065+
});
3066+
}
30093067
}
30103068

30113069
if ((hookEventName === "SessionStart" && !skipCanonicalSessionStartContext) || hookEventName === "UserPromptSubmit") {

0 commit comments

Comments
 (0)