From c1e686f31feae530f52ee45f91e2cb9691cdf5da Mon Sep 17 00:00:00 2001 From: yanny-sec Date: Tue, 14 Jul 2026 11:07:59 +0100 Subject: [PATCH 1/2] feat(hitl): forward HITL verdicts to Claude Code for native inline approval --- backend/internal/proto/event.pb.go | 28 ++++++++-- backend/internal/ws/handler.go | 23 ++++++--- backend/internal/ws/session.go | 5 +- backend/proto/event.proto | 4 ++ frontend/app/(dashboard)/settings/page.tsx | 33 ++++++++++-- proto/event.proto | 4 ++ sdk/python/adrian/proto/event_pb2.py | 60 +++++++++++----------- sdk/python/adrian/proto/event_pb2.pyi | 13 ++++- 8 files changed, 122 insertions(+), 48 deletions(-) diff --git a/backend/internal/proto/event.pb.go b/backend/internal/proto/event.pb.go index 06caa26..e126aaa 100644 --- a/backend/internal/proto/event.pb.go +++ b/backend/internal/proto/event.pb.go @@ -625,6 +625,7 @@ type PairedEvent struct { // Escape hatch for framework metadata not modelled as first-class fields // (e.g. LangGraph checkpoint_ns, arbitrary tags). MetadataJson []byte `protobuf:"bytes,20,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"` + Source string `protobuf:"bytes,21,opt,name=source,proto3" json:"source,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -754,6 +755,13 @@ func (x *PairedEvent) GetMetadataJson() []byte { return nil } +func (x *PairedEvent) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + type isPairedEvent_Data interface { isPairedEvent_Data() } @@ -996,6 +1004,9 @@ type SessionLogin struct { LlmStack *LLMStack `protobuf:"bytes,2,opt,name=llm_stack,json=llmStack,proto3" json:"llm_stack,omitempty"` // Wire schema version the client is speaking. Server rejects unknown values. SchemaVersion uint32 `protobuf:"varint,4,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` + // SDK / integration that produced this session (e.g. "claude-code"); the + // server branches HITL behavior on it. Empty for legacy SDKs. + Source string `protobuf:"bytes,5,opt,name=source,proto3" json:"source,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1051,6 +1062,13 @@ func (x *SessionLogin) GetSchemaVersion() uint32 { return 0 } +func (x *SessionLogin) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + // ClientFrame is the top-level envelope for all client WebSocket messages. type ClientFrame struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1567,7 +1585,7 @@ const file_event_proto_rawDesc = "" + "\ftool_call_id\x18\x02 \x01(\tR\n" + "toolCallId\x12\x14\n" + "\x05input\x18\x03 \x01(\tR\x05input\x12\x16\n" + - "\x06output\x18\x04 \x01(\tR\x06output\"\x8c\x04\n" + + "\x06output\x18\x04 \x01(\tR\x06output\"\xa4\x04\n" + "\vPairedEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12#\n" + "\rinvocation_id\x18\x02 \x01(\tR\finvocationId\x12\x1d\n" + @@ -1582,7 +1600,8 @@ const file_event_proto_rawDesc = "" + "\x03llm\x18\n" + " \x01(\v2\x1f.adrian.core_api.v1.LlmPairDataH\x00R\x03llm\x126\n" + "\x04tool\x18\v \x01(\v2 .adrian.core_api.v1.ToolPairDataH\x00R\x04tool\x12#\n" + - "\rmetadata_json\x18\x14 \x01(\fR\fmetadataJsonB\x06\n" + + "\rmetadata_json\x18\x14 \x01(\fR\fmetadataJson\x12\x16\n" + + "\x06source\x18\x15 \x01(\tR\x06sourceB\x06\n" + "\x04data\"K\n" + "\x10PairedEventBatch\x127\n" + "\x06events\x18\x01 \x03(\v2\x1f.adrian.core_api.v1.PairedEventR\x06events\"Y\n" + @@ -1594,12 +1613,13 @@ const file_event_proto_rawDesc = "" + "\aservers\x18\x01 \x03(\v2\x1d.adrian.core_api.v1.McpServerR\aservers\"<\n" + "\bLLMStack\x12\x1a\n" + "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" + - "\x05model\x18\x02 \x01(\tR\x05model\"\xa1\x01\n" + + "\x05model\x18\x02 \x01(\tR\x05model\"\xb9\x01\n" + "\fSessionLogin\x12\x1d\n" + "\n" + "session_id\x18\x01 \x01(\tR\tsessionId\x129\n" + "\tllm_stack\x18\x02 \x01(\v2\x1c.adrian.core_api.v1.LLMStackR\bllmStack\x12%\n" + - "\x0eschema_version\x18\x04 \x01(\rR\rschemaVersionJ\x04\b\x03\x10\x04R\n" + + "\x0eschema_version\x18\x04 \x01(\rR\rschemaVersion\x12\x16\n" + + "\x06source\x18\x05 \x01(\tR\x06sourceJ\x04\b\x03\x10\x04R\n" + "block_mode\"\xf1\x01\n" + "\vClientFrame\x128\n" + "\x05login\x18\x01 \x01(\v2 .adrian.core_api.v1.SessionLoginH\x00R\x05login\x12I\n" + diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index a40706b..546012f 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -214,6 +214,7 @@ func handleLogin(ctx context.Context, conn *websocket.Conn, sess *session, st *s sess.llmProvider = login.LlmStack.Provider sess.llmModel = login.LlmStack.Model } + sess.source = login.GetSource() sess.loggedIn = true pol, err := st.GetPolicy(ctx) @@ -369,20 +370,26 @@ func dispatchVerdict(ctx context.Context, sess *session, st *store.Store, hub *H // notified. No SDK-side action is expected in alert mode. return nil case pb.Mode_MODE_HITL: - if inScope && isActionable(ev) { - // Queue for human review and hold the verdict. The reviews - // REST handler resumes the SDK with a HitlResponse-bearing - // Verdict on approve/reject via the same hub channel. + // Claude Code gates HITL client-side: its PreToolUse hook blocks and + // shows a terminal approval prompt, so CC is its own wait point. + // Forward every in-scope verdict to it and never queue a dashboard + // review no operator would action. This is source-gated and + // independent of isActionable, which only models the LangChain wait + // point. Other SDKs are held for review only when they have a + // server-visible wait point (isActionable); anything else forwards so + // the SDK isn't left waiting on a resolution that will never come. + if inScope && sess.source != "claude-code" && isActionable(ev) { + // Queue for human review and hold the verdict. The reviews REST + // handler resumes the SDK with a HitlResponse-bearing Verdict on + // approve/reject via the same hub channel. if err := st.InsertHitlQueue(ctx, ev.EventId, verdictID, sess.sessionID, madCode); err != nil { slog.ErrorContext(ctx, "hitl.insert_failed", "error", err, "event_id", ev.EventId) } return nil } - // Out of scope OR non-actionable, forward so the SDK isn't - // left waiting on a resolution that will never come, and the - // operator isn't asked to review something approving cannot - // influence. + // Fall through: forward (Claude Code inline HITL, a non-actionable + // event, or an out-of-scope code). case pb.Mode_MODE_BLOCK: // Forward every verdict; SDK is the policy enforcement point. default: diff --git a/backend/internal/ws/session.go b/backend/internal/ws/session.go index cb251a3..ad0cf4d 100644 --- a/backend/internal/ws/session.go +++ b/backend/internal/ws/session.go @@ -14,7 +14,10 @@ type session struct { sessionID string llmProvider string llmModel string - loggedIn bool + // source is the SDK / integration identifier from SessionLogin.source + // (e.g. "claude-code"). Drives CC-native HITL handling in dispatchVerdict. + source string + loggedIn bool } // agentProfileID returns the bound agent_profile_id (or nil if the diff --git a/backend/proto/event.proto b/backend/proto/event.proto index fe1ad33..5b7ea98 100644 --- a/backend/proto/event.proto +++ b/backend/proto/event.proto @@ -117,6 +117,7 @@ message PairedEvent { // Escape hatch for framework metadata not modelled as first-class fields // (e.g. LangGraph checkpoint_ns, arbitrary tags). bytes metadata_json = 20; + string source = 21; } // PairedEventBatch wraps multiple paired events for batched WebSocket frames. @@ -169,6 +170,9 @@ message SessionLogin { reserved "block_mode"; // Wire schema version the client is speaking. Server rejects unknown values. uint32 schema_version = 4; + // SDK / integration that produced this session (e.g. "claude-code"); the + // server branches HITL behavior on it. Empty for legacy SDKs. + string source = 5; } // ClientFrame is the top-level envelope for all client WebSocket messages. diff --git a/frontend/app/(dashboard)/settings/page.tsx b/frontend/app/(dashboard)/settings/page.tsx index 9f32b4d..bf0aa95 100644 --- a/frontend/app/(dashboard)/settings/page.tsx +++ b/frontend/app/(dashboard)/settings/page.tsx @@ -96,6 +96,7 @@ function PolicyTab() {
@@ -168,12 +172,35 @@ function PolicyTab() { ) } -function SelectField({ label, value, options, onChange }: { - label: string; value: string; options: string[][]; onChange: (v: string) => void +function InfoTooltip({ text }: { text: string }) { + return ( + + + ! + + + {text} + + + ) +} + +function SelectField({ label, value, options, onChange, hint }: { + label: string; value: string; options: string[][]; onChange: (v: string) => void; hint?: string }) { return (
- +