Fix: parse Grok Build camelCase hook stdin (#646)

Grok was classified as GitHub Copilot, so the design hook skipped every
edit with no-file-path and never ran Stop. Normalize toolInput/sessionId
and treat Stop additionalContext as the Grok product.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-08-24 05:28:21 +05:00
co-authored by Cursor
parent c39b6425fa
commit 35ae07339b
7 changed files with 289 additions and 27 deletions
+94 -12
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,9 +1251,14 @@ 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' || explicit === 'codex') return 'claude';
// 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) {
@@ -1263,6 +1268,33 @@ 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;
}
// Grok Build 1.0.5 (captured 2026-08-24) uses snake_case event names in
// camelCase fields: hookEventName "post_tool_use" / "stop". Map onto the
// internal Claude names the rest of the hook already keys on.
const GROK_HOOK_EVENTS = {
post_tool_use: 'PostToolUse',
pre_tool_use: 'PreToolUse',
stop: 'Stop',
};
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.
@@ -1354,9 +1386,41 @@ function normalizeGitHubEvent(event, projectCwd) {
};
}
function grokProjectCwd(event, projectCwd) {
if (typeof event.cwd === 'string' && event.cwd) return event.cwd;
if (typeof event.workspaceRoot === 'string' && event.workspaceRoot) {
return event.workspaceRoot.replace(/\/+$/, '') || event.workspaceRoot;
}
return envProjectDir(projectCwd) || projectCwd;
}
function normalizeGrokEvent(event, projectCwd) {
const cwd = grokProjectCwd(event, projectCwd);
const sessionId = event.sessionId || event.session_id || 'unknown';
const toolInput = event.toolInput && typeof event.toolInput === 'object' && !Array.isArray(event.toolInput)
? { ...event.toolInput }
: {};
const mappedName = typeof event.hookEventName === 'string'
? (GROK_HOOK_EVENTS[event.hookEventName] || event.hookEventName)
: undefined;
const out = {
...event,
cwd,
session_id: sessionId,
tool_name: event.toolName || event.tool_name || null,
tool_input: toolInput,
};
if (mappedName) out.hook_event_name = mappedName;
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
@@ -1959,7 +2023,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) {
@@ -2191,22 +2263,32 @@ 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);
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
// hook is being re-invoked only because a prior invocation kept the turn
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
// re-blocking now would loop until Claude Code's consecutive-block cap
// force-ends the turn (issue #400). The prior fire already surfaced the
// findings; whether to act on them is the agent's call. Exit fast with no
// output before any scan. Only Claude Code sends this field; other
// harnesses omit it, so the strict `=== true` is a no-op for them. This
// guard makes the loop impossible regardless of the finding cache key's
// line-number sensitivity (out of scope here; see findingCacheKey).
// output before any scan. Claude sends `stop_hook_active`; Grok sends
// `stopHookActive`, copied onto the snake_case field above. The strict
// `=== true` is a no-op when the field is absent. This 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
+9 -8
View File
@@ -2,12 +2,14 @@
/**
* Impeccable design hook — PostToolUse + Stop entry point.
*
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
* `hook_event_name`:
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
* and routes by Stop vs everything else. Claude uses `hook_event_name:
* "Stop"`; Grok uses `hookEventName: "stop"`.
*
* - PostToolUse: runs the immediate-tier detector rules against the touched
* file and emits a system reminder via
* `hookSpecificOutput.additionalContext` when findings exist.
* `hookSpecificOutput.additionalContext` when findings exist. Grok
* discards that stdout; the scan still warms the session cache for Stop.
* - Stop: runs the FULL detector rule set over every UI file touched this
* session (the deep pass), deduped against what the per-edit pass already
* surfaced, and emits once via the Stop additionalContext channel.
@@ -19,7 +21,7 @@
* subprocess. This file is the thin stdin/stdout adapter.
*/
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
async function readStdin() {
if (process.stdin.isTTY) return '';
@@ -28,10 +30,9 @@ async function readStdin() {
return Buffer.concat(chunks).toString('utf-8');
}
function isStopEvent(stdinJson) {
function stdinIsStop(stdinJson) {
try {
const event = JSON.parse(stdinJson);
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
return isStopEvent(JSON.parse(stdinJson));
} catch {
// Malformed stdin falls through to runHook, which audits the skip.
return false;
@@ -48,7 +49,7 @@ async function main() {
let stdinJson = '';
try { stdinJson = await readStdin(); } catch { /* fall through */ }
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
const result = await run({
stdinJson,
env: inheritedEnv,