Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-08-24 02:18:59 +00:00
parent f849d610f3
commit c3ea1c79f5
48 changed files with 1776 additions and 448 deletions
+99 -17
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,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.
@@ -1354,9 +1380,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
@@ -1959,7 +2012,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 +2252,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
@@ -2241,6 +2312,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;
@@ -2261,29 +2333,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 });
}
@@ -2300,8 +2382,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,
+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,