Merge origin/main into fix/603-codex-stop-payload

Keep Codex as its own Stop harness (decision/block) while taking main's Grok envelope detection and Stop cache sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-08-24 07:25:47 +05:00
co-authored by Cursor
57 changed files with 2169 additions and 491 deletions
+102 -21
View File
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
}
// Whether the per-edit pass for this harness should defer non-immediate
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
// them would silently drop the non-immediate rules entirely.
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
// deferring for them would silently drop the non-immediate rules entirely.
export function perEditTieringActive(config, harness) {
if (harness === 'cursor' || harness === 'github') return false;
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
@@ -1251,10 +1251,15 @@ export function resolveHarness(env = {}, event = null) {
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
if (explicit === 'cursor') return 'cursor';
if (explicit === 'github') return 'github';
if (explicit === 'grok') return 'grok';
if (explicit === 'claude') return 'claude';
if (explicit === 'codex') return 'codex';
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
// has no `tool_name`/`tool_input`. That shape is the discriminator.
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
// Check Grok first: the old GitHub heuristic (`toolName` and no
// `tool_input`) also matches Grok, which is how live PostToolUse was
// classified as Copilot and then skipped with no-file-path (#646).
if (looksLikeGrokEnvelope(event)) return 'grok';
if (event && typeof event === 'object'
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
&& event.tool_name === undefined && event.tool_input === undefined) {
@@ -1269,6 +1274,27 @@ export function resolveHarness(env = {}, event = null) {
return 'claude';
}
function looksLikeGrokEnvelope(event) {
if (!event || typeof event !== 'object') return false;
if (event.hook_event_name !== undefined
|| event.tool_name !== undefined
|| event.tool_input !== undefined) {
return false;
}
if (event.toolArgs !== undefined) return false;
if (typeof event.hookEventName === 'string') return true;
return typeof event.toolName === 'string' && event.toolInput !== undefined;
}
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
// normalize, so both casings must match here.
export function isStopEvent(event) {
if (!event || typeof event !== 'object') return false;
const name = event.hook_event_name || event.hookEventName;
return typeof name === 'string' && name.toLowerCase() === 'stop';
}
// GitHub Copilot's postToolUse payload is
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
@@ -1360,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
};
}
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
// stripping here). Only the fields the hook reads are copied; the event
// name stays camelCase because routing already happened on the raw stdin
// (isStopEvent) and nothing downstream reads `hook_event_name`.
function normalizeGrokEvent(event, projectCwd) {
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
const sessionId = event.sessionId || event.session_id || 'unknown';
const rawInput = event.toolInput ?? event.tool_input;
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? { ...rawInput }
: {};
const out = {
...event,
cwd,
session_id: sessionId,
tool_name: event.toolName || event.tool_name || null,
tool_input: toolInput,
};
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
out.stop_hook_active = event.stopHookActive;
}
return out;
}
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
if (!event || typeof event !== 'object') return event;
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
if (harness !== 'cursor') return event;
const cwd = event.cwd
@@ -1965,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
// findings stop being remembered and a reintroduced one reads as fresh.
// Only the immediate tier is remembered: a deferred finding the per-edit
// pass never reported must still read as fresh to the Stop deep pass.
rememberFindings(cache, sessionId, filePath, immediate);
//
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
// Remembering here would dedupe those findings out of Stop. Touch the
// file so Stop has it, and leave the finding list empty.
if (harness === 'grok') {
touchFile(cache, sessionId, filePath);
} else {
rememberFindings(cache, sessionId, filePath, immediate);
}
cacheDirty = true;
if (fresh.length > 0) {
@@ -2170,8 +2231,8 @@ export const STOP_MAX_FILES = 20;
*
* Never throws; exits silent (and fast) when the session touched no UI
* files. Output goes out on the harness's Stop continuation channel: Claude
* Code reads hookSpecificOutput.additionalContext, Codex takes a
* decision: "block" whose reason becomes the continuation prompt. Either
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
* a decision: "block" whose reason becomes the continuation prompt. Either
* way the findings reach the model and the conversation continues so it
* can act.
*/
@@ -2200,6 +2261,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
}
const harness = resolveHarness(env, event);
audit.harness = harness;
event = normalizeHookEvent(event, cwd, harness);
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
// being re-invoked only because a prior invocation kept the turn alive
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
@@ -2209,18 +2274,23 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
// before any scan. Claude Code and Codex both send this field: Codex
// mirrors the Claude contract (StopCommandInput in
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
// turn once a block is honored (codex-rs/core/src/session/turn.rs), so
// this guard is also what caps the Codex deep pass at one continuation
// per turn. Cursor and GitHub Copilot omit the field, so the strict
// `=== true` is a no-op for them. The guard makes the loop impossible
// regardless of the finding cache key's line-number sensitivity (out of
// scope here; see findingCacheKey).
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
// for them. The guard makes the loop impossible regardless of the finding
// cache key's line-number sensitivity (out of scope here; see
// findingCacheKey).
if (event.stop_hook_active === true) {
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
}
const harness = resolveHarness(env, event);
audit.harness = harness;
// Grok fires Stop twice: `end_turn` (the gate that can inject
// additionalContext) then an observe-only `shutdown`. A second deep
// pass would re-emit the same findings. Claude omits `reason`; only
// skip when Grok named a reason that is not end_turn.
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
}
// A Stop event carries no file, so the session cwd is the project.
// Umbrella-dir launches keyed their per-edit cache to the edited file's
@@ -2255,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
const freshGroups = [];
let scanned = 0;
let cacheDirty = false;
for (const filePath of touched) {
if (scanned >= STOP_MAX_FILES) break;
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
@@ -2275,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
let findings;
let detectorThrew = false;
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
if (useHtmlEngine && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
} else {
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
}
// A detector failure tells us nothing about the file. Leave whatever
// was remembered alone rather than recording an empty scan as truth.
if (detectorThrew) continue;
// Full rule set: no tier split here. Config/inline ignores still apply,
// and the session dedupe drops everything the per-edit pass (or an
// earlier Stop pass) already surfaced.
const filtered = filterFindings(findings || [], content, ext, config);
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
// Sync to the live scan, including empty. Remembering only `fresh`
// (or skipping the write on a clean Stop) left stale keys in place, so
// a finding that was fixed and later reintroduced never fired again.
rememberFindings(cache, sessionId, filePath, filtered);
cacheDirty = true;
if (fresh.length > 0) {
rememberFindings(cache, sessionId, filePath, fresh);
freshGroups.push({ filePath, findings: fresh });
}
}
audit.scannedFiles = scanned;
if (freshGroups.length === 0) {
if (cacheDirty) persistCache(projectCwd, cache);
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
}
@@ -2314,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
);
commitFooterShown(cache, sessionId, text);
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear; the notice flags ride along.
// Persist the live finding set so the next Stop fire is silent unless
// new issues appear; the notice flags ride along.
persistCache(projectCwd, cache);
return {
exitCode: 0,