Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions server/internal/attr/conventions.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,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")
RiskSourceKey = attribute.Key("gram.risk.source")
RiskScanAttemptKey = attribute.Key("gram.risk.scan.attempt")
Expand All @@ -238,6 +239,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")
Expand Down Expand Up @@ -1042,6 +1045,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 RiskSource(v string) attribute.KeyValue { return RiskSourceKey.String(v) }
func SlogRiskSource(v string) slog.Attr { return slog.String(string(RiskSourceKey), v) }

Expand Down Expand Up @@ -1075,6 +1081,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) }

Expand Down
12 changes: 7 additions & 5 deletions server/internal/hooks/claude_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 33 additions & 2 deletions server/internal/hooks/risk_scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions server/internal/hooks/session_capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Loading