Skip to content

Commit bd16ef2

Browse files
authored
feat: wire full governance telemetry pipeline (#3)
## Summary Wires the complete governance telemetry pipeline using native ConnectRPC. Authenticates with the user's OIDC token — no client secrets, no client_credentials. ## What this PR does - **ConnectRPC client** (`internal/backend/`) — uses user's bearer token from `kontext login`. 90 lines, no token management. - **Session lifecycle** — CreateSession on start, heartbeat every 30s, EndSession on exit - **Sidecar** — Unix socket server, processes hook events, ingests to backend via ConnectRPC async - **Hook registration** — generates Claude Code `settings.json` with PreToolUse, PostToolUse, UserPromptSubmit - **Hook command** — reads stdin, connects to sidecar via `KONTEXT_SOCKET`, writes decision to stdout - **Wire protocol** — length-prefixed JSON over Unix socket - **Proto codegen** — generated from [`kontext-dev/proto`](https://github.com/kontext-dev/proto) via `buf generate` ## Auth model The user's OIDC access token (from `kontext login`, stored in system keyring) is the only credential. No `KONTEXT_CLIENT_ID`, no `KONTEXT_CLIENT_SECRET`, no `client_credentials` grant. The backend verifies the JWT and derives org + user identity. Credential exchange (`.env.kontext` → provider tokens) uses the existing `POST /oauth2/token` endpoint (RFC 8693), same as the SDK's `requireProvider`. Not part of the ConnectRPC service. ## Blocked by (server-side) - [ ] **kontext-security/kontext#408** — `AgentService` ConnectRPC endpoint + session endpoints accepting user tokens ## Before merging - [ ] #408 deployed - [ ] End-to-end: `kontext start --agent claude` → session in dashboard → events in traces → clean disconnect ## Events | Event | Source | Purpose | |---|---|---| | `session.begin` | CLI lifecycle | Session in dashboard | | `session.end` | CLI lifecycle | Session disconnected | | `hook.pre_tool_call` | PreToolUse | Tool call telemetry | | `hook.post_tool_call` | PostToolUse | Tool result audit | | `hook.user_prompt` | UserPromptSubmit | Prompt logging | Closes #2 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent dcc9d7a commit bd16ef2

File tree

12 files changed

+1444
-161
lines changed

12 files changed

+1444
-161
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
bin/
22
dist/
3-
gen/
43
*.exe
54
.env.kontext
65
kontext

README.md

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ kontext start --agent claude
99
```
1010

1111
1. **Authenticates** — loads your identity from the system keyring (set up via `kontext login`)
12-
2. **Resolves credentials** — reads `.env.kontext`, exchanges placeholders for short-lived tokens via Kontext
13-
3. **Launches the agent** — spawns Claude Code with credentials injected as env vars
14-
4. **Enforces policy** — every tool call is evaluated against your org's OpenFGA policy (via a local sidecar)
15-
5. **Logs everything** — full audit trail streamed to the Kontext backend via gRPC
12+
2. **Creates a session** — registers with the Kontext backend, visible in the dashboard
13+
3. **Resolves credentials** — reads `.env.kontext`, exchanges placeholders for short-lived tokens
14+
4. **Launches the agent** — spawns Claude Code with credentials injected as env vars + governance hooks
15+
5. **Captures every action** — PreToolUse, PostToolUse, and UserPromptSubmit events streamed to the backend
16+
6. **Tears down cleanly** — session disconnected, credentials expired, temp files removed
1617

1718
Credentials are ephemeral — scoped to the session, gone when it ends.
1819

@@ -33,28 +34,25 @@ go build -o bin/kontext ./cmd/kontext
3334
### First-time setup
3435

3536
```bash
36-
kontext login
37+
kontext start --agent claude
3738
```
3839

39-
Opens a browser for OIDC authentication. Stores your refresh token in the system keyring (macOS Keychain / Linux secret service). No client IDs or secrets to manage.
40+
On first run, the CLI handles everything interactively:
41+
- No session? Opens browser for OIDC login, stores refresh token in system keyring
42+
- No `.env.kontext`? Prompts for which providers the project needs, writes the file
43+
- Provider not connected? Opens browser to the Kontext hosted connect flow
4044

4145
### Declare credentials
4246

43-
Create a `.env.kontext` file in your project:
47+
The `.env.kontext` file declares what credentials the project needs:
4448

4549
```
4650
GITHUB_TOKEN={{kontext:github}}
4751
STRIPE_KEY={{kontext:stripe}}
4852
DATABASE_URL={{kontext:postgres/prod-readonly}}
4953
```
5054

51-
### Run
52-
53-
```bash
54-
kontext start --agent claude
55-
```
56-
57-
The CLI resolves each placeholder, injects the credentials as env vars, and launches Claude Code with governance hooks active.
55+
Commit this to your repo — the team shares it.
5856

5957
### Supported agents
6058

@@ -69,38 +67,103 @@ The CLI resolves each placeholder, injects the credentials as env vars, and laun
6967
```
7068
kontext start --agent claude
7169
72-
├── Auth: OIDC refresh token from keyring → ephemeral session token
73-
├── Credentials: .env.kontext → ExchangeCredential RPC → env vars
74-
├── Sidecar: Unix socket server for hook ↔ backend communication
75-
├── Agent: spawn claude with injected env + hook config
70+
├── Auth: OIDC refresh token from keyring
71+
├── ConnectRPC: CreateSession → session in dashboard
72+
├── Sidecar: Unix socket server (kontext.sock)
73+
│ ├── Heartbeat loop (30s)
74+
│ └── Async event ingestion via ConnectRPC
75+
├── Hooks: settings.json → Claude Code --settings
76+
├── Agent: spawn claude with injected env
7677
│ │
77-
│ ├── [PreToolUse] → hook binary → sidecar → policy eval → allow/deny
78-
│ └── [PostToolUse] → hook binary → sidecar → audit log
78+
│ ├── [PreToolUse] → kontext hook → sidecar → ingest
79+
│ ├── [PostToolUse] → kontext hook → sidecar → ingest
80+
│ └── [UserPromptSubmit] → kontext hook → sidecar → ingest
7981
80-
└── Backend: bidirectional gRPC stream (ProcessHookEvent, SyncPolicy)
82+
└── On exit: EndSession → cleanup
8183
```
8284

83-
**Hook handlers** are the compiled `kontext hook` binary — <5ms startup, communicates with the sidecar over a Unix socket. No per-hook HTTP requests.
85+
### Hook flow (per tool call)
8486

85-
**Policy evaluation** uses OpenFGA tuples cached locally by the sidecar. The backend streams policy updates in real-time via `SyncPolicy`.
87+
```
88+
Claude Code fires PreToolUse
89+
→ spawns: kontext hook --agent claude
90+
→ hook reads stdin JSON (tool_name, tool_input)
91+
→ hook connects to sidecar via KONTEXT_SOCKET (Unix socket)
92+
→ sidecar returns allow/deny immediately
93+
→ sidecar ingests event to backend asynchronously
94+
→ hook writes decision JSON to stdout, exits
95+
→ ~5ms total (Go binary, no runtime startup)
96+
```
97+
98+
## Telemetry Strategy
99+
100+
The CLI separates **governance telemetry** from **developer observability**. These are distinct concerns with different backends and data models.
101+
102+
### Governance telemetry (built-in)
103+
104+
Session lifecycle and tool call events flow to the Kontext backend. This powers the dashboard — sessions, traces, audit trail.
105+
106+
| Event | Source | When |
107+
|---|---|---|
108+
| `session.begin` | CLI lifecycle | Agent launched |
109+
| `session.end` | CLI lifecycle | Agent exited |
110+
| `hook.pre_tool_call` | PreToolUse hook | Before every tool execution |
111+
| `hook.post_tool_call` | PostToolUse hook | After every tool execution |
112+
| `hook.user_prompt` | UserPromptSubmit hook | User submits a prompt |
113+
114+
Events are streamed to the backend via the ConnectRPC `ProcessHookEvent` bidirectional stream and stored in the `mcp_events` table.
115+
116+
**What governance telemetry captures:**
117+
- What the agent tried to do (tool name + input)
118+
- What happened (tool response)
119+
- Whether it was allowed (policy decision)
120+
- Who did it (session → user → org attribution)
121+
- When (timestamps, duration)
122+
123+
**What governance telemetry does NOT capture:**
124+
- LLM reasoning or thinking
125+
- Token usage or cost
126+
- Model parameters
127+
- Conversation history
128+
- Response quality
129+
130+
### Developer observability (external, future)
131+
132+
LLM-level observability — generation details, token costs, reasoning traces, conversation history — is a separate concern. It is not part of the governance pipeline.
133+
134+
For this, the CLI will optionally export OpenTelemetry spans to an external backend:
135+
- **Langfuse** — open-source, has a native Claude Code integration, self-hostable
136+
- **Dash0** — OTEL-native SaaS, cheap ($0.60/M spans), AI/agent-aware
137+
138+
This is additive — the governance pipeline works independently. OTEL export is planned but not yet implemented.
86139

87140
## Protocol
88141

89142
Service definitions: [`proto/kontext/agent/v1/agent.proto`](proto/kontext/agent/v1/agent.proto)
90143

91-
Uses [ConnectRPC](https://connectrpc.com/) (gRPC-compatible) for backend communication.
144+
The CLI communicates with the Kontext backend exclusively via ConnectRPC using the generated stubs. Requires the server-side `AgentService` endpoint ([kontext-dev/kontext#408](https://github.com/kontext-dev/kontext/issues/408)).
145+
146+
### Sidecar wire protocol
147+
148+
Hook handlers communicate with the sidecar over a Unix socket using length-prefixed JSON (4-byte big-endian uint32 + JSON payload):
149+
150+
- `EvaluateRequest` — hook → sidecar: agent, hook_event, tool_name, tool_input, tool_response
151+
- `EvaluateResult` — sidecar → hook: allowed (bool), reason (string)
92152

93153
## Development
94154

95155
```bash
96156
# Build
97157
go build -o bin/kontext ./cmd/kontext
98158

99-
# Generate protobuf (requires buf)
159+
# Generate protobuf (requires buf + plugins)
100160
buf generate
101161

102162
# Test
103163
go test ./...
164+
165+
# Link for local use
166+
ln -sf $(pwd)/bin/kontext ~/.local/bin/kontext
104167
```
105168

106169
## License

cmd/kontext/main.go

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
7+
"net"
68
"os"
9+
"time"
710

811
"github.com/spf13/cobra"
912

13+
"github.com/kontext-dev/kontext-cli/internal/agent"
1014
"github.com/kontext-dev/kontext-cli/internal/auth"
15+
"github.com/kontext-dev/kontext-cli/internal/hook"
1116
"github.com/kontext-dev/kontext-cli/internal/run"
17+
"github.com/kontext-dev/kontext-cli/internal/sidecar"
1218

1319
// Register agent adapters
1420
_ "github.com/kontext-dev/kontext-cli/internal/agent/claude"
@@ -97,17 +103,76 @@ func hookCmd() *cobra.Command {
97103
Short: "Process a hook event (called by the agent, not by users)",
98104
Hidden: true,
99105
RunE: func(cmd *cobra.Command, args []string) error {
100-
fmt.Fprintln(os.Stderr, "kontext hook (not yet implemented)")
101-
// TODO:
102-
// 1. Read stdin (hook event JSON)
103-
// 2. Connect to sidecar via KONTEXT_SOCKET
104-
// 3. Send event, receive decision
105-
// 4. Write decision to stdout, exit with appropriate code
106-
return nil
106+
a, ok := agent.Get(agentName)
107+
if !ok {
108+
fmt.Fprintf(os.Stderr, "unknown agent: %s\n", agentName)
109+
os.Exit(2)
110+
}
111+
112+
socketPath := os.Getenv("KONTEXT_SOCKET")
113+
if socketPath == "" {
114+
// No sidecar — fail-open
115+
hook.Run(a, func(e *agent.HookEvent) (bool, string, error) {
116+
return true, "no sidecar", nil
117+
})
118+
return nil // unreachable
119+
}
120+
121+
hook.Run(a, func(e *agent.HookEvent) (bool, string, error) {
122+
return evaluateViaSidecar(socketPath, agentName, e)
123+
})
124+
return nil // unreachable (hook.Run calls os.Exit)
107125
},
108126
}
109127

110128
cmd.Flags().StringVar(&agentName, "agent", "claude", "Agent type")
111129

112130
return cmd
113131
}
132+
133+
func evaluateViaSidecar(socketPath, agentName string, e *agent.HookEvent) (bool, string, error) {
134+
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
135+
if err != nil {
136+
// Sidecar unreachable — fail-open
137+
return true, "sidecar unreachable", nil
138+
}
139+
defer conn.Close()
140+
conn.SetDeadline(time.Now().Add(10 * time.Second))
141+
142+
req := sidecar.EvaluateRequest{
143+
Type: "evaluate",
144+
Agent: agentName,
145+
HookEvent: e.HookEventName,
146+
ToolName: e.ToolName,
147+
ToolUseID: e.ToolUseID,
148+
CWD: e.CWD,
149+
}
150+
151+
// Marshal tool input/response to JSON
152+
if e.ToolInput != nil {
153+
data, _ := marshalJSON(e.ToolInput)
154+
req.ToolInput = data
155+
}
156+
if e.ToolResponse != nil {
157+
data, _ := marshalJSON(e.ToolResponse)
158+
req.ToolResponse = data
159+
}
160+
161+
if err := sidecar.WriteMessage(conn, req); err != nil {
162+
return true, "sidecar write error", nil
163+
}
164+
165+
var result sidecar.EvaluateResult
166+
if err := sidecar.ReadMessage(conn, &result); err != nil {
167+
return true, "sidecar read error", nil
168+
}
169+
170+
return result.Allowed, result.Reason, nil
171+
}
172+
173+
func marshalJSON(v any) ([]byte, error) {
174+
if v == nil {
175+
return nil, nil
176+
}
177+
return json.Marshal(v)
178+
}

0 commit comments

Comments
 (0)