From 9214ac8bf1da73a6fcc240fdbbd55292799196c9 Mon Sep 17 00:00:00 2001 From: David Alberto Adler Date: Sun, 17 May 2026 15:15:34 +0100 Subject: [PATCH 1/2] chore(hooks): log claude_prompt_denied on UserPromptSubmit block Adds an info log on the deny path of handleUserPromptSubmit mirroring the existing claude_hook_denied log shape used by PreToolUse, so a 403 on a claude prompt can be joined to a session in Datadog without inferring from response status alone. --- server/internal/hooks/session_capture.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/internal/hooks/session_capture.go b/server/internal/hooks/session_capture.go index 92d3300726..0ef82c65ae 100644 --- a/server/internal/hooks/session_capture.go +++ b/server/internal/hooks/session_capture.go @@ -100,6 +100,15 @@ func (s *Service) handleUserPromptSubmit(ctx context.Context, payload *gen.Claud if metadata, err := s.getSessionMetadata(ctx, *payload.SessionID); err == nil { s.writeClaudeBlockToClickHouse(ctx, payload, &metadata, auditReason) } + s.logger.InfoContext(ctx, "claude UserPromptSubmit denied by policy", + attr.SlogEvent("claude_prompt_denied"), + attr.SlogHookSource("claude"), + attr.SlogHookEvent(payload.HookEventName), + attr.SlogGenAIConversationID(*payload.SessionID), + attr.SlogHookBlockReason(auditReason), + attr.SlogRiskPolicyID(scanResult.PolicyID), + attr.SlogRiskPolicyName(scanResult.PolicyName), + ) return nil, oops.E(oops.CodeForbidden, nil, "%s", userReason) } } From 260ca38e9b033d02063a1d154db9326e27a3acd1 Mon Sep 17 00:00:00 2001 From: David Alberto Adler Date: Sun, 17 May 2026 16:08:20 +0100 Subject: [PATCH 2/2] feat(hooks): add scan-decision, no-project, and auth-enriched hook logs --- server/internal/attr/conventions.go | 16 ++++++++++++ server/internal/hooks/claude_hooks.go | 12 +++++---- server/internal/hooks/risk_scan.go | 35 +++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/server/internal/attr/conventions.go b/server/internal/attr/conventions.go index a4dc3a238a..a1a3c30317 100644 --- a/server/internal/attr/conventions.go +++ b/server/internal/attr/conventions.go @@ -224,6 +224,7 @@ const ( RiskPolicyCountKey = attribute.Key("gram.risk.policy_count") RiskPolicyIDKey = attribute.Key("gram.risk.policy_id") RiskPolicyNameKey = attribute.Key("gram.risk.policy_name") + RiskMatchedKey = attribute.Key("gram.risk.matched") RiskRuleIDKey = attribute.Key("gram.risk.rule_id") RiskScanAttemptKey = attribute.Key("gram.risk.scan.attempt") RiskScanMaxAttemptsKey = attribute.Key("gram.risk.scan.max_attempts") @@ -234,6 +235,8 @@ const ( SecuritySchemeKey = attribute.Key("gram.security.scheme") SecurityTypeKey = attribute.Key("gram.security.type") SessionIDKey = attribute.Key("gram.session.id") + SessionHasCachedMetadataKey = attribute.Key("gram.session.has_cached_metadata") + SessionHasAuthCtxKey = attribute.Key("gram.session.has_auth_ctx") SlackEventFullKey = attribute.Key("gram.slack.event.full") SlackEventTypeKey = attribute.Key("gram.slack.event.type") SlackTeamIDKey = attribute.Key("gram.slack.team.id") @@ -1026,6 +1029,9 @@ func SlogRiskPolicyName(v string) slog.Attr { return slog.String(string(Ris func RiskRuleID(v string) attribute.KeyValue { return RiskRuleIDKey.String(v) } func SlogRiskRuleID(v string) slog.Attr { return slog.String(string(RiskRuleIDKey), v) } +func RiskMatched(v bool) attribute.KeyValue { return RiskMatchedKey.Bool(v) } +func SlogRiskMatched(v bool) slog.Attr { return slog.Bool(string(RiskMatchedKey), v) } + func RiskScanAttempt(v int) attribute.KeyValue { return RiskScanAttemptKey.Int(v) } func SlogRiskScanAttempt(v int) slog.Attr { return slog.Int(string(RiskScanAttemptKey), v) } @@ -1056,6 +1062,16 @@ func SlogSecurityType(v string) slog.Attr { return slog.String(string(Secur func SessionID(v string) attribute.KeyValue { return SessionIDKey.String(v) } func SlogSessionID(v string) slog.Attr { return slog.String(string(SessionIDKey), v) } +func SessionHasCachedMetadata(v bool) attribute.KeyValue { + return SessionHasCachedMetadataKey.Bool(v) +} +func SlogSessionHasCachedMetadata(v bool) slog.Attr { + return slog.Bool(string(SessionHasCachedMetadataKey), v) +} + +func SessionHasAuthCtx(v bool) attribute.KeyValue { return SessionHasAuthCtxKey.Bool(v) } +func SlogSessionHasAuthCtx(v bool) slog.Attr { return slog.Bool(string(SessionHasAuthCtxKey), v) } + func SlackEventFull(v any) attribute.KeyValue { return SlackEventFullKey.String(fmt.Sprintf("%v", v)) } func SlogSlackEventFull(v any) slog.Attr { return slog.Any(string(SlackEventFullKey), v) } diff --git a/server/internal/hooks/claude_hooks.go b/server/internal/hooks/claude_hooks.go index 701ef7f95b..77693cae43 100644 --- a/server/internal/hooks/claude_hooks.go +++ b/server/internal/hooks/claude_hooks.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "go.opentelemetry.io/otel/trace" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/security" @@ -347,10 +348,6 @@ func (s *Service) Claude(ctx context.Context, payload *gen.ClaudePayload) (*gen. attr.SlogHookHasPluginAuth(hasPluginAuth), ) - logger.InfoContext(ctx, "claude hook received", - attr.SlogEvent("claude_hook"), - ) - if hasPluginAuth { // Auth is optional. Returning a 401 on failure deadlocks the client: // send_hook.sh maps any non-2xx to "block all tool calls", but @@ -373,6 +370,10 @@ func (s *Service) Claude(ctx context.Context, payload *gen.ClaudePayload) (*gen. } } + logger.InfoContext(ctx, "claude hook received", + attr.SlogEvent("claude_hook"), + ) + s.recordHook(ctx, payload) // Route to appropriate handler based on hook type @@ -536,7 +537,8 @@ func (s *Service) recordHook(ctx context.Context, payload *gen.ClaudePayload) { // response (Stop especially — the client closes the connection // immediately on the response). Run it detached so the response // returns promptly and the work completes in the background. - go s.persistHook(ctx, payload, &metadata) + detachedCtx := trace.ContextWithSpanContext(ctx, trace.SpanContext{}) + go s.persistHook(detachedCtx, payload, &metadata) } else { if err := s.bufferHook(ctx, sessionID, payload); err != nil { logger.ErrorContext(ctx, "Failed to buffer hook", diff --git a/server/internal/hooks/risk_scan.go b/server/internal/hooks/risk_scan.go index 5e6a967d72..c5d43f55b2 100644 --- a/server/internal/hooks/risk_scan.go +++ b/server/internal/hooks/risk_scan.go @@ -48,6 +48,21 @@ func (s *Service) scanClaudeForEnforcement(ctx context.Context, payload *gen.Cla return nil } + logAttrs := []any{ + attr.SlogEvent("claude_hook_scan_decision"), + attr.SlogHookSource("claude"), + attr.SlogHookEvent(payload.HookEventName), + attr.SlogGenAIConversationID(*payload.SessionID), + attr.SlogRiskMatched(result != nil), + } + if result != nil { + logAttrs = append(logAttrs, + attr.SlogRiskPolicyID(result.PolicyID), + attr.SlogRiskPolicyName(result.PolicyName), + ) + } + s.logger.InfoContext(ctx, "claude risk scan completed", logAttrs...) + return result } @@ -57,15 +72,31 @@ func (s *Service) scanClaudeForEnforcement(ctx context.Context, payload *gen.Cla // the fallback. Returns ok=false when neither source yields a project_id. func (s *Service) resolveClaudeScanProjectID(ctx context.Context, sessionID string) (uuid.UUID, bool) { metadata, err := s.getSessionMetadata(ctx, sessionID) - if err == nil { + hasCachedMetadata := err == nil + if hasCachedMetadata { pid, perr := uuid.Parse(metadata.ProjectID) if perr == nil { return pid, true } + s.logger.WarnContext(ctx, "claude risk scan skipped: no project resolved", + attr.SlogEvent("claude_scan_no_project"), + attr.SlogHookSource("claude"), + attr.SlogGenAIConversationID(sessionID), + attr.SlogSessionHasCachedMetadata(true), + attr.SlogSessionHasAuthCtx(false), + ) return uuid.Nil, false } authCtx, ok := contextvalues.GetAuthContext(ctx) - if !ok || authCtx == nil || authCtx.ProjectID == nil { + hasAuthCtx := ok && authCtx != nil && authCtx.ProjectID != nil + if !hasAuthCtx { + s.logger.WarnContext(ctx, "claude risk scan skipped: no project resolved", + attr.SlogEvent("claude_scan_no_project"), + attr.SlogHookSource("claude"), + attr.SlogGenAIConversationID(sessionID), + attr.SlogSessionHasCachedMetadata(false), + attr.SlogSessionHasAuthCtx(ok && authCtx != nil && authCtx.ProjectID == nil), + ) return uuid.Nil, false } return *authCtx.ProjectID, true