mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-06-13 22:26:03 -03:00
Merge pull request #2077 from anthropics/fix-1358-1375-1783-hook-output-protocol
security-guidance: emit findings via hookSpecificOutput.additionalContext (#1358 #1375 #1783)
This commit is contained in:
commit
8435428dfc
@ -190,7 +190,13 @@ CONTINUATION_SUFFIX = (
|
|||||||
"response."
|
"response."
|
||||||
)
|
)
|
||||||
|
|
||||||
def emit_metrics(metrics, rewake_summary=None):
|
def emit_metrics(
|
||||||
|
metrics,
|
||||||
|
rewake_summary=None,
|
||||||
|
additional_context=None,
|
||||||
|
system_message=None,
|
||||||
|
hook_event_name="PostToolUse",
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Write a SyncHookJSONOutput line to stdout for Claude Code to pick up.
|
Write a SyncHookJSONOutput line to stdout for Claude Code to pick up.
|
||||||
For asyncRewake (Stop) hooks, CC scans stdout for the first {-prefixed line
|
For asyncRewake (Stop) hooks, CC scans stdout for the first {-prefixed line
|
||||||
@ -213,6 +219,27 @@ def emit_metrics(metrics, rewake_summary=None):
|
|||||||
rewakeSummary in hooks.json, shown to the user in the terminal as the
|
rewakeSummary in hooks.json, shown to the user in the terminal as the
|
||||||
task-notification one-liner. Must be in the same JSON line as the metrics
|
task-notification one-liner. Must be in the same JSON line as the metrics
|
||||||
because CC stops scanning stdout after the first {-prefixed line.
|
because CC stops scanning stdout after the first {-prefixed line.
|
||||||
|
|
||||||
|
`additional_context` (asyncRewake findings): model-visible guidance text
|
||||||
|
that CC surfaces via the modern hook-output protocol
|
||||||
|
(hookSpecificOutput.additionalContext) instead of the legacy stderr +
|
||||||
|
exit(2) pair. The caller passes the finding-explanation text it would
|
||||||
|
have written to stderr; the JSON channel carries it cleanly so CC's UI
|
||||||
|
shows the reason properly instead of "Permission denied with no reason".
|
||||||
|
See anthropics/claude-plugins-official#1375 and #1783. Empty/None
|
||||||
|
means no hookSpecificOutput field is emitted (preserves backward compat
|
||||||
|
for legacy emit-sites that only want metrics).
|
||||||
|
|
||||||
|
`system_message` (optional, asyncRewake only): user-visible TUI message,
|
||||||
|
distinct from rewakeSummary which is the task-notification one-liner.
|
||||||
|
Use sparingly — the rewakeMessage in hooks.json is the primary user
|
||||||
|
surface; systemMessage adds a per-fire override when the static
|
||||||
|
rewakeMessage isn't specific enough for the finding being shown.
|
||||||
|
|
||||||
|
`hook_event_name` (used only when additional_context is set): which event
|
||||||
|
the hookSpecificOutput attaches to. Defaults to "PostToolUse" since the
|
||||||
|
commit-review and push-sweep handlers are the most common callers;
|
||||||
|
handle_stop_hook explicitly passes "Stop".
|
||||||
"""
|
"""
|
||||||
head = {}
|
head = {}
|
||||||
if _PV and "pv" not in metrics:
|
if _PV and "pv" not in metrics:
|
||||||
@ -223,6 +250,17 @@ def emit_metrics(metrics, rewake_summary=None):
|
|||||||
out = {"metrics": metrics}
|
out = {"metrics": metrics}
|
||||||
if rewake_summary:
|
if rewake_summary:
|
||||||
out["rewakeSummary"] = rewake_summary
|
out["rewakeSummary"] = rewake_summary
|
||||||
|
if additional_context:
|
||||||
|
# Wrap in hookSpecificOutput per CC's modern hook-output contract.
|
||||||
|
# Drops the legacy `sys.stderr.write(...) + sys.exit(2)` shape that
|
||||||
|
# left CC's UI showing "denied with no reason" (#1783) and triggered
|
||||||
|
# "json output validation failed" on older CC versions (#1375).
|
||||||
|
out["hookSpecificOutput"] = {
|
||||||
|
"hookEventName": hook_event_name,
|
||||||
|
"additionalContext": additional_context,
|
||||||
|
}
|
||||||
|
if system_message:
|
||||||
|
out["systemMessage"] = system_message
|
||||||
print(json.dumps(out), flush=True)
|
print(json.dumps(out), flush=True)
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@ -1379,18 +1417,26 @@ def handle_commit_review_posttooluse(input_data):
|
|||||||
if s in sev:
|
if s in sev:
|
||||||
sev[s] += 1
|
sev[s] += 1
|
||||||
|
|
||||||
|
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
|
||||||
|
# still lists deduped entries. Pass via additional_context so CC surfaces
|
||||||
|
# the reason via hookSpecificOutput.additionalContext instead of empty
|
||||||
|
# stdout (#1783) / stderr-only "json output validation failed" (#1375).
|
||||||
|
_commit_guidance = (PROVENANCE_BANNER + "\n\n"
|
||||||
|
+ _format_vulns_guidance(new_vulns)
|
||||||
|
+ CONTINUATION_SUFFIX + "\n")
|
||||||
emit_metrics({
|
emit_metrics({
|
||||||
"vulns_found": len(new_vulns), **_base, **_agentic_m,
|
"vulns_found": len(new_vulns), **_base, **_agentic_m,
|
||||||
"critical_count": sev["critical"], "high_count": sev["high"],
|
"critical_count": sev["critical"], "high_count": sev["high"],
|
||||||
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
||||||
**({"deduped": n_deduped} if n_deduped else {}),
|
**({"deduped": n_deduped} if n_deduped else {}),
|
||||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"))
|
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"),
|
||||||
|
additional_context=_commit_guidance,
|
||||||
|
hook_event_name="PostToolUse")
|
||||||
|
|
||||||
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
|
# exit(2) is preserved per the asyncRewake protocol — it's what CC
|
||||||
# still lists deduped entries.
|
# uses as the "force fix" signal that triggers the rewakeMessage flow.
|
||||||
sys.stderr.write(PROVENANCE_BANNER + "\n\n"
|
# The stderr.write was removed; additional_context above now carries
|
||||||
+ _format_vulns_guidance(new_vulns)
|
# the same text via the modern JSON channel. See #1358/#1375/#1783.
|
||||||
+ CONTINUATION_SUFFIX + "\n")
|
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
def handle_push_sweep_posttooluse(input_data):
|
def handle_push_sweep_posttooluse(input_data):
|
||||||
@ -1647,17 +1693,23 @@ def handle_push_sweep_posttooluse(input_data):
|
|||||||
# Metrics — keep within the 10-key cap; agentic sub-metrics are dropped
|
# Metrics — keep within the 10-key cap; agentic sub-metrics are dropped
|
||||||
# here in favour of the push-sweep funnel keys (telemetry can join on session_id
|
# here in favour of the push-sweep funnel keys (telemetry can join on session_id
|
||||||
# to the per-commit fires for agentic detail). rewake_summary must ride
|
# to the per-commit fires for agentic detail). rewake_summary must ride
|
||||||
# this line (CC reads only the first {-prefixed stdout line); it's a
|
# this line (CC reads only the first {-prefixed stdout line); the emit
|
||||||
# no-op when new_vulns is empty since we exit 0 below.
|
# is deferred to the two exit points below so the with-vulns path can
|
||||||
emit_metrics({
|
# also pass additional_context in the same JSON line (#1375/#1783) —
|
||||||
|
# the by-design "CC keeps only the first JSON line" constraint means
|
||||||
|
# we can't emit twice. Builds the shared metrics dict here; vulns path
|
||||||
|
# adds additional_context, no-vulns path emits as-is.
|
||||||
|
_push_metrics = {
|
||||||
**_base, "pushed": len(push_range), "unreviewed": len(tail),
|
**_base, "pushed": len(push_range), "unreviewed": len(tail),
|
||||||
"prefix_advanced": prefix_advanced, "vulns_found": len(new_vulns),
|
"prefix_advanced": prefix_advanced, "vulns_found": len(new_vulns),
|
||||||
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
"files_reviewed": len(diff_files), "review_ms": review_ms,
|
||||||
**({"deduped": n_deduped} if n_deduped else {}),
|
**({"deduped": n_deduped} if n_deduped else {}),
|
||||||
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Push security review found"))
|
}
|
||||||
|
_push_rewake_summary = _format_vulns_summary(new_vulns, prefix="Push security review found")
|
||||||
|
|
||||||
if not new_vulns:
|
if not new_vulns:
|
||||||
debug_log("Push sweep: no new findings")
|
debug_log("Push sweep: no new findings")
|
||||||
|
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# First-push of a big branch can surface many findings at once across
|
# First-push of a big branch can surface many findings at once across
|
||||||
@ -1710,9 +1762,14 @@ def handle_push_sweep_posttooluse(input_data):
|
|||||||
guidance = _format_vulns_guidance(reported) or ""
|
guidance = _format_vulns_guidance(reported) or ""
|
||||||
else:
|
else:
|
||||||
guidance = concrete_guidance or _format_vulns_guidance(reported) or ""
|
guidance = concrete_guidance or _format_vulns_guidance(reported) or ""
|
||||||
sys.stderr.write(
|
# Emit metrics + additional_context together — single JSON line is the
|
||||||
PROVENANCE_BANNER + "\n\n" + guidance + CONTINUATION_SUFFIX + "\n"
|
# contract CC's hook parser expects. exit(2) preserved as the asyncRewake
|
||||||
)
|
# "force fix" trigger (see comment near handle_commit_review_posttooluse).
|
||||||
|
# See #1358 / #1375 / #1783.
|
||||||
|
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary,
|
||||||
|
additional_context=(PROVENANCE_BANNER + "\n\n"
|
||||||
|
+ guidance + CONTINUATION_SUFFIX + "\n"),
|
||||||
|
hook_event_name="PostToolUse")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
def handle_stop_hook(input_data):
|
def handle_stop_hook(input_data):
|
||||||
@ -1945,6 +2002,11 @@ def handle_stop_hook(input_data):
|
|||||||
# untracked_baseline_n is the signal for whether the UPS-time
|
# untracked_baseline_n is the signal for whether the UPS-time
|
||||||
# untracked-snapshot capture actually ran.
|
# untracked-snapshot capture actually ran.
|
||||||
sweep_trimmed = {k: v for k, v in sweep.items() if k != "warn_unresolved_mask"}
|
sweep_trimmed = {k: v for k, v in sweep.items() if k != "warn_unresolved_mask"}
|
||||||
|
# Pass guidance via additional_context so CC surfaces the findings via
|
||||||
|
# hookSpecificOutput.additionalContext instead of stderr-only (which
|
||||||
|
# was the cause of "json output validation failed" / empty-reason UI in
|
||||||
|
# #1375 / #1783). exit(2) preserved as the asyncRewake "force fix"
|
||||||
|
# signal — that's the documented mechanism. See #1358 / #1375 / #1783.
|
||||||
emit_metrics({
|
emit_metrics({
|
||||||
"vulns_found": len(vulns),
|
"vulns_found": len(vulns),
|
||||||
"untracked_baseline_n": len(untracked_at_baseline),
|
"untracked_baseline_n": len(untracked_at_baseline),
|
||||||
@ -1958,10 +2020,10 @@ def handle_stop_hook(input_data):
|
|||||||
**({"diff_truncated": llm._last_review_truncated_bytes}
|
**({"diff_truncated": llm._last_review_truncated_bytes}
|
||||||
if llm._last_review_truncated_bytes else {}),
|
if llm._last_review_truncated_bytes else {}),
|
||||||
**sweep_trimmed,
|
**sweep_trimmed,
|
||||||
}, rewake_summary=_format_vulns_summary(vulns))
|
}, rewake_summary=_format_vulns_summary(vulns),
|
||||||
|
additional_context=(PROVENANCE_BANNER + "\n\n"
|
||||||
# Exit code 2 with stderr forces Claude to continue and fix
|
+ concrete_guidance + CONTINUATION_SUFFIX + "\n"),
|
||||||
sys.stderr.write(PROVENANCE_BANNER + "\n\n" + concrete_guidance + CONTINUATION_SUFFIX + "\n")
|
hook_event_name="Stop")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
if llm._last_call_claude_http_error is not None:
|
if llm._last_call_claude_http_error is not None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user