From 700462d6e9442ac9930f2b2d2cf5a1277aee5d5f Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Wed, 5 Aug 2026 19:18:57 -0700 Subject: [PATCH 1/9] feat: add the internal/ipc transport and the in-process telemetry recorder internal/ipc carries one hook invocation per connection between the client and server modes: length-prefixed JSON frames, an endpoint derived from the consumer identity (executable + pre-sentinel flags) as a unix socket or a go-winio named pipe, and executable build stamps for the LSP-style upgrade handshake. internal/filelock (ported from the telemetry branch) backs the server singleton and the client spawn lock. The telemetry package lands with the delivery-agnostic pieces ported from feat/telemetry (record model, deterministic trace/span identity, credential redaction) and its pipeline rebuilt for in-process delivery: an OTel BatchProcessor feeding a gzip-compressed OTLP/HTTP exporter, flushed via the new Shutdown/ForceFlush methods. The disk spool and the exporter daemon never land. Co-authored-by: Cursor --- go.mod | 29 ++- go.sum | 67 ++++- internal/filelock/filelock_other.go | 34 +++ internal/filelock/filelock_unix.go | 34 +++ internal/filelock/filelock_windows.go | 37 +++ internal/hookrecord/hookrecord.go | 87 +++++++ internal/ipc/endpoint_unix.go | 69 +++++ internal/ipc/endpoint_windows.go | 49 ++++ internal/ipc/ipc.go | 188 ++++++++++++++ internal/ipc/ipc_test.go | 288 +++++++++++++++++++++ telemetry/helpers_test.go | 134 ++++++++++ telemetry/identity.go | 117 +++++++++ telemetry/identity_test.go | 193 ++++++++++++++ telemetry/record.go | 356 ++++++++++++++++++++++++++ telemetry/redact.go | 209 +++++++++++++++ telemetry/telemetry.go | 254 ++++++++++++++++++ telemetry/telemetry_test.go | 270 +++++++++++++++++++ 17 files changed, 2412 insertions(+), 3 deletions(-) create mode 100644 internal/filelock/filelock_other.go create mode 100644 internal/filelock/filelock_unix.go create mode 100644 internal/filelock/filelock_windows.go create mode 100644 internal/hookrecord/hookrecord.go create mode 100644 internal/ipc/endpoint_unix.go create mode 100644 internal/ipc/endpoint_windows.go create mode 100644 internal/ipc/ipc.go create mode 100644 internal/ipc/ipc_test.go create mode 100644 telemetry/helpers_test.go create mode 100644 telemetry/identity.go create mode 100644 telemetry/identity_test.go create mode 100644 telemetry/record.go create mode 100644 telemetry/redact.go create mode 100644 telemetry/telemetry.go create mode 100644 telemetry/telemetry_test.go diff --git a/go.mod b/go.mod index 478cc41..0cabdf6 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,31 @@ go 1.25.0 require github.com/pelletier/go-toml/v2 v2.4.2 -require golang.org/x/sys v0.46.0 +require ( + github.com/Microsoft/go-winio v0.6.2 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 + go.opentelemetry.io/otel/log v0.21.0 + go.opentelemetry.io/otel/sdk v1.45.0 + go.opentelemetry.io/otel/sdk/log v0.21.0 + go.opentelemetry.io/otel/trace v1.45.0 + go.opentelemetry.io/proto/otlp v1.11.0 + golang.org/x/sys v0.47.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/grpc v1.83.0 // indirect +) diff --git a/go.sum b/go.sum index a529463..c0225bf 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,67 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0/go.mod h1:zyGrjRKL2B/6+Jc/m4/otPoZqV2MY9ZjC/aBraRO7zc= +go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA= +go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc= +go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/filelock/filelock_other.go b/internal/filelock/filelock_other.go new file mode 100644 index 0000000..9b4dc62 --- /dev/null +++ b/internal/filelock/filelock_other.go @@ -0,0 +1,34 @@ +//go:build !linux && !darwin && !windows + +package filelock + +import ( + "os" + "time" +) + +// staleAfter bounds how long an O_EXCL marker left by a crashed process can +// block new lock holders on platforms without advisory locks. +const staleAfter = 5 * time.Minute + +// TryLock attempts a best-effort exclusive lock on path using an O_EXCL +// marker file. It returns a release func, whether the lock was acquired, and +// any unexpected error. A held lock elsewhere yields (noop, false, nil). +func TryLock(path string) (func(), bool, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if os.IsExist(err) { + if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleAfter { + if removeErr := os.Remove(path); removeErr == nil { + return TryLock(path) + } + } + return func() {}, false, nil + } + if err != nil { + return func() {}, false, err + } + return func() { + _ = f.Close() + _ = os.Remove(path) + }, true, nil +} diff --git a/internal/filelock/filelock_unix.go b/internal/filelock/filelock_unix.go new file mode 100644 index 0000000..4998d87 --- /dev/null +++ b/internal/filelock/filelock_unix.go @@ -0,0 +1,34 @@ +//go:build linux || darwin + +// Package filelock provides a best-effort, non-blocking advisory file lock, +// used to enforce the hook server singleton and to serialize client +// auto-spawns. +package filelock + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +// TryLock attempts a non-blocking exclusive lock on path, creating the file +// if needed. It returns a release func, whether the lock was acquired, and +// any unexpected error. A held lock elsewhere yields (noop, false, nil). +func TryLock(path string) (func(), bool, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return func() {}, false, err + } + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + _ = f.Close() + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return func() {}, false, nil + } + return func() {}, false, err + } + return func() { + _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) + _ = f.Close() + }, true, nil +} diff --git a/internal/filelock/filelock_windows.go b/internal/filelock/filelock_windows.go new file mode 100644 index 0000000..1649f68 --- /dev/null +++ b/internal/filelock/filelock_windows.go @@ -0,0 +1,37 @@ +//go:build windows + +package filelock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// TryLock attempts a non-blocking exclusive lock on path, creating the file +// if needed. It returns a release func, whether the lock was acquired, and +// any unexpected error. A held lock elsewhere yields (noop, false, nil). +func TryLock(path string) (func(), bool, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return func() {}, false, err + } + overlapped := &windows.Overlapped{} + err = windows.LockFileEx( + windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, 1, 0, overlapped, + ) + if err != nil { + _ = f.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return func() {}, false, nil + } + return func() {}, false, err + } + return func() { + _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped) + _ = f.Close() + }, true, nil +} diff --git a/internal/hookrecord/hookrecord.go b/internal/hookrecord/hookrecord.go new file mode 100644 index 0000000..64ce0c2 --- /dev/null +++ b/internal/hookrecord/hookrecord.go @@ -0,0 +1,87 @@ +// Package hookrecord carries the runner's end-of-processing view of one +// hook event across the agenthooks ↔ telemetry package boundary. It is +// internal on purpose: the telemetry recorder's tap methods take these +// types, which keeps them callable by the runner but not by external +// consumers, without growing the public API of either package. +package hookrecord + +import ( + "encoding/json" + "time" +) + +// Record is the observational snapshot of a hook event the runner hands to +// the telemetry recorder: identity, the normalized payload fields telemetry +// needs, and processing-health signals (dispatch duration, handler errors). +// Deliberately absent: the enforcement decision — telemetry logs the event, +// never the verdict; gram's decision-time enforcement log is the sole +// record of decisions. +type Record struct { + Provider string + Variant string + NativeName string + Kind string // unified kind, e.g. "tool.pre" + Time time.Time // library receive time + Backfilled bool + + SessionID string + TurnID string + CWD string + Model string + UserEmail string + + SubagentID string + SubagentType string + + Tool *Tool + + // Kind-specific payloads; zero-valued when the kind does not carry them. + Prompt string + FinalMessage string + LoopCount int + Usage *Usage + Notification string + FilePath string + SessionSource string + SessionEndReason string + CompactTrigger string + + HandlerErr string // non-empty when the handler pipeline failed + + // HookDurationMS is dispatch-to-response-encoded time in milliseconds — + // the hook's own overhead, distinct from tool execution time. + HookDurationMS float64 +} + +// Tool mirrors the normalized ToolCall plus the post-execution fields of +// tool.post/tool.error events. +type Tool struct { + ID string + Synthesized bool + Name string + Canonical string + Input json.RawMessage + Output json.RawMessage + Failed bool + Error string + DurationMS *float64 + MCP *MCP +} + +// MCP mirrors MCPCall: decoded identity plus transport. +type MCP struct { + Server string + Tool string + URL string + Command string + FromConfig bool +} + +// Usage mirrors the end-of-turn token/cost totals. +type Usage struct { + InputTokens *int + OutputTokens *int + CacheReadTokens *int + CacheWriteTokens *int + Cost *float64 +} diff --git a/internal/ipc/endpoint_unix.go b/internal/ipc/endpoint_unix.go new file mode 100644 index 0000000..457a838 --- /dev/null +++ b/internal/ipc/endpoint_unix.go @@ -0,0 +1,69 @@ +//go:build !windows + +package ipc + +import ( + "errors" + "net" + "os" + "path/filepath" + "syscall" + "time" +) + +// maxSocketPath conservatively undercuts both sun_path limits (104 bytes on +// macOS/BSD, 108 on Linux). Longer derived paths fall back to the system +// temp dir, whose paths are short by construction. +const maxSocketPath = 96 + +// stateDir roots the sockets and lock files: +// $XDG_STATE_HOME/agenthooks, falling back to the user cache dir, then the +// system temp dir. +func stateDir() (string, error) { + if s := os.Getenv("XDG_STATE_HOME"); s != "" { + return filepath.Join(s, "agenthooks"), nil + } + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "agenthooks"), nil + } + return filepath.Join(os.TempDir(), "agenthooks"), nil +} + +func endpoint(dir, id string) string { + name := "agenthooks-" + id + ".sock" + path := filepath.Join(dir, name) + if len(path) > maxSocketPath { + return filepath.Join(os.TempDir(), name) + } + return path +} + +func lockPath(dir, id, suffix string) string { + return filepath.Join(dir, "agenthooks-"+id+suffix) +} + +// Listen binds the endpoint. A socket file left behind by a crashed server +// (its advisory locks died with it, so the caller holds the server lock) is +// probed and swept; if something actually answers, ErrAlreadyRunning. +func Listen(endpoint string) (net.Listener, error) { + if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil { + return nil, err + } + ln, err := net.Listen("unix", endpoint) + if err == nil { + return ln, nil + } + if !errors.Is(err, syscall.EADDRINUSE) { + return nil, err + } + if dialProbe(endpoint) { + return nil, ErrAlreadyRunning + } + _ = os.Remove(endpoint) + return net.Listen("unix", endpoint) +} + +// Dial connects to a listening server, bounded by timeout. +func Dial(endpoint string, timeout time.Duration) (net.Conn, error) { + return net.DialTimeout("unix", endpoint, timeout) +} diff --git a/internal/ipc/endpoint_windows.go b/internal/ipc/endpoint_windows.go new file mode 100644 index 0000000..9a9ffc7 --- /dev/null +++ b/internal/ipc/endpoint_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package ipc + +import ( + "net" + "os" + "path/filepath" + "time" + + "github.com/Microsoft/go-winio" +) + +// stateDir roots the lock files (the endpoint itself is a named pipe, not a +// filesystem path): %LOCALAPPDATA%\agenthooks via os.UserCacheDir, falling +// back to the system temp dir. +func stateDir() (string, error) { + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "agenthooks"), nil + } + return filepath.Join(os.TempDir(), "agenthooks"), nil +} + +func endpoint(_, id string) string { + return `\\.\pipe\agenthooks-` + id +} + +func lockPath(dir, id, suffix string) string { + return filepath.Join(dir, "agenthooks-"+id+suffix) +} + +// Listen creates the named pipe. Pipe instances vanish with their process, +// so there is no stale-endpoint sweep here; a creation failure with a live +// listener behind it maps to ErrAlreadyRunning. +func Listen(endpoint string) (net.Listener, error) { + ln, err := winio.ListenPipe(endpoint, nil) + if err == nil { + return ln, nil + } + if dialProbe(endpoint) { + return nil, ErrAlreadyRunning + } + return nil, err +} + +// Dial connects to a listening server, bounded by timeout. +func Dial(endpoint string, timeout time.Duration) (net.Conn, error) { + return winio.DialPipe(endpoint, &timeout) +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go new file mode 100644 index 0000000..58ce453 --- /dev/null +++ b/internal/ipc/ipc.go @@ -0,0 +1,188 @@ +// Package ipc is the transport between the per-hook client process +// (`mybinary agenthooks client`) and the long-running hook server +// (`mybinary agenthooks server`): endpoint derivation from the consumer +// identity, length-prefixed JSON framing, and the request/response types. +// Unix domain sockets everywhere except Windows, which uses named pipes. +// +// The package is internal on purpose: the wire is an implementation detail +// of the runner's client/server modes, versioned by ProtocolVersion, not a +// public API. +package ipc + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "time" +) + +// ProtocolVersion is the framing/schema version. A server that receives a +// request with a different version answers with an error frame, which makes +// the client fall back to its in-process pipeline. +const ProtocolVersion = 1 + +// MaxFrameBytes bounds one frame. The hook payload cap is 32 MiB; base64 +// (JSON []byte encoding) inflates it by 4/3, and the envelope adds argv and +// environment. 64 MiB leaves comfortable headroom without letting a corrupt +// length prefix allocate unbounded memory. +const MaxFrameBytes = 64 << 20 + +// Request is one hook invocation forwarded by the client: the client's full +// argv (the server re-parses it for --provider/--timeout/--filter and the +// payload-carrying positionals of the notify verb), the raw stdin payload, +// and the slice of process state the pipeline needs (allowlisted environment +// variables, working directory). +type Request struct { + V int `json:"v"` + // Build fingerprints the client's executable (path, size, mtime). On + // mismatch with the server's own fingerprint the server finishes + // in-flight work, flushes telemetry, and exits, so the next spawn runs + // the new binary — the LSP-style upgrade story. + Build string `json:"build,omitempty"` + Argv []string `json:"argv,omitempty"` + Stdin []byte `json:"stdin,omitempty"` + Env map[string]string `json:"env,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +// Response carries the provider-dialect wire output back to the client, +// which relays it verbatim: stdout bytes, stderr bytes (Kimi's blocking +// mechanism is exit 2 with the reason on stderr), and the exit code. A +// non-empty Error means the server could not process the request at the +// protocol level; the client treats it like an unreachable server and runs +// in-process. +type Response struct { + V int `json:"v"` + Error string `json:"error,omitempty"` + Stdout []byte `json:"stdout,omitempty"` + Stderr []byte `json:"stderr,omitempty"` + ExitCode int `json:"exit_code"` +} + +// ErrAlreadyRunning reports that another server already owns the endpoint. +var ErrAlreadyRunning = errors.New("ipc: a server is already listening on this endpoint") + +// WriteFrame writes one length-prefixed JSON frame: a 4-byte big-endian +// length followed by the JSON body. +func WriteFrame(w io.Writer, v any) error { + body, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("ipc: encoding frame: %w", err) + } + if len(body) > MaxFrameBytes { + return fmt.Errorf("ipc: frame of %d bytes exceeds the %d-byte cap", len(body), MaxFrameBytes) + } + var prefix [4]byte + binary.BigEndian.PutUint32(prefix[:], uint32(len(body))) //nolint:gosec // bounded by the MaxFrameBytes check above + if _, err := w.Write(prefix[:]); err != nil { + return fmt.Errorf("ipc: writing frame length: %w", err) + } + if _, err := w.Write(body); err != nil { + return fmt.Errorf("ipc: writing frame body: %w", err) + } + return nil +} + +// ReadFrame reads one length-prefixed JSON frame into v. +func ReadFrame(r io.Reader, v any) error { + var prefix [4]byte + if _, err := io.ReadFull(r, prefix[:]); err != nil { + return fmt.Errorf("ipc: reading frame length: %w", err) + } + n := binary.BigEndian.Uint32(prefix[:]) + if n > MaxFrameBytes { + return fmt.Errorf("ipc: frame of %d bytes exceeds the %d-byte cap", n, MaxFrameBytes) + } + body := make([]byte, n) + if _, err := io.ReadFull(r, body); err != nil { + return fmt.Errorf("ipc: reading frame body: %w", err) + } + if err := json.Unmarshal(body, v); err != nil { + return fmt.Errorf("ipc: decoding frame: %w", err) + } + return nil +} + +// Identity fingerprints one consumer deployment: the executable path plus +// the consumer flags that precede the "agenthooks" sentinel in the hook +// command (e.g. --config=/path/speakeasy.json). Distinct binaries or +// distinct configs get distinct identities — and therefore distinct +// servers; the per-hook flags after the sentinel (--provider, --timeout) +// deliberately do not participate. +func Identity(exe string, preArgs []string) string { + h := sha256.New() + h.Write([]byte(exe)) + for _, a := range preArgs { + h.Write([]byte{0}) + h.Write([]byte(a)) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// BuildStamp fingerprints the executable file behind path — path, size, and +// mtime — cheaply enough to compute per hook invocation. Replacing the +// binary on disk changes the stamp, which is what triggers the server's +// upgrade shutdown. Returns "" when the file cannot be inspected; empty +// stamps never trigger a mismatch. +func BuildStamp(exe string) string { + fi, err := os.Stat(exe) + if err != nil { + return "" + } + h := sha256.New() + h.Write([]byte(exe)) + h.Write([]byte{0}) + h.Write([]byte(strconv.FormatInt(fi.Size(), 10))) + h.Write([]byte{0}) + h.Write([]byte(strconv.FormatInt(fi.ModTime().UnixNano(), 10))) + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// Address is the resolved rendezvous for one consumer identity. +type Address struct { + // ID is the consumer identity hash (see Identity). + ID string + // Endpoint is the unix socket path, or the named-pipe name on Windows. + Endpoint string + // ServerLock is held for the server's lifetime — belt-and-braces + // singleton enforcement alongside the endpoint bind itself. + ServerLock string + // SpawnLock serializes client auto-spawns so a thundering herd of hook + // invocations starts one server, not dozens. + SpawnLock string +} + +// Resolve derives the Address for a consumer identity and ensures the state +// directory exists (0700). +func Resolve(exe string, preArgs []string) (Address, error) { + id := Identity(exe, preArgs) + dir, err := stateDir() + if err != nil { + return Address{}, fmt.Errorf("ipc: resolving state dir: %w", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return Address{}, fmt.Errorf("ipc: creating state dir: %w", err) + } + return Address{ + ID: id, + Endpoint: endpoint(dir, id), + ServerLock: lockPath(dir, id, ".lock"), + SpawnLock: lockPath(dir, id, ".spawn"), + }, nil +} + +// dialProbe reports whether something answers on the endpoint right now. +func dialProbe(endpoint string) bool { + conn, err := Dial(endpoint, 250*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go new file mode 100644 index 0000000..afda999 --- /dev/null +++ b/internal/ipc/ipc_test.go @@ -0,0 +1,288 @@ +package ipc + +import ( + "bytes" + "encoding/binary" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestFrameRoundTrip(t *testing.T) { + var buf bytes.Buffer + in := Request{ + V: ProtocolVersion, + Build: "abcdef0123456789", + Argv: []string{"--config=/x", "agenthooks", "client", "--provider=claude-code"}, + Stdin: []byte(`{"hook_event_name":"PreToolUse"}`), + Env: map[string]string{"TRACEPARENT": "00-11-22-01"}, + CWD: "/work", + } + if err := WriteFrame(&buf, in); err != nil { + t.Fatalf("WriteFrame: %v", err) + } + var out Request + if err := ReadFrame(&buf, &out); err != nil { + t.Fatalf("ReadFrame: %v", err) + } + if out.V != in.V || out.Build != in.Build || out.CWD != in.CWD || + !bytes.Equal(out.Stdin, in.Stdin) || len(out.Argv) != len(in.Argv) || + out.Env["TRACEPARENT"] != "00-11-22-01" { + t.Errorf("round trip mangled the frame: %+v", out) + } + if buf.Len() != 0 { + t.Errorf("frame left %d trailing bytes", buf.Len()) + } +} + +func TestFrameSequenceOnOneConnection(t *testing.T) { + // One request then one response over the same buffer, like a + // connection carries them. + var buf bytes.Buffer + if err := WriteFrame(&buf, Request{V: 1}); err != nil { + t.Fatal(err) + } + if err := WriteFrame(&buf, Response{V: 1, Stdout: []byte("{}"), ExitCode: 2}); err != nil { + t.Fatal(err) + } + var req Request + if err := ReadFrame(&buf, &req); err != nil { + t.Fatal(err) + } + var resp Response + if err := ReadFrame(&buf, &resp); err != nil { + t.Fatal(err) + } + if resp.ExitCode != 2 || string(resp.Stdout) != "{}" { + t.Errorf("second frame wrong: %+v", resp) + } +} + +func TestReadFrameRejectsOversizedLength(t *testing.T) { + var prefix [4]byte + binary.BigEndian.PutUint32(prefix[:], MaxFrameBytes+1) + var out Request + err := ReadFrame(bytes.NewReader(prefix[:]), &out) + if err == nil || !strings.Contains(err.Error(), "cap") { + t.Errorf("oversized length prefix must be rejected before allocation: %v", err) + } +} + +func TestReadFrameTruncatedBody(t *testing.T) { + var buf bytes.Buffer + if err := WriteFrame(&buf, Request{V: 1}); err != nil { + t.Fatal(err) + } + trunc := buf.Bytes()[:buf.Len()-2] + var out Request + if err := ReadFrame(bytes.NewReader(trunc), &out); err == nil { + t.Errorf("truncated frame must error") + } +} + +func TestIdentity(t *testing.T) { + base := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}) + if len(base) != 16 { + t.Fatalf("identity length = %d, want 16", len(base)) + } + if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}); got != base { + t.Errorf("identity must be deterministic: %s vs %s", got, base) + } + if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/b.json"}); got == base { + t.Errorf("distinct configs must get distinct identities") + } + if got := Identity("/other/binary", []string{"--config=/a.json"}); got == base { + t.Errorf("distinct binaries must get distinct identities") + } + // The separator must keep the encoding injective across arg boundaries. + if Identity("/bin/x", []string{"ab", "c"}) == Identity("/bin/x", []string{"a", "bc"}) { + t.Errorf("arg boundaries must participate in the identity") + } +} + +func TestBuildStamp(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "bin") + if err := os.WriteFile(exe, []byte("v1"), 0o755); err != nil { + t.Fatal(err) + } + a := BuildStamp(exe) + if a == "" { + t.Fatalf("stamp empty for existing file") + } + if b := BuildStamp(exe); b != a { + t.Errorf("stamp must be stable for an unchanged file") + } + // Replacing the binary (new size or mtime) changes the stamp. + if err := os.WriteFile(exe, []byte("v2-bigger"), 0o755); err != nil { + t.Fatal(err) + } + if b := BuildStamp(exe); b == a { + t.Errorf("stamp must change when the executable is replaced") + } + if got := BuildStamp(filepath.Join(dir, "missing")); got != "" { + t.Errorf("missing file must stamp empty, got %q", got) + } +} + +func TestResolveDerivesEndpointAndLocks(t *testing.T) { + if runtime.GOOS != "windows" { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + } + addr, err := Resolve("/usr/local/bin/myhooks", []string{"--config=/a.json"}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if addr.ID == "" || addr.Endpoint == "" || addr.ServerLock == "" || addr.SpawnLock == "" { + t.Fatalf("incomplete address: %+v", addr) + } + if !strings.Contains(addr.Endpoint, addr.ID) { + t.Errorf("endpoint must embed the identity: %+v", addr) + } + if runtime.GOOS == "windows" { + if !strings.HasPrefix(addr.Endpoint, `\\.\pipe\agenthooks-`) { + t.Errorf("windows endpoint must be a named pipe: %s", addr.Endpoint) + } + } else { + if !strings.HasSuffix(addr.Endpoint, ".sock") || len(addr.Endpoint) > maxSocketPath { + t.Errorf("unix endpoint must be a bounded socket path: %s (%d bytes)", addr.Endpoint, len(addr.Endpoint)) + } + if fi, err := os.Stat(filepath.Dir(addr.ServerLock)); err != nil || fi.Mode().Perm() != 0o700 { + t.Errorf("state dir must exist with 0700: %v %v", fi, err) + } + } + if addr.ServerLock == addr.SpawnLock { + t.Errorf("server and spawn locks must differ: %+v", addr) + } + + other, err := Resolve("/usr/local/bin/myhooks", []string{"--config=/b.json"}) + if err != nil { + t.Fatal(err) + } + if other.Endpoint == addr.Endpoint { + t.Errorf("distinct configs must rendezvous on distinct endpoints") + } +} + +func TestSocketPathLengthFallsBackToTempDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("named pipes have no path-length constraint") + } + t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), strings.Repeat("deep", 30))) + addr, err := Resolve("/usr/local/bin/myhooks", nil) + if err != nil { + t.Fatal(err) + } + if len(addr.Endpoint) > maxSocketPath { + t.Errorf("endpoint exceeds sun_path budget: %s (%d bytes)", addr.Endpoint, len(addr.Endpoint)) + } +} + +func TestListenDialRoundTrip(t *testing.T) { + addr := testAddress(t) + ln, err := Listen(addr.Endpoint) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer func() { _ = ln.Close() }() + + done := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + done <- err + return + } + defer func() { _ = conn.Close() }() + var req Request + if err := ReadFrame(conn, &req); err != nil { + done <- err + return + } + done <- WriteFrame(conn, Response{V: 1, Stdout: []byte("ok"), ExitCode: 0}) + }() + + conn, err := Dial(addr.Endpoint, time.Second) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer func() { _ = conn.Close() }() + if err := WriteFrame(conn, Request{V: 1}); err != nil { + t.Fatal(err) + } + var resp Response + if err := ReadFrame(conn, &resp); err != nil { + t.Fatal(err) + } + if string(resp.Stdout) != "ok" { + t.Errorf("response = %+v", resp) + } + if err := <-done; err != nil { + t.Fatalf("server side: %v", err) + } +} + +func TestListenDetectsLiveServer(t *testing.T) { + addr := testAddress(t) + ln, err := Listen(addr.Endpoint) + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + // Keep the listener accepting so the probe's dial succeeds. + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + if _, err := Listen(addr.Endpoint); !errors.Is(err, ErrAlreadyRunning) { + t.Errorf("second Listen = %v, want ErrAlreadyRunning", err) + } +} + +func TestListenSweepsStaleSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("pipe instances die with their process; no stale files on windows") + } + addr := testAddress(t) + ln, err := Listen(addr.Endpoint) + if err != nil { + t.Fatal(err) + } + // Simulate a crash: the socket file stays behind with nobody listening. + if ul, ok := ln.(interface{ SetUnlinkOnClose(bool) }); ok { + ul.SetUnlinkOnClose(false) + } + _ = ln.Close() + if _, err := os.Stat(addr.Endpoint); err != nil { + t.Skipf("platform unlinked the socket on close: %v", err) + } + + ln2, err := Listen(addr.Endpoint) + if err != nil { + t.Fatalf("Listen must sweep a stale socket file: %v", err) + } + _ = ln2.Close() +} + +// testAddress resolves an Address rooted in a per-test state dir (unix) or +// with a unique identity (windows, where pipes are process-scoped anyway). +func testAddress(t *testing.T) Address { + t.Helper() + if runtime.GOOS != "windows" { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + } + addr, err := Resolve("/test/bin/agenthooks", []string{"--test-id=" + t.Name(), "--nonce=" + t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return addr +} diff --git a/telemetry/helpers_test.go b/telemetry/helpers_test.go new file mode 100644 index 0000000..4cc7fd7 --- /dev/null +++ b/telemetry/helpers_test.go @@ -0,0 +1,134 @@ +package telemetry + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "go.opentelemetry.io/otel/attribute" + sdklog "go.opentelemetry.io/otel/sdk/log" + + "github.com/speakeasy-api/agenthooks/internal/hookrecord" +) + +// testEndpoint is syntactically valid but never contacted: tests swap the +// exporter constructor for an in-memory collector. +const testEndpoint = "http://127.0.0.1:9/v1/logs" + +// memExporter collects exported records in memory. +type memExporter struct { + mu sync.Mutex + recs []sdklog.Record +} + +func (m *memExporter) Export(_ context.Context, records []sdklog.Record) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, rec := range records { + m.recs = append(m.recs, rec.Clone()) + } + return nil +} + +func (m *memExporter) Shutdown(context.Context) error { return nil } +func (m *memExporter) ForceFlush(context.Context) error { return nil } + +// newTestRecorder builds a Recorder through the real New path with the +// in-memory exporter substituted behind the constructor seam. +func newTestRecorder(t *testing.T, mutate func(*Config)) (*Recorder, *memExporter) { + t.Helper() + cfg := Config{Endpoint: testEndpoint} + if mutate != nil { + mutate(&cfg) + } + exp := &memExporter{} + orig := newExporter + newExporter = func(Config) (sdklog.Exporter, error) { return exp, nil } + t.Cleanup(func() { newExporter = orig }) + rec, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = rec.Shutdown(ctx) + }) + return rec, exp +} + +// flushed force-flushes the batch processor and returns everything the +// exporter has seen. +func flushed(t *testing.T, rec *Recorder, exp *memExporter) []sdklog.Record { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := rec.ForceFlush(ctx); err != nil { + t.Fatalf("ForceFlush: %v", err) + } + exp.mu.Lock() + defer exp.mu.Unlock() + return append([]sdklog.Record(nil), exp.recs...) +} + +var testReceiveTime = time.Unix(1700000000, 123456789) + +// toolPreRecord is a fully-populated tool.pre snapshot: MCP transport, +// timing. +func toolPreRecord() *hookrecord.Record { + return &hookrecord.Record{ + Provider: "claude-code", + Variant: "cli", + NativeName: "PreToolUse", + Kind: "tool.pre", + Time: testReceiveTime, + SessionID: "sess-123", + TurnID: "turn-7", + CWD: "/work/repo", + Model: "claude-sonnet-4-5", + Tool: &hookrecord.Tool{ + ID: "toolu_01SsRreQbJuFTsZS9ZszkzNR", + Name: "mcp__github__create_issue", + Canonical: "mcp", + Input: json.RawMessage(`{"title":"hi","token":"sk-abcdef1234567890"}`), + MCP: &hookrecord.MCP{ + Server: "github", + Tool: "create_issue", + URL: "https://user:hunter2@mcp.example.com/sse?api_key=abc123&x=1", + Command: "npx mcp-github --token=ghp_1234567890abcdef", + }, + }, + HookDurationMS: 12.5, + } +} + +// attrMap flattens an exported record's attributes into Go values for +// assertions. +func attrMap(rec sdklog.Record) map[string]any { + out := map[string]any{} + rec.WalkAttributes(func(kv attribute.KeyValue) bool { + out[string(kv.Key)] = attrValue(kv.Value) + return true + }) + return out +} + +func attrValue(v attribute.Value) any { + switch v.Type() { + case attribute.BOOL: + return v.AsBool() + case attribute.INT64: + return v.AsInt64() + case attribute.FLOAT64: + return v.AsFloat64() + case attribute.STRING: + return v.AsString() + } + return v.AsInterface() +} + +func bodyString(rec sdklog.Record) string { + return rec.Body().AsString() +} diff --git a/telemetry/identity.go b/telemetry/identity.go new file mode 100644 index 0000000..fdd0e5c --- /dev/null +++ b/telemetry/identity.go @@ -0,0 +1,117 @@ +package telemetry + +import ( + "crypto/rand" + "crypto/sha256" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/otel/trace" +) + +// Deterministic trace-context identity, reproducing gram's derivation +// byte-for-byte so agent-emitted and server-derived rows for the same event +// share trace IDs and existing joins keep working (RFC §4.4). The reference +// implementation is gram's canonicalTraceID / hashToolCallIDToTraceID / +// syntheticToolCallID (server/internal/hooks/ingest_hooks.go, +// server/internal/hooks/impl.go): +// +// 1. tool events with a per-call id → SHA-256(toolCallID)[:16] +// 2. tool events without one → SHA-256(len(sessionID) + "|" + +// sessionID + "|" + toolName)[:16] +// 3. everything else with a session id → SHA-256(sessionID)[:16] +// 4. last resort → random +// +// In this library rule 1 covers effectively every tool event — ToolCall.ID +// is the native id or the synthesized hook_synth_* id, the same value the +// relay sends today — but the full ladder is reproduced so any input hashes +// identically to gram's. + +// deriveTraceID returns the trace ID for an event and whether it was +// deterministically derived. ok is false only on the random fallback +// (empty session id on a non-tool event), which callers flag with +// agenthooks.session.unidentified=true. +func deriveTraceID(isTool bool, toolCallID, sessionID, toolName string) (trace.TraceID, bool) { + switch { + case isTool && toolCallID != "": + return traceIDFrom(toolCallID), true + case isTool && sessionID != "" && toolName != "": + // gram's syntheticToolCallID: the session id is length-prefixed so + // the encoding is injective even when session ids contain "|". + return traceIDFrom(strconv.Itoa(len(sessionID)) + "|" + sessionID + "|" + toolName), true + case sessionID != "": + return traceIDFrom(sessionID), true + } + var id trace.TraceID + _, _ = rand.Read(id[:]) + return id, false +} + +// traceIDFrom is gram's hashToolCallIDToTraceID: the first 16 bytes of the +// key's SHA-256. +func traceIDFrom(key string) trace.TraceID { + sum := sha256.Sum256([]byte(key)) + var id trace.TraceID + copy(id[:], sum[:16]) + return id +} + +// parseTraceparent parses a W3C trace-context traceparent value +// ("00-<32 hex trace id>-<16 hex parent span id>-<2 hex flags>"). ok is +// false for malformed values, the invalid all-zero IDs, and the reserved +// version ff. Only the trace ID and flags are consumed by this library: the +// parent span ID identifies the launcher's span, not this event, and each +// record keeps its own deterministic span identity (§4.4). +func parseTraceparent(v string) (trace.TraceID, trace.TraceFlags, bool) { + parts := strings.Split(strings.TrimSpace(v), "-") + if len(parts) < 4 || !isHexByte(parts[0]) || strings.EqualFold(parts[0], "ff") { + return trace.TraceID{}, 0, false + } + traceID, err := trace.TraceIDFromHex(strings.ToLower(parts[1])) + if err != nil || !traceID.IsValid() { + return trace.TraceID{}, 0, false + } + if _, err := trace.SpanIDFromHex(strings.ToLower(parts[2])); err != nil { + return trace.TraceID{}, 0, false + } + flags, err := strconv.ParseUint(parts[3], 16, 8) + if err != nil || len(parts[3]) != 2 { + return trace.TraceID{}, 0, false + } + return traceID, trace.TraceFlags(flags), true +} + +// isHexByte reports whether s is exactly two hex digits (a traceparent +// version field). +func isHexByte(s string) bool { + if len(s) != 2 { + return false + } + _, err := strconv.ParseUint(s, 16, 8) + return err == nil +} + +// deriveSpanID is deterministic per event: the first 8 bytes of the SHA-256 +// of "agenthooks|event" followed by the length-prefixed session ID, turn ID, +// native name, tool-call ID, and receive-time nanos. Length prefixes keep +// the encoding injective — field values are provider-controlled and may +// contain the separator — so two distinct events can never collide onto one +// key (the same reasoning as gram's syntheticToolCallID). Identical +// double-fires and spool replays still collide onto the same +// (trace_id, span_id) and dedupe at the storage layer; nothing joins on +// span ids today (gram's are random). +func deriveSpanID(sessionID, turnID, nativeName, toolCallID string, receive time.Time) trace.SpanID { + var key strings.Builder + key.WriteString("agenthooks|event") + for _, part := range []string{sessionID, turnID, nativeName, toolCallID, strconv.FormatInt(receive.UnixNano(), 10)} { + key.WriteString("|") + key.WriteString(strconv.Itoa(len(part))) + key.WriteString("|") + key.WriteString(part) + } + sum := sha256.Sum256([]byte(key.String())) + var id trace.SpanID + copy(id[:], sum[:8]) + return id +} diff --git a/telemetry/identity_test.go b/telemetry/identity_test.go new file mode 100644 index 0000000..bb8d2c4 --- /dev/null +++ b/telemetry/identity_test.go @@ -0,0 +1,193 @@ +package telemetry + +import ( + "encoding/hex" + "testing" + "time" + + "go.opentelemetry.io/otel/trace" +) + +// Fixture vectors computed by running gram's reference derivation — +// hashToolCallIDToTraceID (server/internal/hooks/impl.go), +// syntheticToolCallID (impl.go), canonicalTraceID (ingest_hooks.go) — over +// known inputs. The agent side must match byte-for-byte: the shadow-MCP +// provenance lookup joins recorded tool-call ids to telemetry rows via +// trace_id = hashToolCallIDToTraceID(recorded id), and dual-emit parity +// diffs join on (trace_id, event.name). +func TestDeriveTraceIDMatchesGramDerivation(t *testing.T) { + tests := []struct { + name string + isTool bool + toolCallID string + sessionID string + toolName string + want string + }{ + { + name: "rule 1: tool event with a native per-call id", + isTool: true, toolCallID: "toolu_01SsRreQbJuFTsZS9ZszkzNR", + sessionID: "sess-123", toolName: "Bash", + want: "cec2e4457e6d548f3c3d4cbc02b8f15e", + }, + { + name: "rule 1: synthesized hook_synth id hashes like any other id", + isTool: true, toolCallID: "hook_synth_0123456789abcdef", + sessionID: "sess-123", toolName: "Bash", + want: "bd9bd987e5c96f59bed9589e2f3fd1dc", + }, + { + name: "rule 2: tool event without an id uses the length-prefixed synthetic key", + isTool: true, toolCallID: "", + sessionID: "sess-123", toolName: "Bash", + want: "c2541915e6fe97a45eac686e137028be", // sha256("8|sess-123|Bash")[:16] + }, + { + name: "rule 2: injective encoding for session ids containing pipes", + isTool: true, toolCallID: "", + sessionID: "s|p", toolName: "mcp__srv__t", + want: "32bd97bcc6bc60e69915e141833a3105", // sha256("3|s|p|mcp__srv__t")[:16] + }, + { + name: "rule 3: non-tool events trace per session", + isTool: false, sessionID: "sess-123", + want: "c8d9cf2851b3e2ac6f87788b7745331a", + }, + { + name: "rule 3: uuid session", + isTool: false, sessionID: "049b8ff5-a44e-4e0c-8b9e-a9ecd221ac4a", + want: "93ef04f4a09db03df4c60d025c294339", + }, + { + name: "rule 3: tool event with neither id nor tool name falls back to the session hash", + isTool: true, toolCallID: "", sessionID: "sess-123", toolName: "", + want: "c8d9cf2851b3e2ac6f87788b7745331a", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, ok := deriveTraceID(tt.isTool, tt.toolCallID, tt.sessionID, tt.toolName) + if !ok { + t.Fatalf("expected deterministic derivation") + } + if got := hex.EncodeToString(id[:]); got != tt.want { + t.Errorf("trace id = %s, want %s (gram derivation)", got, tt.want) + } + }) + } +} + +func TestDeriveTraceIDRandomFallback(t *testing.T) { + a, ok := deriveTraceID(false, "", "", "") + if ok { + t.Fatalf("empty session must not claim a deterministic id") + } + if !a.IsValid() { + t.Fatalf("random fallback produced an invalid trace id") + } + b, _ := deriveTraceID(false, "", "", "") + if a == b { + t.Errorf("random fallback repeated a trace id") + } +} + +func TestParseTraceparent(t *testing.T) { + traceID, flags, ok := parseTraceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + if !ok || traceID.String() != "4bf92f3577b34da6a3ce929d0e0e4736" || flags != 0x01 { + t.Errorf("valid traceparent: trace=%s flags=%v ok=%v", traceID, flags, ok) + } + // A valid future version is accepted per W3C forward compatibility. + if _, _, ok := parseTraceparent("cc-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); !ok { + t.Errorf("future version cc must parse") + } + for _, bad := range []string{ + "", + "not-a-traceparent", + "00-00000000000000000000000000000000-00f067aa0ba902b7-01", // all-zero trace id + "00-4bf92f3577b34da6a3ce929d0e0e4736-zzzz67aa0ba902b7-01", // bad span hex + "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", // reserved version + "FF-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", // reserved version, uppercase + "zz-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", // non-hex version + "0-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", // short version + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7", // missing flags + } { + if _, _, ok := parseTraceparent(bad); ok { + t.Errorf("parseTraceparent(%q) accepted", bad) + } + } +} + +func TestRecorderHonorsTraceparentOnlyWhenOptedIn(t *testing.T) { + t.Setenv("TRACEPARENT", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + + // Default: deterministic identity, TRACEPARENT ignored. + rec, exp := newTestRecorder(t, nil) + if err := rec.RecordHook(toolPreRecord()); err != nil { + t.Fatal(err) + } + records := flushed(t, rec, exp) + traceID := records[0].TraceID() + deterministic := hex.EncodeToString(traceID[:]) + if deterministic == "4bf92f3577b34da6a3ce929d0e0e4736" { + t.Fatalf("TRACEPARENT must be ignored without HonorTraceparent") + } + if _, present := attrMap(records[0])["agenthooks.deterministic_trace_id"]; present { + t.Errorf("no deterministic_trace_id attribute when the trace-context field carries it") + } + + // Opted in: the ambient trace ID takes the trace-context field, the + // deterministic ID moves to an attribute, the span ID stays derived. + optIn, expIn := newTestRecorder(t, func(cfg *Config) { cfg.HonorTraceparent = true }) + if err := optIn.RecordHook(toolPreRecord()); err != nil { + t.Fatal(err) + } + records2 := flushed(t, optIn, expIn) + trace2 := records2[0].TraceID() + if got := hex.EncodeToString(trace2[:]); got != "4bf92f3577b34da6a3ce929d0e0e4736" { + t.Errorf("honored trace id = %s, want the ambient TRACEPARENT trace", got) + } + if got := attrMap(records2[0])["agenthooks.deterministic_trace_id"]; got != deterministic { + t.Errorf("deterministic trace id attribute = %v, want %s", got, deterministic) + } + if records2[0].SpanID() != records[0].SpanID() { + t.Errorf("span identity must stay deterministic under TRACEPARENT") + } + if records2[0].TraceFlags() != 0x01 { + t.Errorf("sampled flag lost: %d", records2[0].TraceFlags()) + } + + // Opted in but no ambient context: deterministic identity as usual. + t.Setenv("TRACEPARENT", "") + plain, expPlain := newTestRecorder(t, func(cfg *Config) { cfg.HonorTraceparent = true }) + if err := plain.RecordHook(toolPreRecord()); err != nil { + t.Fatal(err) + } + records3 := flushed(t, plain, expPlain) + trace3 := records3[0].TraceID() + if got := hex.EncodeToString(trace3[:]); got != deterministic { + t.Errorf("without TRACEPARENT the deterministic id stays: %s", got) + } +} + +func TestDeriveSpanIDDeterministic(t *testing.T) { + at := time.Unix(1700000000, 123456789) + a := deriveSpanID("sess-1", "turn-1", "PreToolUse", "toolu_1", at) + if b := deriveSpanID("sess-1", "turn-1", "PreToolUse", "toolu_1", at); a != b { + t.Errorf("identical inputs must collide onto one span id: %s vs %s", a, b) + } + variants := []struct { + name string + got trace.SpanID + }{ + {"session", deriveSpanID("sess-2", "turn-1", "PreToolUse", "toolu_1", at)}, + {"turn", deriveSpanID("sess-1", "turn-2", "PreToolUse", "toolu_1", at)}, + {"native name", deriveSpanID("sess-1", "turn-1", "PostToolUse", "toolu_1", at)}, + {"tool id", deriveSpanID("sess-1", "turn-1", "PreToolUse", "toolu_2", at)}, + {"time", deriveSpanID("sess-1", "turn-1", "PreToolUse", "toolu_1", at.Add(time.Nanosecond))}, + } + for _, v := range variants { + if v.got == a { + t.Errorf("changing %s must change the span id", v.name) + } + } +} diff --git a/telemetry/record.go b/telemetry/record.go new file mode 100644 index 0000000..12ef385 --- /dev/null +++ b/telemetry/record.go @@ -0,0 +1,356 @@ +package telemetry + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/trace" + + "github.com/speakeasy-api/agenthooks/internal/hookrecord" +) + +// maxContentBytes bounds each captured content value (prompt text, tool IO, +// assistant message). Longer values are truncated and the record flagged +// agenthooks.record.truncated=true, keeping single records under the spool's +// per-record cap. +const maxContentBytes = 256 << 10 + +// buildRecord assembles the OTel log record for one hook event — a wide +// event whose attribute keys reconcile with what gram's pipeline derives +// from hook payloads today (RFC §4.3) — plus the synthetic span context +// carrying the deterministic trace/span identity (§4.4). +func (r *Recorder) buildRecord(hr *hookrecord.Record) (log.Record, trace.SpanContext) { + b := &recordBuilder{redactor: r.cfg.Redactor} + + var rec log.Record + rec.SetTimestamp(hr.Time) + rec.SetEventName(eventName(hr)) + rec.SetSeverity(severityOf(hr)) + rec.SetSeverityText(severityText(severityOf(hr))) + + // Identity and classification. The event name is deliberately emitted + // twice: as the top-level EventName field (current OTel semconv — the + // event.name attribute is deprecated in its favor) and as the + // event.name attribute, because gram's OTLP/JSON ingest schema has no + // eventName field and its URN deriver reads only the attribute. + b.str("gram.hook.event", hr.NativeName) + b.str("gram.hook.source", hr.Provider) + b.str("event.name", eventName(hr)) + b.str("gram.event.origin", "agenthooks") + b.str("agenthooks.provider", hr.Provider) + b.str("agenthooks.variant", hr.Variant) + b.str("session.id", hr.SessionID) + b.str("agenthooks.turn.id", hr.TurnID) + b.str("gen_ai.response.model", hr.Model) + b.str("user.email", hr.UserEmail) + if hr.Backfilled { + b.flag("agenthooks.event.backfilled") + } + b.str("agenthooks.subagent.id", hr.SubagentID) + b.str("agenthooks.subagent.type", hr.SubagentType) + // Semconv twin: gen_ai.agent.name is the standard home for a + // human-readable agent name; the subagent type is the closest fit. + b.str("gen_ai.agent.name", hr.SubagentType) + b.float("agenthooks.hook.duration_ms", hr.HookDurationMS) + + // Health signals only — the record is observational and never carries + // the enforcement decision (the enforcement backend's decision-time log + // is the sole record of decisions, RFC §5.1). + b.str("agenthooks.handler.error", hr.HandlerErr) + // error.type (stable semconv) classifies genuine failures with a + // documented low-cardinality value; policy denies are successful + // enforcement, not errors, and do not set it. + b.str("error.type", errorType(hr)) + + if t := hr.Tool; t != nil { + b.str("gen_ai.tool.call.id", t.ID) + b.str("gram.tool.name", t.Name) + // Semconv twin of the gram-dialect key, for collector/vendor + // interop. + b.str("gen_ai.tool.name", t.Name) + b.str("agenthooks.tool.canonical", t.Canonical) + if t.Synthesized { + b.flag("agenthooks.tool.synthesized") + } + if t.DurationMS != nil { + b.float("agenthooks.tool.duration_ms", *t.DurationMS) + } + b.str("gram.hook.error", t.Error) + if len(t.Input) > 0 { + if r.cfg.Capture >= CaptureContent { + b.content("gen_ai.tool.call.arguments", string(t.Input)) + } else { + b.digest("agenthooks.tool.input", t.Input) + } + } + if len(t.Output) > 0 { + if r.cfg.Capture >= CaptureContent { + b.content("gen_ai.tool.call.result", string(t.Output)) + } else { + b.digest("agenthooks.tool.output", t.Output) + } + } + if m := t.MCP; m != nil { + b.str("gram.mcp.match", mcpMatch(m)) + b.str("gram.mcp.server_url", redactURL(m.URL)) + b.str("agenthooks.mcp.server", m.Server) + b.str("agenthooks.mcp.tool", m.Tool) + b.str("agenthooks.mcp.command", redactCommand(m.Command)) + if m.FromConfig { + b.flag("agenthooks.mcp.from_config") + } + } + } + + if hr.Kind == "prompt.submitted" { + // Sizes and digests stand in for text at the default capture level: + // enough for volume/shape analytics and joins against the + // enforcement side, which still sees the full decision inputs. + b.digest("agenthooks.prompt", []byte(hr.Prompt)) + } + if hr.FinalMessage != "" { + b.int("agenthooks.message.length", len(hr.FinalMessage)) + } + if hr.LoopCount > 0 { + b.int("agenthooks.loop_count", hr.LoopCount) + } + if u := hr.Usage; u != nil { + b.intp("gen_ai.usage.input_tokens", u.InputTokens) + b.intp("gen_ai.usage.output_tokens", u.OutputTokens) + b.intp("gen_ai.usage.cache_read.input_tokens", u.CacheReadTokens) + b.intp("gen_ai.usage.cache_creation.input_tokens", u.CacheWriteTokens) + if u.Cost != nil { + b.float("gen_ai.usage.cost", *u.Cost) + } + } + b.str("agenthooks.notification.message", hr.Notification) + b.str("agenthooks.session.source", hr.SessionSource) + b.str("agenthooks.session.end_reason", hr.SessionEndReason) + b.str("agenthooks.compact.trigger", hr.CompactTrigger) + if r.cfg.Capture >= CaptureContent { + // Paths are location-revealing, so they ride only at the elevated + // capture level — same posture as content. + b.str("agenthooks.session.cwd", hr.CWD) + b.str("agenthooks.file.path", hr.FilePath) + } + + // Body: "Hook: ", matching the synthetic + // gram.log.body the backend writes for derived rows today. At + // CaptureContent, prompt and final-message records carry the text as + // the body instead — the established body destination. + body := b.redact("body", "Hook: "+nativeOrKind(hr)) + if r.cfg.Capture >= CaptureContent { + switch { + case hr.Kind == "prompt.submitted" && hr.Prompt != "": + body = b.contentValue("body", hr.Prompt) + case hr.FinalMessage != "": + body = b.contentValue("body", hr.FinalMessage) + } + } + rec.SetBody(attribute.StringValue(body)) + + // Deterministic identity, injected via a synthetic span context on the + // emit context — no tracer, no spans started (§4.4). With + // HonorTraceparent and an ambient TRACEPARENT, the launcher's trace ID + // takes the trace-context field and the deterministic ID rides as an + // attribute so hash-derived joins keep working; the span ID stays + // deterministic per event in both cases (replay dedupe relies on it). + toolCallID, toolName := "", "" + if hr.Tool != nil { + toolCallID, toolName = hr.Tool.ID, hr.Tool.Name + } + traceID, derived := deriveTraceID(hr.Tool != nil, toolCallID, hr.SessionID, toolName) + if !derived { + b.flag("agenthooks.session.unidentified") + } + scc := trace.SpanContextConfig{ + TraceID: traceID, + SpanID: deriveSpanID(hr.SessionID, hr.TurnID, hr.NativeName, toolCallID, hr.Time), + } + if r.ambientOK { + scc.TraceID = r.ambientTrace + scc.TraceFlags = r.ambientFlags + if derived { + b.str("agenthooks.deterministic_trace_id", traceID.String()) + } + } + if b.truncated { + b.flag("agenthooks.record.truncated") + } + rec.AddAttributes(b.attrs...) + return rec, trace.NewSpanContext(scc) +} + +// mcpMatch mirrors gram's gram.mcp.match semantics: the server-level +// identifier the matcher resolved — an HTTP/SSE URL, a stdio command, or (as +// fallback) the mcp____ prefix from the tool name — transport +// redacted before it leaves the machine. +func mcpMatch(m *hookrecord.MCP) string { + switch { + case m.URL != "": + return redactURL(m.URL) + case m.Command != "": + return redactCommand(m.Command) + case m.Server != "": + return "mcp__" + m.Server + "__" + } + return "" +} + +func nativeOrKind(hr *hookrecord.Record) string { + if hr.NativeName != "" { + return hr.NativeName + } + return hr.Kind +} + +// eventName is the record's unified event identity — the top-level +// EventName field and the event.name attribute, which gram's URN deriver +// turns into urn:telemetry:agent_hook:log:. Mapped kinds pass +// through as-is (tool.pre, agent.stop, ...). Unmapped natives (kind +// "other") would all collapse into one URN type, so they classify as +// "other." with the native lowercased and folded to the +// URN-friendly [a-z0-9._-] alphabet; gram.hook.event carries the native +// name verbatim alongside. +func eventName(hr *hookrecord.Record) string { + if hr.Kind != "other" || hr.NativeName == "" { + return hr.Kind + } + return "other." + urnSafe(hr.NativeName) +} + +func urnSafe(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', + r == '.', r == '_', r == '-': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return b.String() +} + +// severityOf maps the record's health signals onto log severity: ERROR for +// handler/pipeline failures and failed tool executions, INFO for everything +// else. Decision outcomes never influence severity — records are +// observational, and a deny is successful enforcement, not a fault in the +// hook rail. Gram auto-infers severity when unset, so this mapping only +// refines it. +func severityOf(hr *hookrecord.Record) log.Severity { + if hr.HandlerErr != "" || (hr.Tool != nil && hr.Tool.Failed) { + return log.SeverityError + } + return log.SeverityInfo +} + +// errorType maps genuine failures onto the stable error.type semconv +// attribute. Values are low-cardinality and documented here: "handler_error" +// when the handler pipeline failed, "tool_error" when the tool execution the +// event reports failed. Empty (attribute omitted) otherwise. +func errorType(hr *hookrecord.Record) string { + switch { + case hr.HandlerErr != "": + return "handler_error" + case hr.Tool != nil && hr.Tool.Failed: + return "tool_error" + } + return "" +} + +func severityText(s log.Severity) string { + if s == log.SeverityError { + return "ERROR" + } + return "INFO" +} + +// recordBuilder accumulates attributes, skipping empty values and running +// every string value through the consumer's Redactor before it can touch +// disk (the built-in transport/content redaction runs before that, at the +// call sites that carry credential-prone values). +type recordBuilder struct { + attrs []attribute.KeyValue + redactor func(key, value string) string + truncated bool +} + +func (b *recordBuilder) redact(key, value string) string { + if b.redactor == nil || value == "" { + return value + } + return b.redactor(key, value) +} + +func (b *recordBuilder) str(key, value string) { + if value == "" { + return + } + b.attrs = append(b.attrs, attribute.String(key, b.redact(key, value))) +} + +// flag attaches a true-valued marker attribute; false markers are expressed +// by omission. +func (b *recordBuilder) flag(key string) { + b.attrs = append(b.attrs, attribute.Bool(key, true)) +} + +func (b *recordBuilder) int(key string, v int) { + b.attrs = append(b.attrs, attribute.Int(key, v)) +} + +func (b *recordBuilder) intp(key string, v *int) { + if v == nil { + return + } + b.attrs = append(b.attrs, attribute.Int(key, *v)) +} + +func (b *recordBuilder) float(key string, v float64) { + b.attrs = append(b.attrs, attribute.Float64(key, v)) +} + +// digest stands in for content at the default capture level: byte length +// plus SHA-256, under .length / .sha256. +func (b *recordBuilder) digest(prefix string, content []byte) { + sum := sha256.Sum256(content) + b.attrs = append(b.attrs, + attribute.Int(prefix+".length", len(content)), + attribute.String(prefix+".sha256", hex.EncodeToString(sum[:])), + ) +} + +// content attaches a captured content value: built-in credential redaction, +// then the consumer's Redactor, then the per-value truncation cap. +func (b *recordBuilder) content(key, value string) { + if value == "" { + return + } + b.attrs = append(b.attrs, attribute.String(key, b.contentValue(key, value))) +} + +func (b *recordBuilder) contentValue(key, value string) string { + v := b.redact(key, redactContent(value)) + if len(v) > maxContentBytes { + v = truncateUTF8(v, maxContentBytes) + b.truncated = true + } + return v +} + +// truncateUTF8 cuts s to at most n bytes without splitting a rune. +func truncateUTF8(s string, n int) string { + if len(s) <= n { + return s + } + for n > 0 && s[n]&0xC0 == 0x80 { + n-- + } + return s[:n] +} diff --git a/telemetry/redact.go b/telemetry/redact.go new file mode 100644 index 0000000..910c024 --- /dev/null +++ b/telemetry/redact.go @@ -0,0 +1,209 @@ +package telemetry + +import ( + "net/url" + "regexp" + "strings" +) + +// Built-in transport-credential redaction, ported from the proven relay +// implementation (gram/hooks/relay/redact.go). Credential material can ride +// along in MCP server transport — basic-auth userinfo and secret-named query +// parameters in a URL, or secret flags/tokens in a stdio launch command — +// and in captured content. Everything is redacted before touching the disk +// spool; host, path, and non-secret arguments survive so transport identity +// stays matchable server-side. The consumer's Redactor runs after this. + +var ( + secretParamRE = regexp.MustCompile(`(?i)(key|token|secret|password|passwd|credential|auth)`) + signatureParamRE = regexp.MustCompile(`(?i)^(sig|signature|x-amz-signature|x-goog-signature)$`) +) + +// redactURL strips basic-auth userinfo and fragments and masks secret-named +// query values while preserving the host, path, and benign parameters. An +// unparseable URL could hide credentials anywhere, so it is dropped entirely +// (matchable identity is already hopeless for it) — the one divergence from +// the relay port, which returns such input untouched. +func redactURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "***" + } + u.User = nil + u.Fragment = "" + if u.RawQuery != "" { + q := u.Query() + for k := range q { + if secretParamRE.MatchString(k) || signatureParamRE.MatchString(k) { + q.Set(k, "***") + } + } + u.RawQuery = q.Encode() + } + return u.String() +} + +var ( + secretEnvAssignRE = regexp.MustCompile(`(?i)^[A-Za-z_][A-Za-z0-9_]*(key|token|secret|password|passwd|credential|auth)[A-Za-z0-9_]*=`) + secretAssignRE = regexp.MustCompile(`(?i)^--?[^=]*(key|token|secret|password|passwd|credential|bearer|auth)[^=]*=`) + secretFlagRE = regexp.MustCompile(`(?i)^--?[^=]*(key|token|secret|password|passwd|credential|bearer|auth)[^=]*$`) + // The optional prefix covers --header=NAME:..., and curl's attached + // short-option form (-HNAME:... after quote stripping). Alongside the + // known names, any header name carrying a secret keyword counts + // (api-key, X-Auth-Token) — guarded at the call site against URL tokens, + // whose scheme can carry a keyword too (oauth://). + secretHeaderRE = regexp.MustCompile(`(?i)^(--?[^=]*=|-[a-z])?(authorization|proxy-authorization|cookie|x-api-key) *:`) + genericSecretHeaderRE = regexp.MustCompile(`(?i)^(--?[^=]*=|-[a-z])?[a-z0-9-]*(key|token|secret|password|passwd|credential|auth)[a-z0-9-]* *:`) + envAssignRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) + // authSchemeRE matches auth scheme words that precede the credential in a + // header value ("Authorization: Token abc"); the scheme is not the secret. + authSchemeRE = regexp.MustCompile(`(?i)^(bearer|basic|token|digest|negotiate|ntlm|dpop|oauth|hawk|apikey)$`) + tokenPrefixRE = regexp.MustCompile(`(?i)://[^/@]*@|^(sk-|ghp_|gho_|github_pat_|xox[a-z]-|glpat-)`) +) + +// redactCommand masks secret flag values and inline tokens in a stdio MCP +// launch command. Tokenization splits on spaces and cannot see through shell +// quoting; the patterns cover the common unquoted shapes, so a repointed +// server keeps a stable redacted identity. +func redactCommand(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + raw = strings.ReplaceAll(raw, `"`, "") + raw = strings.ReplaceAll(raw, "'", "") + fields := strings.Fields(raw) + out := make([]string, 0, len(fields)) + maskNext := false + // schemeNext marks a pending mask that came from a header, whose value may + // open with an auth-scheme word ("authorization: bearer TOKEN"); the + // scheme is not the secret, so the mask rides through to the credential. + // A secret flag's value gets no such exception — it is the secret even + // when it collides with a scheme word. + schemeNext := false + // cookieNext continues a masked multi-part cookie ("Cookie: sid=abc; + // csrf=def") whose fragments tokenize separately, but only through + // cookie-pair-shaped tokens, so a trailing ';' on the last fragment + // cannot swallow an unrelated following argument. cookiePending scopes + // the continuation to cookie headers: other secret values ending in ';' + // do not fragment. + cookieNext := false + cookiePending := false + for _, f := range fields { + if maskNext { + if schemeNext && authSchemeRE.MatchString(f) { + out = append(out, f) + schemeNext = false + continue + } + out = append(out, "***") + cookieNext = cookiePending && strings.HasSuffix(f, ";") + maskNext, schemeNext, cookiePending = false, false, false + continue + } + if cookieNext { + cookieNext = false + if strings.Contains(f, "=") && !strings.Contains(f, "://") { + out = append(out, "***") + cookieNext = strings.HasSuffix(f, ";") + continue + } + // Not a cookie pair: the value ended at the ';'. + } + switch { + case secretEnvAssignRE.MatchString(f): + i := strings.IndexByte(f, '=') + out = append(out, f[:i+1]+"***") + case secretAssignRE.MatchString(f): + if i := strings.IndexByte(f, '='); i >= 0 { + out = append(out, f[:i+1]+"***") + } else { + out = append(out, "***") + } + case secretFlagRE.MatchString(f): + out = append(out, f) + maskNext = true + // A token is URL-shaped only when its first colon opens "://"; a + // keyword-bearing scheme (oauth://) must not read as a header, and a + // no-space header whose value is a URL must not read as one. + case secretHeaderRE.MatchString(f) || (strings.Index(f, "://") != strings.IndexByte(f, ':') && genericSecretHeaderRE.MatchString(f)): + // `--header "X-API-Key: abc"` tokenizes the value into the next + // field after quote stripping; a header with nothing after its + // colon — or with only an auth scheme, as in + // "Authorization:Bearer TOKEN" — must keep the mask pending for + // the credential that follows. + i := strings.IndexByte(f, ':') + value := strings.TrimSpace(f[i+1:]) + isCookie := strings.Contains(strings.ToLower(f[:i]), "cookie") + switch { + case value == "": + out = append(out, f[:i]+":") + maskNext, schemeNext = true, true + cookiePending = isCookie + case authSchemeRE.MatchString(value): + out = append(out, f[:i]+": "+value) + maskNext = true + default: + out = append(out, f[:i]+": ***") + // A no-space header ("Cookie:sid=abc; csrf=def") carries the + // first fragment in this token; a ';'-terminated value means + // more fragments follow. + cookieNext = isCookie && strings.HasSuffix(value, ";") + } + case strings.EqualFold(f, "bearer"): + out = append(out, f) + maskNext = true + case envAssignRE.MatchString(f) && tokenPrefixRE.MatchString(f[strings.IndexByte(f, '=')+1:]): + // An env assignment whose name has no secret keyword can still + // carry a recognizable credential ("GITHUB_PAT=github_pat_...", + // a userinfo URL); the value's shape gives it away. + i := strings.IndexByte(f, '=') + out = append(out, f[:i+1]+"***") + case strings.Contains(f, "://"): + // A server URL passed as an argument (npx mcp-remote ) can + // carry credentials in userinfo or its query string; structured + // redaction keeps the host and path matchable. Checked before + // tokenPrefixRE so userinfo URLs are stripped, not swallowed + // whole. A token the parser cannot make sense of could hide + // credentials anywhere, so it is dropped entirely. + if _, err := url.Parse(f); err != nil { + out = append(out, "***") + } else { + out = append(out, redactURL(f)) + } + case tokenPrefixRE.MatchString(f): + out = append(out, "***") + default: + out = append(out, f) + } + } + return strings.Join(out, " ") +} + +// Content redaction is conservative on purpose: prompt text, tool IO, and +// assistant messages are free-form, so only unambiguous credential shapes +// are masked in place, never restructured. +var ( + contentTokenRE = regexp.MustCompile(`\b(?:sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|xox[a-z]-[A-Za-z0-9-]{8,}|glpat-[A-Za-z0-9_-]{8,})`) + contentBearerRE = regexp.MustCompile(`(?i)\b(bearer|authorization: *(?:bearer|basic|token)?) +[A-Za-z0-9._~+/=-]{8,}`) + contentEnvRE = regexp.MustCompile(`(?i)\b([A-Za-z_][A-Za-z0-9_]*(?:key|token|secret|password|passwd|credential|auth)[A-Za-z0-9_]*=)[^\s"']+`) + contentUserinfoRE = regexp.MustCompile(`([a-z][a-z0-9+.-]*://)[^/\s@]+@`) +) + +// redactContent masks unambiguous credential material inside free-form +// captured content (token-prefixed secrets, bearer credentials, secret-named +// env assignments, URL userinfo) while leaving the surrounding text intact. +func redactContent(s string) string { + if s == "" { + return "" + } + s = contentTokenRE.ReplaceAllString(s, "***") + s = contentBearerRE.ReplaceAllString(s, "$1 ***") + s = contentEnvRE.ReplaceAllString(s, "$1***") + s = contentUserinfoRE.ReplaceAllString(s, "$1***@") + return s +} diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go new file mode 100644 index 0000000..0c2c601 --- /dev/null +++ b/telemetry/telemetry.go @@ -0,0 +1,254 @@ +// Package telemetry emits one OpenTelemetry log record (a wide event) per +// hook event, batched in process and shipped over OTLP/HTTP (gzip) in the +// background by the OTel SDK's batch processor. +// +// Wire the recorder into a Runner with agenthooks.WithTelemetry: +// +// rec, err := telemetry.New(telemetry.Config{ +// Endpoint: "https://app.getgram.ai/rpc/hooks.otel/v1/logs", +// Headers: map[string]string{"Gram-Key": key, "Gram-Project": project}, +// }) +// if err != nil { ... } +// r := agenthooks.New(agenthooks.WithTelemetry(rec)) +// +// The recorder is built for the hook server (`mybinary agenthooks server`), +// the long-lived process the client/server architecture runs the pipeline +// in: batches ship on the processor's schedule while the server lives, and +// the server flushes on idle shutdown, SIGINT/SIGTERM, and version-upgrade +// exits via Shutdown. Records buffered in a process that dies without a +// flush are lost — telemetry is best-effort by design; the enforcement rail +// carries the decisions. +// +// The feature is opt-in and fail-open by construction: without the option +// nothing changes; with it, recorder failures degrade to a logged warning +// and never affect the decision path. Any OTLP logs endpoint works; gram is +// one consumer configuration. +package telemetry + +import ( + "context" + "errors" + "net/url" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + "go.opentelemetry.io/otel/log" + sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/trace" + + "github.com/speakeasy-api/agenthooks/internal/hookrecord" +) + +// CaptureLevel selects how much event content leaves the process. +type CaptureLevel int + +const ( + // CaptureAttributes (the default) emits structured attributes only: no + // prompt text, no tool input/output bodies, no assistant messages, no + // cwd. Sizes and SHA-256 digests stand in for content. + CaptureAttributes CaptureLevel = iota + // CaptureContent additionally records prompt text, tool input/output, + // and assistant messages — after the built-in transport-credential + // redaction and the consumer's Redactor. + CaptureContent +) + +// Config configures a Recorder. Misconfiguration fails at New — construction +// time, in the consumer's control — not at event time. +type Config struct { + // Endpoint is the OTLP/HTTP logs endpoint, e.g. + // "https://app.getgram.ai/rpc/hooks.otel/v1/logs" or any collector's + // "/v1/logs". Required. + Endpoint string + // Headers are added to every export request (auth: e.g. Gram-Key, + // Gram-Project). + Headers map[string]string + // Resource attributes merged over the library defaults (service.name, + // service.version, host.name, os.type, host.arch, + // gram.event.origin=agenthooks, ...). + Resource map[string]string + + // Capture selects the content level. Default: CaptureAttributes. + Capture CaptureLevel + // Redactor rewrites attribute and body values before they enter the + // export pipeline. It is called with the attribute key (the body uses + // key "body") and the value, and returns the replacement. The library + // always applies its built-in transport-credential redaction (URLs, + // commands, token-shaped values) first; Redactor runs after it. + Redactor func(key string, value string) string + + // HonorTraceparent opts into W3C trace-context parenting: when the + // recording process carries a valid TRACEPARENT environment variable + // (read once at construction — in the client/server architecture that + // is the server process, whose environment came from the client that + // spawned it), its trace ID and sampled flag replace the deterministic + // trace ID on emitted records, and the deterministic ID moves to the + // agenthooks.deterministic_trace_id attribute so hash-derived joins + // (e.g. gram's) still work. Off by default: the deterministic + // derivation is what backend joins key on, and an ambient trace ID + // would silently regroup records. Each record keeps its own + // deterministic span ID either way. + HonorTraceparent bool +} + +// Recorder captures hook events as OTel log records into an in-process +// batch-export pipeline. Construct with New; install with +// agenthooks.WithTelemetry. Methods are safe for concurrent use. +type Recorder struct { + cfg Config + provider *sdklog.LoggerProvider + logger log.Logger + + // Ambient W3C trace context, read once at construction when + // Config.HonorTraceparent is set and TRACEPARENT is valid. + ambientTrace trace.TraceID + ambientFlags trace.TraceFlags + ambientOK bool +} + +// scopeName identifies this package as the instrumentation scope of every +// emitted record. +const scopeName = "github.com/speakeasy-api/agenthooks/telemetry" + +// newExporter builds the OTLP/HTTP exporter; a variable so tests can +// substitute an in-memory exporter behind the real construction path. +var newExporter = func(cfg Config) (sdklog.Exporter, error) { + return otlploghttp.New(context.Background(), + otlploghttp.WithEndpointURL(cfg.Endpoint), + otlploghttp.WithHeaders(cfg.Headers), + otlploghttp.WithCompression(otlploghttp.GzipCompression), + ) +} + +// New builds a Recorder: it validates the endpoint and stands up the +// sdk/log pipeline — a batch processor feeding a gzip-compressed OTLP/HTTP +// exporter. Construction performs no network I/O; the first export happens +// on the processor's schedule (or on ForceFlush/Shutdown). +func New(cfg Config) (*Recorder, error) { + endpoint := strings.TrimSpace(cfg.Endpoint) + if endpoint == "" { + return nil, errors.New("telemetry: Config.Endpoint is required") + } + u, err := url.Parse(endpoint) + if err != nil { + return nil, errors.New("telemetry: Config.Endpoint is not a valid URL: " + err.Error()) + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, errors.New("telemetry: Config.Endpoint must be an absolute http(s) URL") + } + cfg.Endpoint = endpoint + + res, err := buildResource(cfg.Resource) + if err != nil { + return nil, errors.New("telemetry: building resource: " + err.Error()) + } + exporter, err := newExporter(cfg) + if err != nil { + return nil, errors.New("telemetry: building exporter: " + err.Error()) + } + provider := sdklog.NewLoggerProvider( + sdklog.WithResource(res), + sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), + ) + r := &Recorder{ + cfg: cfg, + provider: provider, + logger: provider.Logger(scopeName, log.WithInstrumentationVersion(agenthooksVersion())), + } + if cfg.HonorTraceparent { + r.ambientTrace, r.ambientFlags, r.ambientOK = parseTraceparent(os.Getenv("TRACEPARENT")) + } + return r, nil +} + +// RecordHook captures one hook event at end of processing: it builds the +// observational log record (event identity, payload shape, hook-rail health +// — never the enforcement decision), injects the deterministic trace/span +// identity via a synthetic span context on the emit context, and enqueues +// the record on the batch processor — no synchronous network I/O. +// +// RecordHook is invoked by the runner tap agenthooks.WithTelemetry installs. +// Its parameter type lives in an internal package, so it is not callable by +// external consumers. +func (r *Recorder) RecordHook(hr *hookrecord.Record) error { + rec, sc := r.buildRecord(hr) + ctx := trace.ContextWithSpanContext(context.Background(), sc) + r.logger.Emit(ctx, rec) + return nil +} + +// ForceFlush exports every buffered record without stopping the pipeline. +func (r *Recorder) ForceFlush(ctx context.Context) error { + return r.provider.ForceFlush(ctx) +} + +// Shutdown flushes buffered records and stops the pipeline; further +// RecordHook calls become no-ops. The hook server calls it on idle +// shutdown, on SIGINT/SIGTERM, and before a version-upgrade exit. +func (r *Recorder) Shutdown(ctx context.Context) error { + return r.provider.Shutdown(ctx) +} + +// buildResource merges the library defaults with the consumer's overrides. +func buildResource(extra map[string]string) (*resource.Resource, error) { + attrs := []attribute.KeyValue{ + attribute.String("service.name", serviceName()), + attribute.String("os.type", runtime.GOOS), + attribute.String("host.arch", runtime.GOARCH), + attribute.String("gram.event.origin", "agenthooks"), + attribute.String("agenthooks.version", agenthooksVersion()), + } + if host, err := os.Hostname(); err == nil && host != "" { + attrs = append(attrs, attribute.String("host.name", host)) + } + if v := binaryVersion(); v != "" { + attrs = append(attrs, attribute.String("service.version", v)) + } + for k, v := range extra { + attrs = append(attrs, attribute.String(k, v)) + } + return resource.Merge(resource.Default(), resource.NewSchemaless(attrs...)) +} + +func serviceName() string { + exe, err := os.Executable() + if err != nil || exe == "" { + return "agenthooks" + } + return strings.TrimSuffix(filepath.Base(exe), ".exe") +} + +// agenthooksVersion reports this module's version as built into the consumer +// binary, or "unknown" outside module builds. +func agenthooksVersion() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "unknown" + } + const module = "github.com/speakeasy-api/agenthooks" + if bi.Main.Path == module && bi.Main.Version != "" { + return bi.Main.Version + } + for _, dep := range bi.Deps { + if dep.Path == module && dep.Version != "" { + return dep.Version + } + } + return "unknown" +} + +// binaryVersion reports the consumer binary's own module version, "" when +// unavailable. +func binaryVersion() string { + bi, ok := debug.ReadBuildInfo() + if !ok || bi.Main.Version == "" || bi.Main.Version == "(devel)" { + return "" + } + return bi.Main.Version +} diff --git a/telemetry/telemetry_test.go b/telemetry/telemetry_test.go new file mode 100644 index 0000000..06a7ce9 --- /dev/null +++ b/telemetry/telemetry_test.go @@ -0,0 +1,270 @@ +package telemetry + +import ( + "compress/gzip" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + collpb "go.opentelemetry.io/proto/otlp/collector/logs/v1" + "google.golang.org/protobuf/proto" +) + +func TestNewValidatesConfig(t *testing.T) { + if _, err := New(Config{}); err == nil { + t.Errorf("empty endpoint must fail at construction") + } + if _, err := New(Config{Endpoint: "not a url"}); err == nil { + t.Errorf("malformed endpoint must fail at construction") + } + if _, err := New(Config{Endpoint: "grpc://example.com/v1/logs"}); err == nil { + t.Errorf("non-http endpoint must fail at construction") + } +} + +func TestCaptureContentLevel(t *testing.T) { + rec, exp := newTestRecorder(t, func(cfg *Config) { cfg.Capture = CaptureContent }) + hr := toolPreRecord() + if err := rec.RecordHook(hr); err != nil { + t.Fatalf("RecordHook: %v", err) + } + prompt := toolPreRecord() + prompt.Kind, prompt.NativeName = "prompt.submitted", "UserPromptSubmit" + prompt.Tool = nil + prompt.Prompt = "deploy with API_TOKEN=supersecret please" + if err := rec.RecordHook(prompt); err != nil { + t.Fatalf("RecordHook: %v", err) + } + + records := flushed(t, rec, exp) + if len(records) != 2 { + t.Fatalf("records = %d, want 2", len(records)) + } + toolAttrs := attrMap(records[0]) + args, ok := toolAttrs["gen_ai.tool.call.arguments"].(string) + if !ok { + t.Fatalf("content level must carry tool arguments") + } + if !strings.Contains(args, `"title":"hi"`) { + t.Errorf("arguments lost benign content: %s", args) + } + if strings.Contains(args, "sk-abcdef1234567890") { + t.Errorf("built-in redaction must scrub token-shaped values from content: %s", args) + } + if toolAttrs["agenthooks.session.cwd"] != "/work/repo" { + t.Errorf("cwd rides at content level, got %v", toolAttrs["agenthooks.session.cwd"]) + } + + body := bodyString(records[1]) + if !strings.Contains(body, "deploy with") { + t.Errorf("prompt text must ride the body at content level: %q", body) + } + if strings.Contains(body, "supersecret") { + t.Errorf("secret-named env assignment must be scrubbed from the body: %q", body) + } +} + +func TestUserRedactorRunsAfterBuiltinRedaction(t *testing.T) { + var sawKeys []string + rec, exp := newTestRecorder(t, func(cfg *Config) { + cfg.Capture = CaptureContent + cfg.Redactor = func(key, value string) string { + sawKeys = append(sawKeys, key) + if key == "session.id" { + return "REDACTED-SESSION" + } + return strings.ReplaceAll(value, "hi", "**") + } + }) + if err := rec.RecordHook(toolPreRecord()); err != nil { + t.Fatalf("RecordHook: %v", err) + } + records := flushed(t, rec, exp) + attrs := attrMap(records[0]) + if attrs["session.id"] != "REDACTED-SESSION" { + t.Errorf("Redactor must rewrite attribute values: %v", attrs["session.id"]) + } + if args, _ := attrs["gen_ai.tool.call.arguments"].(string); strings.Contains(args, "hi") { + t.Errorf("Redactor must see content values: %s", args) + } + joined := strings.Join(sawKeys, ",") + if !strings.Contains(joined, "body") { + t.Errorf("Redactor must see the body (key \"body\"); saw %s", joined) + } +} + +func TestPromptDigestAtDefaultLevel(t *testing.T) { + rec, exp := newTestRecorder(t, nil) + hr := toolPreRecord() + hr.Kind, hr.NativeName = "prompt.submitted", "UserPromptSubmit" + hr.Tool = nil + hr.Prompt = "refactor the auth middleware" + if err := rec.RecordHook(hr); err != nil { + t.Fatalf("RecordHook: %v", err) + } + records := flushed(t, rec, exp) + attrs := attrMap(records[0]) + if attrs["agenthooks.prompt.length"] != int64(len(hr.Prompt)) { + t.Errorf("prompt length = %v", attrs["agenthooks.prompt.length"]) + } + if attrs["agenthooks.prompt.sha256"] != "61d18f121f92c32678dc7bdf69b23794a67d6247aaf7b33b68459d0dfe061660" { + t.Errorf("prompt sha256 = %v", attrs["agenthooks.prompt.sha256"]) + } + if body := bodyString(records[0]); strings.Contains(body, "refactor") { + t.Errorf("prompt text must not leave the process at the default level: %q", body) + } + // Non-tool events trace per session (gram rule 3). + if records[0].TraceID() == [16]byte{} { + t.Fatalf("trace id missing") + } +} + +func TestRecordHookAfterShutdownIsNoOp(t *testing.T) { + rec, exp := newTestRecorder(t, nil) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := rec.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown: %v", err) + } + if err := rec.RecordHook(toolPreRecord()); err != nil { + t.Fatalf("RecordHook after Shutdown must not error: %v", err) + } + exp.mu.Lock() + defer exp.mu.Unlock() + if len(exp.recs) != 0 { + t.Errorf("records after shutdown = %d, want 0", len(exp.recs)) + } +} + +// TestShutdownShipsOverOTLP exercises the real export path end to end: a +// record is enqueued, Shutdown flushes it to an OTLP/HTTP endpoint with +// gzip compression and the configured auth headers. +func TestShutdownShipsOverOTLP(t *testing.T) { + type export struct { + header http.Header + req *collpb.ExportLogsServiceRequest + } + exports := make(chan export, 16) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body io.Reader = r.Body + if r.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(r.Body) + if err != nil { + t.Errorf("gzip reader: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + defer func() { _ = gz.Close() }() + body = gz + } + raw, err := io.ReadAll(body) + if err != nil { + t.Errorf("reading export body: %v", err) + } + var req collpb.ExportLogsServiceRequest + if err := proto.Unmarshal(raw, &req); err != nil { + t.Errorf("decoding export body: %v", err) + } + exports <- export{header: r.Header.Clone(), req: &req} + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + rec, err := New(Config{ + Endpoint: srv.URL + "/v1/logs", + Headers: map[string]string{"Gram-Key": "key-1", "Gram-Project": "proj-1"}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := rec.RecordHook(toolPreRecord()); err != nil { + t.Fatalf("RecordHook: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := rec.Shutdown(ctx); err != nil { + t.Fatalf("Shutdown: %v", err) + } + + select { + case got := <-exports: + if got.header.Get("Content-Encoding") != "gzip" { + t.Errorf("Content-Encoding = %q, want gzip", got.header.Get("Content-Encoding")) + } + if got.header.Get("Gram-Key") != "key-1" || got.header.Get("Gram-Project") != "proj-1" { + t.Errorf("auth headers missing: %v", got.header) + } + var events []string + for _, rl := range got.req.GetResourceLogs() { + for _, sl := range rl.GetScopeLogs() { + for _, lr := range sl.GetLogRecords() { + for _, kv := range lr.GetAttributes() { + if kv.GetKey() == "gram.hook.event" { + events = append(events, kv.GetValue().GetStringValue()) + } + } + } + } + } + if len(events) != 1 || events[0] != "PreToolUse" { + t.Errorf("shipped gram.hook.event values = %v, want [PreToolUse]", events) + } + case <-time.After(15 * time.Second): + t.Fatalf("Shutdown never delivered the record") + } +} + +func TestRedactURL(t *testing.T) { + tests := []struct{ in, want string }{ + {"https://user:pass@host.example.com/path", "https://host.example.com/path"}, + // url.Values.Encode percent-encodes the mask, matching the relay + // implementation this is ported from. + {"https://host.example.com/sse?api_key=abc&x=1", "https://host.example.com/sse?api_key=%2A%2A%2A&x=1"}, + {"https://host.example.com/p?signature=zzz", "https://host.example.com/p?signature=%2A%2A%2A"}, + {"https://host.example.com/p#frag", "https://host.example.com/p"}, + // Unparseable URLs could hide credentials anywhere: dropped whole. + {"https://u:p@host/%zz", "***"}, + {"", ""}, + } + for _, tt := range tests { + if got := redactURL(tt.in); got != tt.want { + t.Errorf("redactURL(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestRedactCommand(t *testing.T) { + tests := []struct{ in, want string }{ + {"npx server --api-key=abc123", "npx server --api-key=***"}, + {"npx server --token abc123", "npx server --token ***"}, + {"OPENAI_API_KEY=sk-123 npx server", "OPENAI_API_KEY=*** npx server"}, + {`curl -H "Authorization: Bearer abc.def" https://api.example.com`, "curl -H Authorization: Bearer *** https://api.example.com"}, + {"npx mcp-remote https://u:p@srv.example.com/mcp", "npx mcp-remote https://srv.example.com/mcp"}, + {"npx server ghp_0123456789abcdef", "npx server ***"}, + } + for _, tt := range tests { + if got := redactCommand(tt.in); got != tt.want { + t.Errorf("redactCommand(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestRedactContent(t *testing.T) { + tests := []struct{ in, want string }{ + {"use sk-abcdef1234567890 for auth", "use *** for auth"}, + {"push with ghp_0123456789abcdef now", "push with *** now"}, + {"MY_API_KEY=hunter2 ./run", "MY_API_KEY=*** ./run"}, + {"see https://user:pw@host.example.com/x", "see https://***@host.example.com/x"}, + {"Authorization: Bearer abc12345678", "Authorization: Bearer ***"}, + {"plain text stays intact", "plain text stays intact"}, + } + for _, tt := range tests { + if got := redactContent(tt.in); got != tt.want { + t.Errorf("redactContent(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} From d5859ab38b9d48fe060cf26e1f21cb6c7247bc0e Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Wed, 5 Aug 2026 19:19:13 -0700 Subject: [PATCH 2/9] feat: run hooks through a client/server pair with in-process fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agenthooks client` (installed as the hook command) forwards the raw event — argv, stdin payload, an allowlisted env snapshot, cwd — to a long-running `agenthooks server` singleton per consumer identity, auto-spawning it via the detached self re-exec under a spawn lock, and relays the decision back to the provider. Any transport failure degrades to running the identical pipeline in-process, so decisions never depend on server health. The server hosts the full decode/route/decision pipeline per connection (one goroutine each; library caches are already file-locked), early-acks non-gating events right after decode — replacing the --async re-exec quirk — and shuts down with a telemetry flush on idle timeout, SIGINT/SIGTERM, or when a client from a newer build connects (upgrade drain). WithTelemetry now taps the runner after each response is on the wire and installs the recorder's Shutdown for the server's flush points; the TelemetryRecorder interface drops ExporterMain. Co-authored-by: Cursor --- agenthooks.go | 77 ++++++- backfill.go | 4 + client.go | 172 ++++++++++++++++ clientserver_e2e_test.go | 105 ++++++++++ clientserver_test.go | 431 +++++++++++++++++++++++++++++++++++++++ detect.go | 58 ++++-- runnertelemetry.go | 192 +++++++++++++++++ runnertelemetry_test.go | 354 ++++++++++++++++++++++++++++++++ serve.go | 4 + server.go | 301 +++++++++++++++++++++++++++ testdata/hookbin/main.go | 29 +++ 11 files changed, 1699 insertions(+), 28 deletions(-) create mode 100644 client.go create mode 100644 clientserver_e2e_test.go create mode 100644 clientserver_test.go create mode 100644 runnertelemetry.go create mode 100644 runnertelemetry_test.go create mode 100644 server.go create mode 100644 testdata/hookbin/main.go diff --git a/agenthooks.go b/agenthooks.go index c52ff98..93c7852 100644 --- a/agenthooks.go +++ b/agenthooks.go @@ -54,6 +54,9 @@ type Runner struct { anyHandlers []func(context.Context, *Event) error otherByName map[string][]func(context.Context, *Event) error interceptors []Interceptor + afterEvent afterEvent + telemetryShutdown func(context.Context) error + spawnServer func(preArgs []string) error hSessionStart []func(context.Context, *SessionStartEvent) (SessionStartDecision, error) hSessionEnd []func(context.Context, *SessionEndEvent) error @@ -142,6 +145,7 @@ func New(opts ...Option) *Runner { logger: defaultLogger(), now: time.Now, otherByName: map[string][]func(context.Context, *Event) error{}, + spawnServer: spawnServerDetached, } for _, o := range opts { o(r) @@ -293,21 +297,60 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout _, _ = fmt.Fprintln(stderr, err) return 64 } - if inv.mode == "serve" { + switch inv.mode { + case "serve": return r.serve(ctx, inv, stdin, stdout, stderr) + case "server": + return r.serverMain(ctx, inv, stderr) + case "client": + return r.clientMain(ctx, inv, stdin, stdout, stderr) } + return r.runEvent(ctx, inv, r.readPayload(inv, stdin), runOpts{getenv: os.Getenv}, stdout, stderr) +} - var payload []byte +// readPayload materializes the hook payload for run/notify/client modes: +// argv positionals for the notify verb and --argv-payload, stdin otherwise. +func (r *Runner) readPayload(inv *invocation, stdin io.Reader) []byte { if inv.mode == "notify" || inv.argvPayload { - payload = []byte(inv.payload) - } else { - payload, err = io.ReadAll(io.LimitReader(stdin, maxPayloadBytes)) - if err != nil { - r.logger.Error("agenthooks: reading stdin", "error", err) - } + return []byte(inv.payload) + } + payload, err := io.ReadAll(io.LimitReader(stdin, maxPayloadBytes)) + if err != nil { + r.logger.Error("agenthooks: reading stdin", "error", err) + } + return payload +} + +// runOpts carries the per-invocation environment of one hook event through +// the pipeline. getenv resolves environment lookups (the process env for +// in-process runs, the request's forwarded snapshot on the server). +// earlyAck, when set (server mode), is invoked with the provider's no-op +// response as soon as a decoded event turns out to be non-gating: the +// server replies to the client immediately and finishes processing +// afterwards — replacing the --async re-exec quirk for client-mode installs. +type runOpts struct { + getenv func(string) string + earlyAck func(wire wireResponse) +} + +// gatingKind reports whether an event kind can carry a decision or context +// the provider waits for. Non-gating kinds are observe-only: their response +// is always the provider's no-op form, so the server can acknowledge them +// before the handlers run. +func gatingKind(k EventKind) bool { + switch k { + case KindToolPre, KindToolPost, KindToolError, KindPermission, + KindPromptSubmitted, KindStop, KindSubagentStop, KindSessionStart: + return true } + return false +} - provider, conf := detectProvider(inv, payload) +// runEvent is the single-event pipeline behind the run, notify, and client +// (fallback and server-side) paths: detect → decode → dispatch → encode, +// with the response written to stdout/stderr and the exit code returned. +func (r *Runner) runEvent(ctx context.Context, inv *invocation, payload []byte, opts runOpts, stdout, stderr io.Writer) int { + provider, conf := detectProvider(inv, payload, opts.getenv) if provider == "" { r.logger.Error("agenthooks: cannot detect provider; emitting neutral no-op", "payload_bytes", len(payload)) _, _ = fmt.Fprint(stdout, "{}") @@ -315,10 +358,11 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout } variant := inv.variant if variant == VariantUnknown { - variant = detectVariant(provider) + variant = detectVariant(provider, opts.getenv) } var typed any + var err error if inv.mode == "notify" { typed, err = decodeCodexNotify(variant, conf, r.now(), payload) } else { @@ -338,6 +382,14 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout r.logger.Debug("agenthooks: event decoded", "native", base.NativeName, "kind", string(base.Kind)) } + // Non-gating events cannot change the provider's behavior, so the + // server acknowledges them the moment they decode and processes on its + // own time. The response the pipeline produces below is the same no-op + // and is discarded by the acked caller. + if opts.earlyAck != nil && !gatingKind(base.Kind) { + opts.earlyAck(noOpResponse(provider)) + } + // Start the launch-context inventory probe without delaying SessionStart. // A first MCP event arriving before it finishes waits on the same cache // lock and consumes the worker's snapshot. @@ -421,6 +473,7 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout } } } + encodedAt := r.now() if len(wire.Stdout) > 0 { _, _ = stdout.Write(wire.Stdout) } @@ -429,6 +482,10 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout // (quirk #23); other dialects never populate Stderr. _, _ = stderr.Write(wire.Stderr) } + // Telemetry taps in after the response is on the wire: it observes the + // event and the hook rail's health (timing, errors) — never the + // decision — and can never delay or change the response. + r.tapAfterEvent(typed, base, herr, encodedAt) return wire.ExitCode } diff --git a/backfill.go b/backfill.go index 6ef8930..fcb4fe0 100644 --- a/backfill.go +++ b/backfill.go @@ -158,6 +158,10 @@ func (r *Runner) maybeBackfillPrompt(ctx context.Context, base *Event) { if core.kind != DecisionNoDecision && core.kind != DecisionAcceptPrompt { r.logger.Debug("agenthooks: decision on backfilled prompt.submitted discarded (event is reporting-only)") } + // Telemetry still records the backfilled event, flagged + // agenthooks.event.backfilled=true; records are observational, so the + // discarded handler decision was never part of the record anyway. + r.tapAfterEvent(pe, &pe.Event, err, r.now()) } // recoverPromptText best-effort recovers the submitted prompt. Only the diff --git a/client.go b/client.go new file mode 100644 index 0000000..5c98d9f --- /dev/null +++ b/client.go @@ -0,0 +1,172 @@ +package agenthooks + +import ( + "context" + "fmt" + "io" + "net" + "os" + "time" + + "github.com/speakeasy-api/agenthooks/internal/filelock" + "github.com/speakeasy-api/agenthooks/internal/ipc" +) + +// The `agenthooks client` mode: the lightweight per-hook process that +// generated configs install in place of `run`. It reads the payload, +// forwards the invocation to the long-running hook server over the +// consumer-identity socket (spawning the server first if none answers), and +// relays the server's stdout/stderr/exit code back to the provider. +// +// The server is an optimization, never a dependency: any failure — no +// server, spawn blocked, connect timeout, protocol mismatch, truncated +// response — degrades to running the exact same pipeline in-process, which +// is byte-for-byte today's `run` behavior. Decisions never wait on server +// health; only the warm caches and in-process telemetry do. + +const ( + // clientDialTimeout bounds one connection attempt. + clientDialTimeout = 250 * time.Millisecond + // clientSpawnBudget bounds the whole connect-spawn-reconnect dance + // before the client gives up and runs in-process. + clientSpawnBudget = 2 * time.Second + // clientResponseSlack rides on top of the hook deadline when waiting + // for the server's response. + clientResponseSlack = 5 * time.Second +) + +// forwardedEnv is the allowlist of environment variables a client snapshots +// into the request: provider detection signals (detect.go) and the trace +// context. Deeper best-effort quirk paths (MCP config discovery, launch +// probes, transcript paths) read the server's own environment, which it +// inherited from the client that spawned it. +var forwardedEnv = []string{ + "TRACEPARENT", + "CURSOR_VERSION", "CURSOR_TRACE_ID", "CURSOR_AGENT", "CURSOR_TRANSCRIPT_PATH", + "CODEX_HOME", "CODEX_SANDBOX", + "GEMINI_CWD", "GEMINI_CLI", + "OPENCODE_SERVER", "OPENCODE", + "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT", "CLAUDE_CODE_REMOTE", "CLAUDE_PID", + "KIMI_CODE_HOME", +} + +// clientMain implements the `agenthooks client` argv mode. +func (r *Runner) clientMain(ctx context.Context, inv *invocation, stdin io.Reader, stdout, stderr io.Writer) int { + payload := r.readPayload(inv, stdin) + resp, err := r.callServer(inv, payload) + if err == nil { + if len(resp.Stdout) > 0 { + _, _ = stdout.Write(resp.Stdout) + } + if len(resp.Stderr) > 0 { + _, _ = stderr.Write(resp.Stderr) + } + return resp.ExitCode + } + r.logger.Warn("agenthooks: hook server unavailable; running in-process", "error", err) + return r.runEvent(ctx, inv, payload, runOpts{getenv: os.Getenv}, stdout, stderr) +} + +// callServer performs one framed request/response exchange, spawning the +// server if nothing answers on the endpoint. +func (r *Runner) callServer(inv *invocation, payload []byte) (*ipc.Response, error) { + exe, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolving executable: %w", err) + } + addr, err := ipc.Resolve(exe, inv.preArgs) + if err != nil { + return nil, err + } + conn, err := r.connectOrSpawn(addr, inv.preArgs) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + + env := make(map[string]string, len(forwardedEnv)) + for _, key := range forwardedEnv { + if v := os.Getenv(key); v != "" { + env[key] = v + } + } + cwd, _ := os.Getwd() + req := ipc.Request{ + V: ipc.ProtocolVersion, + Build: ipc.BuildStamp(exe), + Argv: inv.raw, + Stdin: payload, + Env: env, + CWD: cwd, + } + + // The response can legitimately take as long as the hook deadline (the + // server runs the same policy timeouts run mode would); past that plus + // slack, falling back in-process could still answer before the provider + // gives up on us. + wait := defaultDeadline + if inv.timeout > 0 { + wait = inv.timeout + } + _ = conn.SetDeadline(time.Now().Add(wait + clientResponseSlack)) + if err := ipc.WriteFrame(conn, req); err != nil { + return nil, err + } + var resp ipc.Response + if err := ipc.ReadFrame(conn, &resp); err != nil { + return nil, err + } + if resp.Error != "" { + return nil, fmt.Errorf("server error: %s", resp.Error) + } + return &resp, nil +} + +// connectOrSpawn dials the endpoint, auto-spawning the server on a miss. A +// file lock serializes the spawn so a burst of hook invocations starts one +// server; losers of the lock race just keep re-dialing while the winner's +// server comes up. +func (r *Runner) connectOrSpawn(addr ipc.Address, preArgs []string) (net.Conn, error) { + conn, err := ipc.Dial(addr.Endpoint, clientDialTimeout) + if err == nil { + return conn, nil + } + + release, locked, lockErr := filelock.TryLock(addr.SpawnLock) + if lockErr == nil && locked { + // Hold the lock through the reconnect loop: as long as this client + // is still waiting for its spawn to bind, nobody else spawns. + defer release() + if r.spawnServer == nil { + return nil, fmt.Errorf("dialing hook server: %w (spawning disabled)", err) + } + if spawnErr := r.spawnServer(preArgs); spawnErr != nil { + return nil, fmt.Errorf("spawning hook server: %w", spawnErr) + } + } + + deadline := time.Now().Add(clientSpawnBudget) + backoff := 20 * time.Millisecond + for { + time.Sleep(backoff) + backoff = min(backoff*2, 250*time.Millisecond) + conn, err = ipc.Dial(addr.Endpoint, clientDialTimeout) + if err == nil { + return conn, nil + } + if !time.Now().Before(deadline) { + return nil, fmt.Errorf("dialing hook server (after spawn window): %w", err) + } + } +} + +// spawnServerDetached re-execs this binary as the detached hook server, +// preserving the consumer flags that define the server identity +// ("mybinary --config=x agenthooks server"). It is the default behind +// Runner.spawnServer; tests substitute in-process spawns. +func spawnServerDetached(preArgs []string) error { + args := make([]string, 0, len(preArgs)+2) + args = append(args, preArgs...) + args = append(args, "agenthooks", "server") + return startDetachedSelf(args, nil) +} diff --git a/clientserver_e2e_test.go b/clientserver_e2e_test.go new file mode 100644 index 0000000..587268d --- /dev/null +++ b/clientserver_e2e_test.go @@ -0,0 +1,105 @@ +package agenthooks + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/speakeasy-api/agenthooks/internal/ipc" +) + +// TestClientAutoSpawnsDetachedServer exercises the real process topology: +// a consumer binary (built from testdata/hookbin) is invoked twice as +// `... agenthooks client`; the first invocation re-execs itself as a +// detached `agenthooks server`, both invocations get their decision over +// the socket/pipe from that one server process, and the server then idles +// out on its own. +func TestClientAutoSpawnsDetachedServer(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skipf("go toolchain unavailable: %v", err) + } + dir := t.TempDir() + bin := filepath.Join(dir, "hookbin") + if runtime.GOOS == "windows" { + bin += ".exe" + } + build := exec.Command("go", "build", "-o", bin, "./testdata/hookbin") + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + t.Fatalf("building hookbin: %v", err) + } + + // The test process and the subprocesses must agree on the rendezvous: + // t.Setenv covers ipc.Resolve here; hookEnv covers the children (and + // the detached server they spawn, which inherits it). + stateDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateDir) + logPath := filepath.Join(dir, "pids.log") + hookEnv := append(os.Environ(), + "XDG_STATE_HOME="+stateDir, + "HOOKBIN_LOG="+logPath, + "AGENTHOOKS_SERVER_IDLE_TIMEOUT=1s", + ) + + runClient := func() string { + cmd := exec.Command(bin, "agenthooks", "client", "--provider=claude-code") + cmd.Env = hookEnv + cmd.Stdin = bytes.NewReader(fixture(t, "claude/pre_tool_use.json")) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + if err := cmd.Run(); err != nil { + t.Fatalf("client run: %v (stderr: %s)", err, errb.String()) + } + return out.String() + } + + want := `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"denied by hookbin"}}` + for i := 0; i < 2; i++ { + if got := runClient(); got != want { + t.Fatalf("client %d: got %q, want %q", i, got, want) + } + } + + // One server pid for both invocations — the second client reused the + // server the first one spawned; two distinct pids would mean two + // in-process fallbacks. + pids := readPids(t, logPath) + if len(pids) != 2 || pids[0] != pids[1] { + t.Errorf("handler pids = %v, want the same server pid twice", pids) + } + + // The detached server idles out on its own (1s idle timeout). Probe + // sparsely: every accepted connection — including a probe — counts as + // activity, so probing faster than the idle window would keep the + // server alive forever. + addr, err := ipc.Resolve(bin, nil) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(30 * time.Second) + for { + time.Sleep(3 * time.Second) + conn, err := ipc.Dial(addr.Endpoint, 200*time.Millisecond) + if err != nil { + break + } + _ = conn.Close() + if time.Now().After(deadline) { + t.Fatalf("detached server never idled out on %s", addr.Endpoint) + } + } +} + +func readPids(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading pid log: %v", err) + } + return strings.Fields(string(data)) +} diff --git a/clientserver_test.go b/clientserver_test.go new file mode 100644 index 0000000..22663a0 --- /dev/null +++ b/clientserver_test.go @@ -0,0 +1,431 @@ +package agenthooks + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/speakeasy-api/agenthooks/internal/ipc" +) + +// The in-process client/server suite: servers run as goroutines via +// Runner.Run (the same entry the argv mode uses) and clients connect through +// the real socket/pipe transport in internal/ipc. Auto-spawn is exercised +// through the Runner's spawn seam; the true detached re-exec is covered by +// the subprocess test in clientserver_e2e_test.go. + +const denyWire = `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"server says no"}}` + +// testIdentity isolates one test's rendezvous: a per-test state dir (unix +// sockets and locks) plus unique pre-sentinel args (which hash into the +// endpoint name, so Windows pipes are unique too). +func testIdentity(t *testing.T) []string { + t.Helper() + t.Setenv("XDG_STATE_HOME", t.TempDir()) + return []string{"--config=" + filepath.Join(t.TempDir(), "cfg.json")} +} + +func serverRunArgs(preArgs []string, idle string) []string { + args := append(append([]string(nil), preArgs...), "agenthooks", "server") + if idle != "" { + args = append(args, "--idle-timeout="+idle) + } + return args +} + +func clientRunArgs(preArgs []string, extra ...string) []string { + args := append(append([]string(nil), preArgs...), "agenthooks", "client") + return append(args, extra...) +} + +// denyServerRunner is a hermetic server-side Runner that denies tool.pre. +func denyServerRunner(t *testing.T) *Runner { + t.Helper() + r := quietRunner(WithDedupDir(t.TempDir()), WithoutMCPResolution(), WithoutBackfill()) + r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Deny("server says no"), nil + }) + return r +} + +// startServer runs the server mode in a goroutine and blocks until it +// accepts connections. The returned channel yields the exit code. +func startServer(t *testing.T, r *Runner, args []string) chan int { + t.Helper() + exit := make(chan int, 1) + go func() { + exit <- r.Run(context.Background(), args, strings.NewReader(""), io.Discard, io.Discard) + }() + waitForServer(t, args) + return exit +} + +// waitForServer polls the endpoint derived from args' pre-sentinel flags +// until something accepts. +func waitForServer(t *testing.T, args []string) { + t.Helper() + inv, err := parseArgs(args) + if err != nil { + t.Fatal(err) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + addr, err := ipc.Resolve(exe, inv.preArgs) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for { + conn, err := ipc.Dial(addr.Endpoint, 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + if time.Now().After(deadline) { + t.Fatalf("server never came up on %s: %v", addr.Endpoint, err) + } + time.Sleep(10 * time.Millisecond) + } +} + +func waitExit(t *testing.T, exit chan int, what string) int { + t.Helper() + select { + case code := <-exit: + return code + case <-time.After(15 * time.Second): + t.Fatalf("%s never exited", what) + return -1 + } +} + +// noSpawn disables auto-spawn so a client test fails fast instead of +// re-execing the test binary. +func noSpawn(r *Runner) { + r.spawnServer = func([]string) error { return errors.New("spawning disabled in this test") } +} + +func TestClientServerGatingRoundTrip(t *testing.T) { + preArgs := testIdentity(t) + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "5s")) + + // The client's own handler would allow: a deny response proves the + // decision came over the wire from the server, not from the fallback. + client := quietRunner() + noSpawn(client) + client.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Allow(), nil + }) + for i := 0; i < 2; i++ { // second request reuses the same server + out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code"), fixture(t, "claude/pre_tool_use.json")) + if out != denyWire || code != 0 { + t.Fatalf("request %d: got %q (exit %d), want the server's deny", i, out, code) + } + } + if code := waitExit(t, exit, "idle server"); code != 0 { + t.Errorf("server exit = %d, want 0", code) + } +} + +func TestClientRelaysExitCodeAndStderr(t *testing.T) { + preArgs := testIdentity(t) + // Kimi's prompt-blocking mechanism is exit 2 with the reason on stderr + // (quirk #23): the response frame must carry all three channels back. + server := quietRunner(WithDedupDir(t.TempDir()), WithoutMCPResolution(), WithoutBackfill()) + server.OnPromptSubmitted(func(ctx context.Context, e *PromptEvent) (PromptDecision, error) { + return BlockPrompt("kimi block"), nil + }) + exit := startServer(t, server, serverRunArgs(preArgs, "5s")) + + client := quietRunner() + noSpawn(client) + var out, errb bytes.Buffer + code := client.Run(context.Background(), clientRunArgs(preArgs, "--provider=kimi-code"), + bytes.NewReader(kimiPrompt("sess-cs-kimi")), &out, &errb) + if code != 2 { + t.Errorf("exit = %d, want kimi's blocking exit 2 (stderr %q)", code, errb.String()) + } + if !strings.Contains(errb.String(), "kimi block") { + t.Errorf("stderr must carry the reason: %q", errb.String()) + } + waitExit(t, exit, "idle server") +} + +func TestClientSpawnsServerOnDemand(t *testing.T) { + preArgs := testIdentity(t) + server := denyServerRunner(t) + exit := make(chan int, 1) + + var spawns atomic.Int32 + client := quietRunner() + client.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Allow(), nil + }) + client.spawnServer = func(gotPre []string) error { + spawns.Add(1) + if len(gotPre) != len(preArgs) || gotPre[0] != preArgs[0] { + t.Errorf("spawn must preserve pre-sentinel flags: %v", gotPre) + } + go func() { + exit <- server.Run(context.Background(), serverRunArgs(gotPre, "5s"), strings.NewReader(""), io.Discard, io.Discard) + }() + return nil + } + + out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code"), fixture(t, "claude/pre_tool_use.json")) + if out != denyWire || code != 0 { + t.Fatalf("got %q (exit %d), want the spawned server's deny", out, code) + } + if got := spawns.Load(); got != 1 { + t.Errorf("spawns = %d, want 1", got) + } + waitExit(t, exit, "idle server") +} + +func TestClientSpawnRaceStartsOneServer(t *testing.T) { + preArgs := testIdentity(t) + server := denyServerRunner(t) + exit := make(chan int, 1) + + var spawns atomic.Int32 + spawn := func(gotPre []string) error { + if spawns.Add(1) > 1 { + return errors.New("second spawn attempted; the spawn lock failed") + } + go func() { + exit <- server.Run(context.Background(), serverRunArgs(gotPre, "5s"), strings.NewReader(""), io.Discard, io.Discard) + }() + return nil + } + + const clients = 4 + var wg sync.WaitGroup + results := make([]string, clients) + codes := make([]int, clients) + for i := 0; i < clients; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + c := quietRunner() + c.spawnServer = spawn + var out, errb bytes.Buffer + codes[i] = c.Run(context.Background(), clientRunArgs(preArgs, "--provider=claude-code"), + bytes.NewReader(fixture(t, "claude/pre_tool_use.json")), &out, &errb) + results[i] = out.String() + }(i) + } + wg.Wait() + + for i := range results { + if results[i] != denyWire || codes[i] != 0 { + t.Errorf("client %d: got %q (exit %d), want the server's deny", i, results[i], codes[i]) + } + } + // The spawn lock admits one spawner; racing losers reconnect instead. + if got := spawns.Load(); got != 1 { + t.Errorf("spawns = %d, want 1 (spawn lock must serialize the herd)", got) + } + waitExit(t, exit, "idle server") +} + +func TestClientFallsBackInProcessWhenSpawnBlocked(t *testing.T) { + preArgs := testIdentity(t) + client := quietRunner(WithDedupDir(t.TempDir()), WithoutMCPResolution(), WithoutBackfill()) + noSpawn(client) + client.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Deny("fallback says no"), nil + }) + + start := time.Now() + out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code"), fixture(t, "claude/pre_tool_use.json")) + if code != 0 || !strings.Contains(out, `"permissionDecision":"deny"`) || !strings.Contains(out, "fallback says no") { + t.Fatalf("fallback must run the pipeline in-process: %q (exit %d)", out, code) + } + // A failed spawn must not burn the reconnect budget: the seam errors + // immediately, so only the initial dial cost precedes the fallback. + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("fallback took %s", elapsed) + } +} + +func TestServerEarlyAcksNonGatingEvents(t *testing.T) { + preArgs := testIdentity(t) + handlerDone := make(chan struct{}) + release := make(chan struct{}) + server := quietRunner(WithDedupDir(t.TempDir()), WithoutMCPResolution(), WithoutBackfill()) + server.OnNotification(func(ctx context.Context, e *NotificationEvent) error { + <-release + close(handlerDone) + return nil + }) + exit := startServer(t, server, serverRunArgs(preArgs, "5s")) + + client := quietRunner() + noSpawn(client) + out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code"), fixture(t, "claude/notification.json")) + if code != 0 || out != "{}" { + t.Fatalf("early-ack must return the provider no-op: %q (exit %d)", out, code) + } + select { + case <-handlerDone: + t.Fatalf("handler finished before the ack returned — not early-acked") + default: + } + // The handler is still parked: the client got its answer first. + close(release) + select { + case <-handlerDone: + case <-time.After(10 * time.Second): + t.Fatalf("handler never completed after the ack") + } + waitExit(t, exit, "idle server") +} + +func TestServerGatingEventsWaitForHandlers(t *testing.T) { + preArgs := testIdentity(t) + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "5s")) + + client := quietRunner() + noSpawn(client) + // tool.pre is gating: the response must be the handler's decision, not + // an early no-op. + out, _ := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code"), fixture(t, "claude/pre_tool_use.json")) + if out != denyWire { + t.Errorf("gating event must carry the decision: %q", out) + } + waitExit(t, exit, "idle server") +} + +func TestServerIdleShutdown(t *testing.T) { + preArgs := testIdentity(t) + start := time.Now() + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "300ms")) + if code := waitExit(t, exit, "idle server"); code != 0 { + t.Errorf("idle shutdown exit = %d, want 0", code) + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Errorf("idle shutdown took %s", elapsed) + } +} + +func TestServerSingleton(t *testing.T) { + preArgs := testIdentity(t) + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "5s")) + + // A second server for the same identity yields immediately with 0. + second := quietRunner() + code := second.Run(context.Background(), serverRunArgs(preArgs, "5s"), strings.NewReader(""), io.Discard, io.Discard) + if code != 0 { + t.Errorf("second server exit = %d, want 0 (already running)", code) + } + waitExit(t, exit, "idle server") +} + +func TestServerVersionMismatchDrains(t *testing.T) { + preArgs := testIdentity(t) + // Idle long enough that only the mismatch can explain the exit. + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "2m")) + + resp := rawRequest(t, preArgs, ipc.Request{ + V: ipc.ProtocolVersion, + Build: "different-build-stamp", + Argv: clientRunArgs(preArgs, "--provider=claude-code"), + Stdin: fixture(t, "claude/pre_tool_use.json"), + }) + if resp.Error != "" || string(resp.Stdout) != denyWire { + t.Fatalf("the mismatched request must still be served: %+v", resp) + } + if code := waitExit(t, exit, "draining server"); code != 0 { + t.Errorf("upgrade drain exit = %d, want 0", code) + } +} + +func TestServerRejectsProtocolMismatch(t *testing.T) { + preArgs := testIdentity(t) + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "5s")) + + resp := rawRequest(t, preArgs, ipc.Request{ + V: 99, + Argv: clientRunArgs(preArgs, "--provider=claude-code"), + Stdin: fixture(t, "claude/pre_tool_use.json"), + }) + if resp.Error == "" { + t.Errorf("unknown protocol version must produce an error frame: %+v", resp) + } + waitExit(t, exit, "idle server") +} + +func TestServerReportsBadArgv(t *testing.T) { + preArgs := testIdentity(t) + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "5s")) + + resp := rawRequest(t, preArgs, ipc.Request{ + V: ipc.ProtocolVersion, + Argv: clientRunArgs(preArgs, "--provider=claude-code", "--timeout=bogus"), + }) + if resp.ExitCode != 64 || !strings.Contains(string(resp.Stderr), "--timeout") { + t.Errorf("bad argv must round-trip as exit 64 + stderr: %+v", resp) + } + waitExit(t, exit, "idle server") +} + +func TestServerFlushesTelemetryOnShutdown(t *testing.T) { + preArgs := testIdentity(t) + rec := &captureRecorder{} + server := quietRunner(WithDedupDir(t.TempDir()), WithoutMCPResolution(), WithoutBackfill(), WithTelemetry(rec)) + server.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Deny("server says no"), nil + }) + exit := startServer(t, server, serverRunArgs(preArgs, "500ms")) + + client := quietRunner() + noSpawn(client) + if out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code"), fixture(t, "claude/pre_tool_use.json")); code != 0 || out != denyWire { + t.Fatalf("request failed: %q (exit %d)", out, code) + } + waitExit(t, exit, "idle server") + if rec.records.Load() == 0 { + t.Errorf("server-side events must reach the recorder") + } + if !rec.shutdown.Load() { + t.Errorf("idle shutdown must flush the recorder via Shutdown") + } +} + +// rawRequest opens one connection to the test server and performs a framed +// exchange, bypassing clientMain (for protocol-level assertions). +func rawRequest(t *testing.T, preArgs []string, req ipc.Request) ipc.Response { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + addr, err := ipc.Resolve(exe, preArgs) + if err != nil { + t.Fatal(err) + } + conn, err := ipc.Dial(addr.Endpoint, time.Second) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + if err := ipc.WriteFrame(conn, req); err != nil { + t.Fatal(err) + } + var resp ipc.Response + if err := ipc.ReadFrame(conn, &resp); err != nil { + t.Fatal(err) + } + return resp +} diff --git a/detect.go b/detect.go index 94cebb7..934fbf2 100644 --- a/detect.go +++ b/detect.go @@ -3,19 +3,20 @@ package agenthooks import ( "encoding/json" "fmt" - "os" "strings" "time" ) // invocation is the parsed argv contract baked into generated configs: // -// mybinary agenthooks run --provider=claude-code # stdin JSON +// mybinary agenthooks client --provider=claude-code # stdin JSON, via the hook server +// mybinary agenthooks run --provider=claude-code # stdin JSON, in-process // mybinary agenthooks run --provider=cursor --argv-payload # legacy cursor CLI // mybinary agenthooks notify --provider=codex # legacy codex notify (argv JSON) // mybinary agenthooks serve --provider=opencode # NDJSON daemon for the shim +// mybinary agenthooks server [--idle-timeout=10m] # hook server (auto-spawned by client) type invocation struct { - mode string // "run", "notify", "serve" + mode string // "run", "notify", "serve", "server", "client" provider Provider variant Variant confidence DetectionConfidence @@ -23,6 +24,16 @@ type invocation struct { payload string timeout time.Duration filter *ToolMatcher + // raw is the argv exactly as received: the client forwards it to the + // server, which re-parses it for the per-hook flags and the notify + // verb's argv payload. + raw []string + // preArgs are the consumer flags that precede the "agenthooks" sentinel + // (e.g. --config=/path). They define the server identity (internal/ipc) + // and are replayed when the client re-execs itself as the server. + preArgs []string + // idleTimeout overrides the server's idle shutdown (server mode only). + idleTimeout time.Duration } var validProviders = map[Provider]bool{ @@ -35,22 +46,24 @@ var validProviders = map[Provider]bool{ } func parseArgs(args []string) (*invocation, error) { - inv := &invocation{mode: "run"} + inv := &invocation{mode: "run", raw: args} rest := args // Generated configs put consumer-binary flags before the sentinel // ("mybinary --config=x agenthooks serve --provider=opencode"), so the // sentinel and mode are located anywhere in argv, not just at the front. // Everything before the sentinel belongs to the consumer and is dropped - // from agenthooks parsing. + // from agenthooks parsing — but kept as preArgs: it names the consumer + // deployment, which is what the client/server rendezvous keys on. for i, a := range rest { if a == "agenthooks" { + inv.preArgs = rest[:i] rest = rest[i+1:] break } } if len(rest) > 0 { switch rest[0] { - case "run", "notify", "serve": + case "run", "notify", "serve", "server", "client": inv.mode = rest[0] rest = rest[1:] } @@ -84,6 +97,12 @@ func parseArgs(args []string) (*invocation, error) { return nil, err } inv.filter = &m + case strings.HasPrefix(a, "--idle-timeout="): + d, err := time.ParseDuration(strings.TrimPrefix(a, "--idle-timeout=")) + if err != nil { + return nil, fmt.Errorf("agenthooks: bad --idle-timeout: %w", err) + } + inv.idleTimeout = d case strings.HasPrefix(a, "--"): // Unknown flags are tolerated for forward compatibility with // newer generated configs driving older library versions. @@ -98,11 +117,14 @@ func parseArgs(args []string) (*invocation, error) { // detectProvider resolves the invoking provider. Flag-first is a hard rule: // Codex and Cursor deliberately export CLAUDE_* compat vars (quirk #20), so // env sniffing alone is insufficient. Shape sniffing is the last resort. -func detectProvider(inv *invocation, payload []byte) (Provider, DetectionConfidence) { +// getenv abstracts the environment: os.Getenv for in-process runs, the +// request's forwarded snapshot when the server handles a client's hook (the +// server's own environment describes whoever spawned it, not this event). +func detectProvider(inv *invocation, payload []byte, getenv func(string) string) (Provider, DetectionConfidence) { if inv.provider != "" { return inv.provider, DetectionConfig } - if p, ok := detectFromEnv(); ok { + if p, ok := detectFromEnv(getenv); ok { return p, DetectionEnv } if p, ok := detectFromShape(payload); ok { @@ -111,21 +133,21 @@ func detectProvider(inv *invocation, payload []byte) (Provider, DetectionConfide return "", "" } -func detectFromEnv() (Provider, bool) { +func detectFromEnv(getenv func(string) string) (Provider, bool) { // Provider-unique vars first; CLAUDE_* last because it is cross-set. - if os.Getenv("CURSOR_VERSION") != "" || os.Getenv("CURSOR_TRACE_ID") != "" || os.Getenv("CURSOR_AGENT") != "" { + if getenv("CURSOR_VERSION") != "" || getenv("CURSOR_TRACE_ID") != "" || getenv("CURSOR_AGENT") != "" { return ProviderCursor, true } - if os.Getenv("CODEX_HOME") != "" || os.Getenv("CODEX_SANDBOX") != "" { + if getenv("CODEX_HOME") != "" || getenv("CODEX_SANDBOX") != "" { return ProviderCodex, true } - if os.Getenv("GEMINI_CWD") != "" || os.Getenv("GEMINI_CLI") != "" { + if getenv("GEMINI_CWD") != "" || getenv("GEMINI_CLI") != "" { return ProviderGemini, true } - if os.Getenv("OPENCODE_SERVER") != "" || os.Getenv("OPENCODE") != "" { + if getenv("OPENCODE_SERVER") != "" || getenv("OPENCODE") != "" { return ProviderOpenCode, true } - if os.Getenv("CLAUDE_PROJECT_DIR") != "" || os.Getenv("CLAUDE_PLUGIN_ROOT") != "" { + if getenv("CLAUDE_PROJECT_DIR") != "" || getenv("CLAUDE_PLUGIN_ROOT") != "" { return ProviderClaudeCode, true } return "", false @@ -182,18 +204,18 @@ func isCamel(s string) bool { // detectVariant encodes the runtime tricks that distinguish provider // sub-flavors (§6). Best-effort by design; "" means unknown/default. -func detectVariant(p Provider) Variant { +func detectVariant(p Provider, getenv func(string) string) Variant { switch p { case ProviderClaudeCode: - if os.Getenv("CLAUDE_CODE_REMOTE") != "" { + if getenv("CLAUDE_CODE_REMOTE") != "" { return VariantRemote } // cowork: cmux-managed project dirs are the observable signature. - if dir := os.Getenv("CLAUDE_PROJECT_DIR"); strings.Contains(dir, "/cmux/") || strings.Contains(dir, "/cowork/") { + if dir := getenv("CLAUDE_PROJECT_DIR"); strings.Contains(dir, "/cmux/") || strings.Contains(dir, "/cowork/") { return VariantCowork } case ProviderCursor: - if os.Getenv("CURSOR_AGENT") != "" { + if getenv("CURSOR_AGENT") != "" { return VariantCLI } } diff --git a/runnertelemetry.go b/runnertelemetry.go new file mode 100644 index 0000000..bec814e --- /dev/null +++ b/runnertelemetry.go @@ -0,0 +1,192 @@ +package agenthooks + +import ( + "context" + "reflect" + "time" + + "github.com/speakeasy-api/agenthooks/internal/hookrecord" +) + +// recordTiming carries the tap's timing view of one event: the library +// receive time and the dispatch-to-response-encoded duration — hook +// overhead, distinct from tool execution time. +type recordTiming struct { + receive time.Time + duration time.Duration +} + +// afterEvent is the internal end-of-processing hook invoked by Runner.Run +// after the response is encoded, and by the OpenCode serve loop after the +// reply is encoded. Unlike OnAny observers — which run before the handler +// pipeline — it sees the full processing timing and any handler error. It +// deliberately does not see the decision: telemetry records are purely +// observational (the enforcement rail owns decision logging), so the tap +// carries the event, the timing, and the error signal — nothing about the +// verdict. WithTelemetry installs one. +type afterEvent func(typed any, base *Event, timing recordTiming, herr error) + +// TelemetryRecorder is the runner-facing surface of a telemetry recorder; +// *telemetry.Recorder implements it. It exists so this root package does not +// import the telemetry package: only binaries that construct a recorder (and +// therefore import telemetry themselves) link the OTel SDK dependency tree — +// consumers that never opt in pay nothing. The method signatures use an +// internal type on purpose, which keeps the recorder tap callable by the +// runner but not implementable or invokable by external consumers. +type TelemetryRecorder interface { + // RecordHook captures one hook event into the recorder's in-process + // export pipeline. + RecordHook(hr *hookrecord.Record) error + // Shutdown flushes buffered records to the endpoint and stops the + // pipeline. The hook server calls it on idle shutdown, on + // SIGINT/SIGTERM, and before a version-upgrade exit. + Shutdown(ctx context.Context) error +} + +// WithTelemetry installs rec as the runner's telemetry recorder: one OTel +// log record per hook event, captured after the response is on the wire. +// Records are observational — they describe the event and the hook rail's +// own health (timing, errors), never the enforcement decision, which is +// logged by the enforcement backend at decision time. +// +// The recorder batches in process and ships over OTLP/HTTP in the +// background, so it is only meaningful in a long-lived process: the hook +// server (`mybinary agenthooks server`), which flushes it on idle shutdown +// and on signals. In a per-hook process (the client's in-process fallback, +// plain `run`) the process usually exits before a batch ships — telemetry +// there is best-effort by design and the loss is accepted. +// +// Opt-in and fail-open by construction — without the option nothing +// changes; with it, a recorder failure degrades to a logged warning, never +// an error on the pipeline. See the telemetry package for configuration: +// +// rec, err := telemetry.New(telemetry.Config{Endpoint: ...}) +// if err != nil { ... } +// r := agenthooks.New(agenthooks.WithTelemetry(rec)) +// +// Runner.Decide does not record telemetry: it has no wire edge and its +// callers own their own observability. +func WithTelemetry(rec TelemetryRecorder) Option { + return func(r *Runner) { + if rec == nil { + return + } + // A typed-nil recorder (a nil *telemetry.Recorder from a New whose + // error went unchecked) makes the interface non-nil; treat it as + // absent rather than installing a tap that can only fail. + if v := reflect.ValueOf(rec); v.Kind() == reflect.Pointer && v.IsNil() { + return + } + r.telemetryShutdown = rec.Shutdown + r.afterEvent = func(typed any, base *Event, timing recordTiming, herr error) { + if err := rec.RecordHook(buildHookRecord(typed, base, timing, herr)); err != nil { + r.logger.Warn("agenthooks: telemetry record failed", "error", err) + } + } + } +} + +// tapAfterEvent delivers the end-of-processing snapshot to the telemetry +// recorder. encodedAt is sampled at the encoding boundary so the duration +// measures dispatch-to-response-encoded, not the provider write. Fail-open, +// always: the tap runs after the response is written, is panic-guarded like +// observers, and its work is bounded I/O with no network — recorder errors +// log a warning and never change a decision, delay a response, or surface +// as a hook failure. +func (r *Runner) tapAfterEvent(typed any, base *Event, herr error, encodedAt time.Time) { + if r.afterEvent == nil { + return + } + defer func() { + if p := recover(); p != nil { + r.logger.Warn("agenthooks: telemetry tap panic", "panic", p) + } + }() + timing := recordTiming{receive: base.Time, duration: encodedAt.Sub(base.Time)} + r.afterEvent(typed, base, timing, herr) +} + +// buildHookRecord projects the typed event into the flat record the +// telemetry package consumes. +func buildHookRecord(typed any, base *Event, timing recordTiming, herr error) *hookrecord.Record { + hr := &hookrecord.Record{ + Provider: string(base.Provider), + Variant: string(base.Variant), + NativeName: base.NativeName, + Kind: string(base.Kind), + Time: timing.receive, + Backfilled: base.Backfilled, + SessionID: base.Session.ID, + TurnID: base.Session.TurnID, + CWD: base.Session.CWD, + Model: base.Session.Model, + UserEmail: base.Session.UserEmail, + HookDurationMS: float64(timing.duration) / float64(time.Millisecond), + } + if herr != nil { + hr.HandlerErr = herr.Error() + } + if base.Agent != nil { + hr.SubagentID = base.Agent.ID + hr.SubagentType = base.Agent.Type + } + switch ev := typed.(type) { + case *PromptEvent: + hr.Prompt = ev.Prompt + case *ToolPreEvent: + hr.Tool = toolHookRecord(&ev.Tool, nil) + case *PermissionEvent: + hr.Tool = toolHookRecord(&ev.Tool, nil) + case *ToolPostEvent: + hr.Tool = toolHookRecord(&ev.Tool, ev) + case *StopEvent: + hr.FinalMessage = ev.FinalMessage + hr.LoopCount = ev.LoopCount + if u := ev.Usage; u != nil { + hr.Usage = &hookrecord.Usage{ + InputTokens: u.InputTokens, + OutputTokens: u.OutputTokens, + CacheReadTokens: u.CacheReadTokens, + CacheWriteTokens: u.CacheWriteTokens, + Cost: u.Cost, + } + } + case *SessionStartEvent: + hr.SessionSource = ev.Source + case *SessionEndEvent: + hr.SessionEndReason = ev.Reason + case *CompactEvent: + hr.CompactTrigger = ev.Trigger + case *NotificationEvent: + hr.Notification = ev.Message + case *FileEditedEvent: + hr.FilePath = ev.Path + } + return hr +} + +func toolHookRecord(t *ToolCall, post *ToolPostEvent) *hookrecord.Tool { + tr := &hookrecord.Tool{ + ID: t.ID, + Synthesized: t.Synthesized, + Name: t.Name, + Canonical: string(t.Canonical), + Input: t.Input, + } + if m := t.MCP; m != nil { + tr.MCP = &hookrecord.MCP{ + Server: m.Server, + Tool: m.Tool, + URL: m.URL, + Command: m.Command, + FromConfig: m.FromConfig, + } + } + if post != nil { + tr.Output = post.Output + tr.Failed = post.Failed + tr.Error = post.Error + tr.DurationMS = post.DurationMS + } + return tr +} diff --git a/runnertelemetry_test.go b/runnertelemetry_test.go new file mode 100644 index 0000000..2b986a3 --- /dev/null +++ b/runnertelemetry_test.go @@ -0,0 +1,354 @@ +package agenthooks + +import ( + "compress/gzip" + "context" + "encoding/hex" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + collpb "go.opentelemetry.io/proto/otlp/collector/logs/v1" + cpb "go.opentelemetry.io/proto/otlp/common/v1" + lpb "go.opentelemetry.io/proto/otlp/logs/v1" + "google.golang.org/protobuf/proto" + + "github.com/speakeasy-api/agenthooks/internal/hookrecord" + "github.com/speakeasy-api/agenthooks/telemetry" +) + +// newTestTelemetry stands up a real OTLP/HTTP collector and a Recorder +// pointed at it. collect() force-flushes the recorder's batch pipeline and +// returns every log record received so far. +func newTestTelemetry(t *testing.T) (*telemetry.Recorder, func() []*lpb.LogRecord) { + t.Helper() + var mu sync.Mutex + var records []*lpb.LogRecord + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body io.Reader = r.Body + if r.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(r.Body) + if err != nil { + t.Errorf("gzip reader: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + defer func() { _ = gz.Close() }() + body = gz + } + raw, err := io.ReadAll(body) + if err != nil { + t.Errorf("reading export body: %v", err) + } + var req collpb.ExportLogsServiceRequest + if err := proto.Unmarshal(raw, &req); err != nil { + t.Errorf("decoding export body: %v", err) + } + mu.Lock() + for _, rl := range req.GetResourceLogs() { + for _, sl := range rl.GetScopeLogs() { + records = append(records, sl.GetLogRecords()...) + } + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + rec, err := telemetry.New(telemetry.Config{Endpoint: srv.URL + "/v1/logs"}) + if err != nil { + t.Fatalf("telemetry.New: %v", err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = rec.Shutdown(ctx) + }) + collect := func() []*lpb.LogRecord { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := rec.ForceFlush(ctx); err != nil { + t.Fatalf("ForceFlush: %v", err) + } + mu.Lock() + defer mu.Unlock() + return append([]*lpb.LogRecord(nil), records...) + } + return rec, collect +} + +func telemetryAttrs(pr *lpb.LogRecord) map[string]any { + out := map[string]any{} + for _, kv := range pr.GetAttributes() { + switch val := kv.GetValue().GetValue().(type) { + case *cpb.AnyValue_BoolValue: + out[kv.GetKey()] = val.BoolValue + case *cpb.AnyValue_IntValue: + out[kv.GetKey()] = val.IntValue + case *cpb.AnyValue_DoubleValue: + out[kv.GetKey()] = val.DoubleValue + default: + out[kv.GetKey()] = kv.GetValue().GetStringValue() + } + } + return out +} + +// requireNoDecisionAttrs asserts the observational contract: records never +// carry the enforcement decision (the enforcement backend's decision-time +// log is the sole record of decisions). +func requireNoDecisionAttrs(t *testing.T, attrs map[string]any) { + t.Helper() + for _, key := range []string{ + "gram.hook.decision", "agenthooks.decision.reason", + "agenthooks.decision.blocking", "agenthooks.decision.source", + } { + if _, ok := attrs[key]; ok { + t.Errorf("decision attribute %s must not be emitted: %v", key, attrs) + } + } +} + +func TestWithTelemetryRecordsEvent(t *testing.T) { + rec, collect := newTestTelemetry(t) + r := quietRunner(WithTelemetry(rec)) + r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Deny("blocked"), nil + }) + out, code := runWith(t, r, claudeArgs(), fixture(t, "claude/pre_tool_use.json")) + want := `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"blocked"}}` + if out != want || code != 0 { + t.Fatalf("telemetry must not change the wire: got %q (exit %d)", out, code) + } + + records := collect() + if len(records) != 1 { + t.Fatalf("recorded records = %d, want 1", len(records)) + } + pr := records[0] + attrs := telemetryAttrs(pr) + if attrs["gram.hook.event"] != "PreToolUse" || attrs["event.name"] != "tool.pre" { + t.Errorf("record identity wrong: %v", attrs) + } + if attrs["gen_ai.tool.call.id"] != "toolu_01ABC" || attrs["gram.tool.name"] != "Bash" { + t.Errorf("tool identity wrong: %v", attrs) + } + if attrs["session.id"] != "sess-claude-1" { + t.Errorf("session id wrong: %v", attrs) + } + requireNoDecisionAttrs(t, attrs) + // gram's hashToolCallIDToTraceID("toolu_01ABC"). + if got := hex.EncodeToString(pr.GetTraceId()); got != "7661011023ab0fab264a729fccde4ff1" { + t.Errorf("trace id = %s, want gram derivation for toolu_01ABC", got) + } + // The handler denied, but a deny is successful enforcement, not a rail + // fault: severity stays INFO. + if pr.GetSeverityText() != "INFO" { + t.Errorf("severity = %q, want INFO", pr.GetSeverityText()) + } +} + +func TestWithTelemetryRecordStaysObservationalUnderPolicy(t *testing.T) { + rec, collect := newTestTelemetry(t) + r := quietRunner(WithTelemetry(rec), WithPolicy(Policy{Unsupported: Degrade, AskFallback: FallbackDeny})) + r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return AskUser("confirm?"), nil + }) + out, _ := runWith(t, r, []string{"agenthooks", "run", "--provider=codex"}, fixture(t, "codex/pre_tool_use.json")) + if !strings.Contains(out, `"permissionDecision":"deny"`) { + t.Fatalf("ask should degrade to deny on codex: %q", out) + } + + records := collect() + if len(records) != 1 { + t.Fatalf("recorded records = %d, want 1", len(records)) + } + // Neither the handler's ask nor the degraded deny reaches the record: + // it stays a pure observation of the event. + attrs := telemetryAttrs(records[0]) + requireNoDecisionAttrs(t, attrs) + if attrs["event.name"] != "tool.pre" { + t.Errorf("event identity wrong: %v", attrs) + } + // gram's hashToolCallIDToTraceID("call_9"). + if got := hex.EncodeToString(records[0].GetTraceId()); got != "5e447f59d541311dada70f8d9d26d0e3" { + t.Errorf("trace id = %s, want gram derivation for call_9", got) + } +} + +func TestWithTelemetryRecordsHandlerError(t *testing.T) { + rec, collect := newTestTelemetry(t) + r := quietRunner(WithTelemetry(rec)) + r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return NoDecision(), errors.New("boom: handler exploded") + }) + // Default FailOpen policy: the wire response stays a no-op. + out, code := runWith(t, r, claudeArgs(), fixture(t, "claude/pre_tool_use.json")) + if code != 0 { + t.Fatalf("fail-open handler error must not change the exit code: %q (exit %d)", out, code) + } + + records := collect() + if len(records) != 1 { + t.Fatalf("records must fire even when the handler errors: got %d", len(records)) + } + pr := records[0] + attrs := telemetryAttrs(pr) + if got, _ := attrs["agenthooks.handler.error"].(string); !strings.Contains(got, "handler exploded") { + t.Errorf("agenthooks.handler.error = %v, want the handler failure", attrs["agenthooks.handler.error"]) + } + if attrs["error.type"] != "handler_error" { + t.Errorf("error.type = %v, want handler_error", attrs["error.type"]) + } + if pr.GetSeverityText() != "ERROR" { + t.Errorf("handler-error severity = %q, want ERROR", pr.GetSeverityText()) + } + requireNoDecisionAttrs(t, attrs) +} + +func TestWithTelemetryRecordsUnmappedNative(t *testing.T) { + rec, collect := newTestTelemetry(t) + r := quietRunner(WithTelemetry(rec)) + if out, code := runWith(t, r, claudeArgs(), fixture(t, "claude/setup.json")); code != 0 { + t.Fatalf("unmapped native must no-op cleanly: %q (exit %d)", out, code) + } + + records := collect() + if len(records) != 1 { + t.Fatalf("recorded records = %d, want 1", len(records)) + } + attrs := telemetryAttrs(records[0]) + // The native name rides verbatim; the unified identity classifies as + // other. so unmapped natives do not collapse into a + // single gram URN type. + if attrs["gram.hook.event"] != "Setup" || attrs["event.name"] != "other.setup" { + t.Errorf("unmapped-native identity wrong: %v", attrs) + } + if records[0].GetEventName() != "other.setup" { + t.Errorf("EventName field = %q, want other.setup", records[0].GetEventName()) + } + if attrs["session.id"] != "sess-claude-1" { + t.Errorf("session id wrong: %v", attrs) + } + requireNoDecisionAttrs(t, attrs) +} + +func TestWithTelemetryRecordsBackfilledPrompt(t *testing.T) { + rec, collect := newTestTelemetry(t) + r := quietRunner(WithTelemetry(rec), WithDedupDir(t.TempDir())) + if out, code := runWith(t, r, []string{"agenthooks", "run", "--provider=kimi-code"}, kimiPre("sess-tel-bf")); code != 0 { + t.Fatalf("run failed: %q (exit %d)", out, code) + } + + // The synthesized reporting-only prompt.submitted records before the + // triggering tool.pre, flagged as backfilled (nil Raw, no prompt text + // recovered here — the record still forms). + records := collect() + if len(records) != 2 { + t.Fatalf("recorded records = %d, want 2 (backfilled prompt + tool.pre)", len(records)) + } + prompt := telemetryAttrs(records[0]) + if prompt["event.name"] != "prompt.submitted" || prompt["agenthooks.event.backfilled"] != true { + t.Errorf("backfilled prompt record wrong: %v", prompt) + } + requireNoDecisionAttrs(t, prompt) + toolPre := telemetryAttrs(records[1]) + if toolPre["event.name"] != "tool.pre" { + t.Errorf("triggering event record wrong: %v", toolPre) + } + if _, ok := toolPre["agenthooks.event.backfilled"]; ok { + t.Errorf("real events must not carry the backfilled flag: %v", toolPre) + } +} + +// captureRecorder is a minimal TelemetryRecorder for tap-boundary tests. +type captureRecorder struct { + records atomic.Int32 + shutdown atomic.Bool + fail bool +} + +func (c *captureRecorder) RecordHook(*hookrecord.Record) error { + c.records.Add(1) + if c.fail { + return errors.New("recorder unavailable") + } + return nil +} + +func (c *captureRecorder) Shutdown(context.Context) error { + c.shutdown.Store(true) + return nil +} + +func TestWithTelemetryFailingRecorderKeepsWire(t *testing.T) { + rec := &captureRecorder{fail: true} + r := quietRunner(WithTelemetry(rec)) + r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Deny("blocked"), nil + }) + out, code := runWith(t, r, claudeArgs(), fixture(t, "claude/pre_tool_use.json")) + want := `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"blocked"}}` + if out != want || code != 0 { + t.Errorf("failing recorder must never change the wire response: %q (exit %d)", out, code) + } + if rec.records.Load() != 1 { + t.Errorf("tap must still deliver to the failing recorder") + } +} + +func TestTelemetryTapPanicIsContained(t *testing.T) { + r := quietRunner() + r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Deny("blocked"), nil + }) + r.afterEvent = func(any, *Event, recordTiming, error) { + panic("recorder bug") + } + out, code := runWith(t, r, claudeArgs(), fixture(t, "claude/pre_tool_use.json")) + if code != 0 || !strings.Contains(out, `"deny"`) { + t.Errorf("tap panic must not leak into the wire: %q (exit %d)", out, code) + } +} + +func TestWithTelemetryTypedNilRecorderIsNoOp(t *testing.T) { + var rec *telemetry.Recorder + r := quietRunner(WithTelemetry(rec)) + if r.afterEvent != nil || r.telemetryShutdown != nil { + t.Errorf("typed-nil recorder must not install the tap or the shutdown hook") + } +} + +func TestServeLoopTapsTelemetry(t *testing.T) { + rec, collect := newTestTelemetry(t) + r := quietRunner(WithTelemetry(rec)) + r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { + return Deny("no bash in this session"), nil + }) + lines := []string{ + `{"seq":1,"hook":"initialize","input":{"serverUrl":"http://127.0.0.1:1","directory":"/work","worktree":""}}`, + strings.TrimSpace(string(fixture(t, "opencode/tool_execute_before.json"))), + } + var out, errb strings.Builder + code := r.Run(context.Background(), []string{"agenthooks", "serve", "--provider=opencode"}, + strings.NewReader(strings.Join(lines, "\n")+"\n"), &out, &errb) + if code != 0 { + t.Fatalf("serve exit %d, stderr: %s", code, errb.String()) + } + + records := collect() + if len(records) != 1 { + t.Fatalf("recorded records = %d, want 1 (initialize is not a hook event)", len(records)) + } + attrs := telemetryAttrs(records[0]) + if attrs["event.name"] != "tool.pre" || attrs["gram.hook.source"] != "opencode" { + t.Errorf("serve-loop record wrong: %v", attrs) + } + requireNoDecisionAttrs(t, attrs) +} diff --git a/serve.go b/serve.go index 34396e6..bcf6926 100644 --- a/serve.go +++ b/serve.go @@ -104,11 +104,15 @@ func (r *Runner) serve(ctx context.Context, inv *invocation, stdin io.Reader, st r.logger.Error("agenthooks: encode failed", "hook", fr.Hook, "error", encErr) reply = &opencodeReply{} } + encodedAt := r.now() reply.Seq = fr.Seq if err := enc.Encode(reply); err != nil { r.logger.Error("agenthooks: writing reply", "error", err) return 1 } + // Telemetry taps in after the reply is on the wire (§4.2): + // observational only, never the decision. + r.tapAfterEvent(typed, base, herr, encodedAt) } if err := sc.Err(); err != nil { r.logger.Error("agenthooks: reading shim stream", "error", err) diff --git a/server.go b/server.go new file mode 100644 index 0000000..d03afc0 --- /dev/null +++ b/server.go @@ -0,0 +1,301 @@ +package agenthooks + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/speakeasy-api/agenthooks/internal/filelock" + "github.com/speakeasy-api/agenthooks/internal/ipc" +) + +// The `agenthooks server` mode: a long-running singleton per consumer +// identity (executable + pre-sentinel flags, see internal/ipc) that hosts +// the full pipeline — handlers, caches, warm HTTP connections, and the +// in-process telemetry recorder — across hook invocations. Each connection +// carries exactly one framed request (a hook event with its argv, payload, +// and environment snapshot) and receives one framed response (the +// provider-dialect stdout/stderr/exit code), which the paired +// `agenthooks client` relays to the provider. +// +// The server is spawned on demand by the first client that finds no +// listener, stays up while hooks keep arriving, and shuts itself down — +// flushing telemetry — after an idle period, on SIGINT/SIGTERM, or when a +// client built from a different binary shows up (the upgrade path: finish +// in-flight work, exit, let the respawn run the new code). +// +// Handlers registered on the Runner run concurrently here (one goroutine +// per connection), so consumer handlers used in client/server installs must +// be safe for concurrent use. The library's own cross-invocation state +// already is: dedup markers, backfill markers, and MCP inventory caches are +// file-based with O_EXCL/flock semantics, which serialize across goroutines +// exactly as they do across processes. + +// defaultIdleTimeout is how long the server lingers without a connection +// before shutting down; override with --idle-timeout= or the +// AGENTHOOKS_SERVER_IDLE_TIMEOUT environment variable (flag wins). +const defaultIdleTimeout = 10 * time.Minute + +// serveConnTimeout bounds one connection end to end: frame read, pipeline +// (whose handler deadlines are the same as run mode's), frame write. +const serveConnTimeout = 5 * time.Minute + +// shutdownFlushTimeout bounds the telemetry flush on shutdown. +const shutdownFlushTimeout = 10 * time.Second + +// serverMain implements the `agenthooks server` argv mode. It returns 0 +// when another server already owns the endpoint (the spawn raced), after a +// clean shutdown, and 1 only for hard setup failures. +func (r *Runner) serverMain(ctx context.Context, inv *invocation, stderr io.Writer) int { + exe, err := os.Executable() + if err != nil { + _, _ = fmt.Fprintf(stderr, "agenthooks: server: resolving executable: %v\n", err) + return 1 + } + addr, err := ipc.Resolve(exe, inv.preArgs) + if err != nil { + _, _ = fmt.Fprintf(stderr, "agenthooks: server: %v\n", err) + return 1 + } + + // The endpoint bind is the real mutual exclusion; the file lock is + // belt-and-braces (and lets Listen distinguish a crashed server's stale + // socket from a live one). A lock error degrades to bind-only. + release, locked, err := filelock.TryLock(addr.ServerLock) + if err != nil { + r.logger.Warn("agenthooks: server lock unavailable; relying on the endpoint bind", "error", err) + } else if !locked { + return 0 // another server is already running + } + releaseOnce := sync.OnceFunc(release) + defer releaseOnce() + + ln, err := ipc.Listen(addr.Endpoint) + if errors.Is(err, ipc.ErrAlreadyRunning) { + return 0 + } + if err != nil { + _, _ = fmt.Fprintf(stderr, "agenthooks: server: binding %s: %v\n", addr.Endpoint, err) + return 1 + } + + idle := inv.idleTimeout + if idle <= 0 { + if env := os.Getenv("AGENTHOOKS_SERVER_IDLE_TIMEOUT"); env != "" { + idle, _ = time.ParseDuration(env) + } + } + if idle <= 0 { + idle = defaultIdleTimeout + } + + srv := &hookServer{ + runner: r, + stamp: ipc.BuildStamp(exe), + lastActivity: time.Now(), + shutdown: make(chan struct{}), + } + r.logger.Debug("agenthooks: server listening", "endpoint", addr.Endpoint, "idle_timeout", idle) + srv.run(ctx, ln, idle) + + // Release the rendezvous before the flush so an upgrade respawn can + // bind while this process drains its telemetry queue. + _ = ln.Close() // net.UnixListener unlinks the socket file on Close + releaseOnce() + srv.wg.Wait() + if r.telemetryShutdown != nil { + fctx, cancel := context.WithTimeout(context.Background(), shutdownFlushTimeout) + defer cancel() + if err := r.telemetryShutdown(fctx); err != nil { + r.logger.Warn("agenthooks: telemetry flush on shutdown failed", "error", err) + } + } + return 0 +} + +// hookServer is the accept-loop state of one server process. +type hookServer struct { + runner *Runner + stamp string // this process's executable fingerprint + + wg sync.WaitGroup + shutdownOnce sync.Once + shutdown chan struct{} + + mu sync.Mutex + active int + lastActivity time.Time +} + +func (s *hookServer) requestShutdown() { + s.shutdownOnce.Do(func() { close(s.shutdown) }) +} + +func (s *hookServer) connOpened() { + s.mu.Lock() + s.active++ + s.lastActivity = time.Now() + s.mu.Unlock() +} + +func (s *hookServer) connClosed() { + s.mu.Lock() + s.active-- + s.lastActivity = time.Now() + s.mu.Unlock() +} + +func (s *hookServer) idleSince(limit time.Duration) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.active == 0 && time.Since(s.lastActivity) >= limit +} + +// run accepts connections until a shutdown trigger fires: idle timeout, +// SIGINT/SIGTERM, context cancellation, or a version-mismatch drain. +func (s *hookServer) run(ctx context.Context, ln net.Listener, idle time.Duration) { + // Every exit path marks the server as shutting down so the accept + // goroutine can never stay blocked handing over a late connection. + defer s.requestShutdown() + conns := make(chan net.Conn) + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed (shutdown) or fatal accept error + } + select { + case conns <- conn: + case <-s.shutdown: + _ = conn.Close() + return + } + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sig) + + tick := max(idle/4, 10*time.Millisecond) + idleTicker := time.NewTicker(tick) + defer idleTicker.Stop() + + for { + select { + case conn := <-conns: + s.connOpened() + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer s.connClosed() + s.serveConn(ctx, conn) + }() + case <-idleTicker.C: + if s.idleSince(idle) { + s.runner.logger.Debug("agenthooks: server idle; shutting down") + return + } + case <-sig: + s.requestShutdown() + return + case <-ctx.Done(): + return + case <-s.shutdown: + return + case <-acceptDone: + return + } + } +} + +// serveConn handles one framed request/response exchange. +func (s *hookServer) serveConn(ctx context.Context, conn net.Conn) { + defer func() { _ = conn.Close() }() + defer func() { + // The pipeline guards handler panics itself; this guard keeps a + // decode/transport panic on one connection from killing the server. + if p := recover(); p != nil { + s.runner.logger.Error("agenthooks: connection handler panic", "panic", p) + } + }() + _ = conn.SetDeadline(time.Now().Add(serveConnTimeout)) + + var req ipc.Request + if err := ipc.ReadFrame(conn, &req); err != nil { + s.runner.logger.Warn("agenthooks: reading request frame", "error", err) + return + } + if req.V != ipc.ProtocolVersion { + s.respond(conn, ipc.Response{ + V: ipc.ProtocolVersion, + Error: fmt.Sprintf("protocol version mismatch: server speaks %d, client sent %d", ipc.ProtocolVersion, req.V), + ExitCode: 1, + }) + return + } + if req.Build != "" && s.stamp != "" && req.Build != s.stamp { + // The client runs a different build of this binary: serve this + // request, then drain and exit so the next spawn picks up the new + // executable — the LSP-style upgrade story. + s.runner.logger.Debug("agenthooks: client build differs; draining after this request", "client", req.Build, "server", s.stamp) + defer s.requestShutdown() + } + + inv, err := parseArgs(req.Argv) + if err != nil { + s.respond(conn, ipc.Response{V: ipc.ProtocolVersion, Stderr: []byte(err.Error() + "\n"), ExitCode: 64}) + return + } + var payload []byte + if inv.mode == "notify" || inv.argvPayload { + payload = []byte(inv.payload) + } else { + payload = req.Stdin + if len(payload) > maxPayloadBytes { + payload = payload[:maxPayloadBytes] + } + } + + acked := false + opts := runOpts{ + getenv: func(key string) string { return req.Env[key] }, + earlyAck: func(wire wireResponse) { + acked = true + s.respond(conn, ipc.Response{ + V: ipc.ProtocolVersion, + Stdout: wire.Stdout, + Stderr: wire.Stderr, + ExitCode: wire.ExitCode, + }) + _ = conn.Close() // the client is free; processing continues here + }, + } + var stdout, stderrBuf bytes.Buffer + code := s.runner.runEvent(ctx, inv, payload, opts, &stdout, &stderrBuf) + if acked { + return + } + s.respond(conn, ipc.Response{ + V: ipc.ProtocolVersion, + Stdout: stdout.Bytes(), + Stderr: stderrBuf.Bytes(), + ExitCode: code, + }) +} + +func (s *hookServer) respond(conn net.Conn, resp ipc.Response) { + if err := ipc.WriteFrame(conn, resp); err != nil { + s.runner.logger.Warn("agenthooks: writing response frame", "error", err) + } +} diff --git a/testdata/hookbin/main.go b/testdata/hookbin/main.go new file mode 100644 index 0000000..a5aa915 --- /dev/null +++ b/testdata/hookbin/main.go @@ -0,0 +1,29 @@ +// Command hookbin is the subprocess fixture for the client/server +// end-to-end test (clientserver_e2e_test.go): a minimal consumer binary +// whose tool.pre handler denies and appends its process id to +// $HOOKBIN_LOG. Two client invocations answered by the same pid prove the +// decisions came from one long-running auto-spawned server rather than two +// in-process fallbacks. +package main + +import ( + "context" + "fmt" + "os" + + "github.com/speakeasy-api/agenthooks" +) + +func main() { + r := agenthooks.New() + r.OnToolPre(func(ctx context.Context, e *agenthooks.ToolPreEvent) (agenthooks.ToolPreDecision, error) { + if path := os.Getenv("HOOKBIN_LOG"); path != "" { + if f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil { + _, _ = fmt.Fprintf(f, "%d\n", os.Getpid()) + _ = f.Close() + } + } + return agenthooks.Deny("denied by hookbin"), nil + }) + agenthooks.Main(r) +} From 065a818c0b99471aa27c8129ef49b911d2d59817 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Wed, 5 Aug 2026 19:19:14 -0700 Subject: [PATCH 3/9] feat(install): render `agenthooks client` hook commands, retire --async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated configs now invoke the client verb so every hook rides the server (with in-process fallback). Codex's --async annotation is dropped: the server's early-ack answers non-gating events immediately, which is what the detached re-exec worked around. Claude's native async flag stays — it is provider-level, not the quirk-#10 mitigation. Co-authored-by: Cursor --- install/install.go | 7 +++++-- install/install_test.go | 23 ++++++++++++----------- install/render_codex.go | 10 ++++------ 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/install/install.go b/install/install.go index bba7ceb..f093cda 100644 --- a/install/install.go +++ b/install/install.go @@ -328,13 +328,16 @@ func mergeManagedJSON(existing, rendered []byte) ([]byte, error) { // hookCommand renders the shell command a provider config invokes, including // the argv contract (--provider, --timeout) and the in-process --filter for -// dialects that can't express the matcher. +// dialects that can't express the matcher. The verb is `client`: the hook +// process forwards the event to the long-running hook server (auto-spawned +// on first use) and relays its decision; when no server can be reached it +// runs the same pipeline in-process, so behavior degrades to `run`. func hookCommand(m Manifest, p agenthooks.Provider, spec HookSpec) string { parts := make([]string, 0, len(m.Command)+5) for _, c := range m.Command { parts = append(parts, shellQuote(c)) } - parts = append(parts, "agenthooks", "run", "--provider="+string(p)) + parts = append(parts, "agenthooks", "client", "--provider="+string(p)) if spec.Timeout > 0 { parts = append(parts, "--timeout="+spec.Timeout.String()) } diff --git a/install/install_test.go b/install/install_test.go index 737d9d1..23c2049 100644 --- a/install/install_test.go +++ b/install/install_test.go @@ -72,7 +72,7 @@ func TestRenderClaudePlugin(t *testing.T) { t.Fatalf("PreToolUse entry wrong: %+v", pre) } cmd := pre[0].Hooks[0] - if !strings.Contains(cmd.Command, "agenthooks run --provider=claude-code") || cmd.Timeout != 30 || cmd.Async { + if !strings.Contains(cmd.Command, "agenthooks client --provider=claude-code") || cmd.Timeout != 30 || cmd.Async { t.Errorf("PreToolUse command wrong: %+v", cmd) } // quirk #1: Stop is forced synchronous even for telemetry hooks. @@ -167,7 +167,7 @@ func TestRenderGeminiMilliseconds(t *testing.T) { } } -func TestRenderCodexAsyncAndTrust(t *testing.T) { +func TestRenderCodexClientAndTrust(t *testing.T) { m := testManifest() m.Hooks = append(m.Hooks, HookSpec{Kind: agenthooks.KindSessionEnd, Blocking: false, Timeout: 60 * time.Second}) fsys, err := Render(m, Target{Provider: agenthooks.ProviderCodex, Scope: ScopeUser, Dir: "/codex-home"}) @@ -178,11 +178,12 @@ func TestRenderCodexAsyncAndTrust(t *testing.T) { if err := json.Unmarshal(readRendered(t, fsys, "hooks.json"), &cfg); err != nil { t.Fatal(err) } - // Codex parses-but-skips async (quirk #10): telemetry hooks get --async - // so the runner detaches itself; no shell wrapper anywhere. + // Codex parses-but-skips async (quirk #10). The client verb covers it: + // the server early-acks non-gating events, so nothing renders --async + // (the retired detached self re-exec) and no shell wrapper appears. post := cfg.Hooks["PostToolUse"][0].Hooks[0] - if !strings.HasSuffix(post.Command, " --async") || strings.Contains(post.Command, "/bin/sh") { - t.Errorf("non-blocking codex hook must render --async without a shell: %s", post.Command) + if !strings.Contains(post.Command, "agenthooks client --provider=codex") || strings.Contains(post.Command, "--async") || strings.Contains(post.Command, "/bin/sh") { + t.Errorf("non-blocking codex hook must render the client verb without --async or a shell: %s", post.Command) } preEntry := cfg.Hooks["PreToolUse"][0] pre := preEntry.Hooks[0] @@ -191,8 +192,8 @@ func TestRenderCodexAsyncAndTrust(t *testing.T) { } sessionEndEntry := cfg.Hooks["SessionEnd"][0] sessionEnd := sessionEndEntry.Hooks[0] - if sessionEnd.Timeout != 3 || !strings.HasSuffix(sessionEnd.Command, " --async") { - t.Errorf("SessionEnd must detach within Codex's 3-second teardown budget: %+v", sessionEnd) + if sessionEnd.Timeout != 3 { + t.Errorf("SessionEnd must keep Codex's 3-second teardown budget: %+v", sessionEnd) } // Trust state keys are "/hooks.json:::" @@ -265,7 +266,7 @@ func TestInstallIdempotentAndMergePreservesForeignEntries(t *testing.T) { if !strings.Contains(string(merged), "other-tool check") || !strings.Contains(string(merged), `"FOO"`) { t.Errorf("foreign config must survive merge:\n%s", merged) } - if !strings.Contains(string(merged), "agenthooks run --provider=claude-code") { + if !strings.Contains(string(merged), "agenthooks client --provider=claude-code") { t.Errorf("managed hooks missing after merge:\n%s", merged) } @@ -311,7 +312,7 @@ func TestRenderKimiTOML(t *testing.T) { if !strings.Contains(toml, `event = "PreToolUse"`) || !strings.Contains(toml, `matcher = "Bash"`) { t.Errorf("PreToolUse entry wrong:\n%s", toml) } - if !strings.Contains(toml, "agenthooks run --provider=kimi-code") { + if !strings.Contains(toml, "agenthooks client --provider=kimi-code") { t.Errorf("argv contract missing:\n%s", toml) } if !strings.Contains(toml, "timeout = 30") { @@ -346,7 +347,7 @@ func TestInstallKimiMergePreservesForeignTOML(t *testing.T) { if !strings.Contains(string(merged), "terminal-notifier") { t.Errorf("foreign hook must survive merge:\n%s", merged) } - if !strings.Contains(string(merged), "agenthooks run --provider=kimi-code") { + if !strings.Contains(string(merged), "agenthooks client --provider=kimi-code") { t.Errorf("managed hooks missing after merge:\n%s", merged) } diff --git a/install/render_codex.go b/install/render_codex.go index bb4a4fe..bd162b1 100644 --- a/install/render_codex.go +++ b/install/render_codex.go @@ -47,13 +47,11 @@ func renderCodex(m Manifest, t Target) (fs.FS, error) { continue } matcher, _ := agenthooks.CompileMatcher(agenthooks.ProviderCodex, spec.Tools) + // Codex parses-but-skips async:true (quirk #10). The old mitigation + // rendered --async on non-blocking hooks (a detached self re-exec); + // with the client verb the server's early-ack answers non-gating + // events immediately, so no annotation is needed. command := hookCommand(m, agenthooks.ProviderCodex, spec) - // Codex parses-but-skips async:true (quirk #10): telemetry hooks get - // --async, which makes the runner re-exec itself as a detached worker - // and return immediately — no shell involved. - if !spec.Blocking { - command += " --async" - } secs := timeoutSeconds(spec) if event == "SessionEnd" && secs > 3 { secs = 3 From bd0144c6b3b997dc96110db1f0987060baa2c820 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Wed, 5 Aug 2026 19:27:56 -0700 Subject: [PATCH 4/9] docs: rewrite RFC-telemetry delivery for client/server; document the new runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC-telemetry.md rides along at rev 3: §4.5 (disk spool) becomes the in-process batch pipeline, §4.7 (exporter lifecycle) becomes the hook server's lifecycle and flush points, and the spool/exporter daemon moves to §8 as a superseded alternative. The record model (§3, §4.3), identity derivation (§4.4), and gram-side sections (§5, §6 semantics) stand. DESIGN.md §6 documents the client/server runtime, the concurrency contract, and the retired --async workaround; README follows. Co-authored-by: Cursor --- DESIGN.md | 60 ++- README.md | 12 +- RFC-telemetry.md | 1187 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1244 insertions(+), 15 deletions(-) create mode 100644 RFC-telemetry.md diff --git a/DESIGN.md b/DESIGN.md index 0395359..af9de44 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -76,7 +76,10 @@ Secondary findings that shape the design (full quirk registry in §9): Non-goals for v1 (§11): auth/login flows, HTTP transport to a decision server, transcript capture pipelines. These are consumer concerns layered *on top of* -this library, not inside it. +this library, not inside it. (Observability is the exception: the opt-in +`telemetry` package emits one OTel log record per hook event, batched and +shipped in-process by the long-running hook server — see §11 and +RFC-telemetry.md.) --- @@ -514,7 +517,9 @@ One binary, several invocation modes, selected by argv the generated configs control: ``` -mybinary agenthooks run --provider=claude-code # process-per-event, stdin JSON (claude, codex, gemini, cursor) +mybinary agenthooks client --provider=claude-code # per-hook process: forwards to the hook server, relays the decision (default install) +mybinary agenthooks server [--idle-timeout=10m] # long-running singleton hosting the pipeline (auto-spawned by client) +mybinary agenthooks run --provider=claude-code # process-per-event, stdin JSON — the client's in-process fallback path mybinary agenthooks run --provider=cursor --argv-payload # legacy cursor-agent CLI (<2026-05-20): payload in argv mybinary agenthooks notify --provider=codex # legacy codex notify: kebab-case JSON in argv[1] mybinary agenthooks serve --provider=opencode # long-lived daemon for the OpenCode shim (§8) @@ -527,6 +532,27 @@ this is a library.) Runtime responsibilities per mode: +- **client/server (the language-server model)**: generated configs install + the `client` verb. The client reads the payload and forwards the whole + invocation — argv, stdin, an allowlisted env snapshot, cwd — over a + unix socket (Windows: named pipe) to a singleton `server` process that + hosts the Runner across invocations: handlers, MCP/inventory caches, + warm HTTP connections, and the opt-in telemetry recorder. The + rendezvous is derived from the consumer identity (executable path + + the flags before the `agenthooks` sentinel, e.g. `--config=...`), so + distinct deployments get distinct servers (`internal/ipc`). No server? + The client re-execs itself as one (detached, spawn-lock serialized) + and retries for ~2 s; any failure past that falls back to running the + identical pipeline in-process — decisions never depend on server + health. The server early-acks non-gating events right after decode + (processing continues async), shuts down after an idle period or on + SIGINT/SIGTERM (flushing telemetry), and drains-and-exits when a + client built from a newer binary connects, so upgrades roll forward on + the next spawn. Handlers run one-goroutine-per-connection on the + server, so consumer handlers in client/server installs must be safe + for concurrent use; the library's own cross-process state (dedup + markers, backfill markers, MCP inventory caches) is file-locked and + already safe. - **stdin mode**: read payload, enforce a deadline slightly under the provider-configured timeout (so we always answer rather than get killed mid-write), decode → dispatch → encode, exit with the dialect-correct code. @@ -581,9 +607,10 @@ Per-target rendering encodes the workaround knowledge: correct empty-response shape is still emitted. - **Codex**: `hooks.json` (or `config.toml` tables) + **trust pre-seeding**: reimplementation of Codex's definition-hash fingerprint so installs can - write `[hooks.state]` trusted hashes. `Blocking: false` renders the - tee-to-tmpfile backgrounder wrapper (Codex parses-but-skips `async: true`). - Emits nothing on stdout for allow. + write `[hooks.state]` trusted hashes. Codex parses-but-skips + `async: true`, but no annotation is needed: the rendered `client` verb's + server early-acks non-gating events (§6), so telemetry hooks return + immediately anyway. Emits nothing on stdout for allow. - **Gemini**: `settings.json` fragment with `name`/`description` (enables `/hooks enable|disable` UX), timeouts converted to **milliseconds**, matcher dialect `mcp_server_tool`. @@ -642,7 +669,7 @@ upstream reference. Seeded from provider research and production observation: | 7 | Cursor fail-open default; crashed hook allows action | `failClosed` in generated config on decision events | | 8 | Codex: empty stdout = allow; unknown JSON rejected; `ask`/`approve` fail the hook run | codec emits exact dialect; Ask degrades per policy | | 9 | Codex hooks require user trust of definition hash | install pre-seeds `[hooks.state]` trusted hashes | -| 10 | Codex has no async hooks (`async` parsed-but-skipped) | generated backgrounder wrapper for telemetry events | +| 10 | Codex has no async hooks (`async` parsed-but-skipped) | client mode: the hook server early-acks non-gating events after decode (§6); the in-process fallback runs them synchronously (best-effort) | | 11 | Gemini: exit codes ≠ docs (any non-zero except 1 blocks); stderr parsed as decision when stdout empty | runner always writes explicit JSON to stdout; never bare non-zero exits | | 12 | Gemini `hookSpecificOutput.tool_input` is shallow-merge | `ErrLossyUpdate` when a rewrite deletes keys; docs marker | | 13 | Gemini `additionalContext` HTML-escapes `<`/`>` | documented loss; optional pre-encoding | @@ -692,21 +719,32 @@ in CI without the actual agents. - **Auth, login, identity** (browser flows, device agents, credential caches): consumer concerns built *on* this library. agenthooks provides the - hook I/O substrate those flows plug into. + hook I/O substrate those flows plug into. The `telemetry` package takes an + endpoint plus static headers as config; acquiring those credentials stays + a consumer concern. - **HTTP/decision-server transport**: a server-authoritative decision model is a consumer of this library — the handler body does the POST. (Claude's native `http` hook type is a possible later `install` target.) + Observability is the exception: the opt-in `telemetry` package emits one + OTel log record per hook event to any OTLP/HTTP logs endpoint — batched + in the long-running hook server (§6) and shipped in the background, so + the decision path never gains a network dependency. - **Transcript capture/dedup pipelines**: the `transcript` package gives - parsing primitives; pipelines belong to consumers. + parsing primitives; pipelines belong to consumers. The `telemetry` package + records event *content* (prompt text, tool IO, assistant messages) only at + an explicit opt-in capture level with built-in credential redaction — off + by default; it is telemetry, not a transcript product. - **A standalone hooks CLI / config-file DSL**: library-first; Go is the DSL. - **In-process Agent-SDK hooks** (Claude Agent SDK callbacks): different runtime model; possible future `sdkbridge` package. ## 12. Open questions -1. **Handler concurrency**: one event per process makes this moot except in - OpenCode serve mode — serialize per session (matching OpenCode's sequential - semantics) or allow parallel with consumer opt-in? +1. **Handler concurrency**: resolved by the client/server architecture — + the hook server runs one goroutine per connection, so handlers in + client/server installs must be goroutine-safe (documented in §6). The + OpenCode serve loop stays sequential per session, matching OpenCode's + semantics. 2. **Version pinning**: do we gate dialect features on detected provider versions (Cursor camelCase era, Claude matcher version gates §Claude 2.1.19x) or always emit the modern + harmless-legacy superset? Proposal: superset diff --git a/README.md b/README.md index ea0be26..5ba078e 100644 --- a/README.md +++ b/README.md @@ -148,10 +148,14 @@ err := install.Install(ctx, m, install.Target{ }) ``` -Generated configs bake in the argv contract (`mybinary agenthooks run ---provider=...`), per-provider timeout units, async workarounds (sync `Stop` -on Claude cowork, backgrounder wrapper on Codex), Cursor `failClosed`, and -Codex trust-hash pre-seeding. +Generated configs bake in the argv contract (`mybinary agenthooks client +--provider=...` — a lightweight per-hook client that forwards each event to +an auto-spawned, long-running `agenthooks server` singleton and relays its +decision, degrading to the in-process `run` pipeline whenever the server is +unreachable), per-provider timeout units, async workarounds (sync `Stop` on +Claude cowork; non-gating events are early-acked by the server), Cursor +`failClosed`, and Codex trust-hash pre-seeding. Handlers run concurrently +on the server, so they must be safe for concurrent use. ## Semantics worth knowing diff --git a/RFC-telemetry.md b/RFC-telemetry.md new file mode 100644 index 0000000..ff96341 --- /dev/null +++ b/RFC-telemetry.md @@ -0,0 +1,1187 @@ +# RFC: Decoupled agent-side telemetry (OTel logs) and backend enforcement logging + +- **Status:** Draft, for review (rev 3 — client/server delivery, superseding the spool/exporter draft) +- **Author:** agenthooks maintainers (drafted from joint research of `agenthooks` and `gram`) +- **Scope:** Both repos — `github.com/speakeasy-api/agenthooks` (this repo) and the `gram` backend (`/Users/subomi/code/gram`, referenced read-only) +- **Date:** 2026-08-05 + +> **Rev 3 note:** delivery moved from a disk spool + externally supervised +> exporter daemon (rev 2's §4.5/§4.7) to the library's **client/server +> architecture**: hooks are installed as a lightweight `agenthooks client` +> that forwards each event to a long-running, auto-spawned `agenthooks +> server` hosting the full pipeline — and the telemetry recorder now lives +> in that server as an ordinary in-process OTel batch pipeline +> (`BatchProcessor` + `otlploghttp`), flushed on the server's shutdown +> points. The record model (§4.3), deterministic identity (§4.4), capture +> levels (§4.6), and everything gram-side (§5, §6) carry over unchanged. +> The durability boundary moves from disk to the server process: records +> lost to a server crash are accepted as best-effort observability. + +> **Rev 2 note:** the primary signal is now **OTel log records (wide events)** +> shipped over OTLP to gram's **existing** `/rpc/hooks.otel/v1/logs` ingest, +> instead of spans to a new traces endpoint. Gram has substantially built out +> its logs processing (attribute normalization, attribution, `telemetry_logs` +> wide-event storage, URN classification); logs-first reuses all of it and +> eliminates most of the backend work the traces draft required. Traces move +> to §8 (alternatives / future evolution); the design keeps deterministic +> trace-context identity so spans can be added later without re-keying. + +--- + +## 1. Background & problem: observability is coupled to enforcement + +Today the hook binary built on this library (`speakeasy-hooks`, at +`gram/hooks/cmd/speakeasy-hooks/main.go`) projects **every** hook event into +the Gram ingest contract and POSTs it synchronously to the backend. The +backend uses that one request for two unrelated jobs: + +1. **Enforcement** — deciding allow/deny/warn for gating events. +2. **Observability** — deriving essentially all coding-agent telemetry + (ClickHouse wide events, Postgres session transcripts, usage metrics, + MCP inventory, skill observations) from the same payload. + +Concrete code paths showing the coupling: + +| Path | What it shows | +|---|---| +| `gram/hooks/relay/runner.go:64-66` | The consumer's own comment of record: *"gating events (prompt.submitted, tool.requested) POST synchronously and honor deny; every other event is relayed as fire-and-forget telemetry."* "Fire-and-forget" means *ignore the verdict*, not *skip the network* — observe-only events still make a synchronous HTTP call (45 s budget, `gram/hooks/relay/client.go:22-28`) unless the provider config detached the whole hook process. | +| `gram/hooks/relay/envelope.go:138-161` (`buildEnvelope`) | One envelope (`hook.ingest.v1`, `IngestRequestBody`) carries both decision inputs and observability freight: `source`, `session`, `event`, feature blocks (`prompt`, `tool_call`, `mcp`, `mcp_inventory`, `usage`, `message`, `skill`, `notification`, `mcp_attribution`) and the scrubbed `raw` provider payload. | +| `gram/server/design/hooks/design.go:267-288` | `IngestHookPayload` / `IngestHookResult`: the *enforcement response* (`decision: allow|deny`) is returned from the same endpoint that receives the observability payload (`POST /rpc/hooks.ingest`). | +| `gram/server/internal/hooks/ingest_hooks.go:55` (`Ingest`) → `:410` (`evaluateCanonicalHook`) → `:622` (`recordCanonicalHook`) → `:734` (`writeCanonicalTelemetry`) → `:868` (`logHookTelemetry`) | The handler evaluates enforcement first, then converts **the same payload** into ClickHouse `telemetry_logs` attributes and Postgres rows. Observability is literally a post-processing step of the enforcement request. | +| `gram/server/internal/hooks/session_capture.go`, `pending_helpers.go`, `claude_hooks.go`, `cursor_hooks.go`, `codex_hooks.go` | The legacy provider endpoints (`/rpc/hooks.claude`, `.cursor`, `.codex`) follow the same dual-use pattern, including `writeClaudeBlockToClickHouse` writing a *block* observability row that shares a hash-derived trace ID with the tool event row. | + +Consequences of the coupling: + +- **Latency:** every observe-only event (tool.post, stop, session.end, + notifications) pays a synchronous round-trip to the control plane from + inside the hook process, bounded only by provider timeouts and the 45 s + send budget. +- **Availability entanglement:** observability data is lost or spooled + whenever the enforcement endpoint is down, and enforcement request size / + schema is held hostage by observability needs (prompt text, assistant + messages, usage, raw payloads all ride the decision request). +- **Second-hand telemetry:** the observability record is whatever the + backend can reconstruct from an enforcement request, stamped server-side + (`writeCanonicalTelemetry`), rather than a first-class record emitted at + the source with real timing. +- **Enforcement is under-logged in its own right:** the decision verdict + lives as attributes stamped onto observability rows derived from the + request (`gram.hook.block_reason` on the event row; `gram.hook.decision` + only on duration *metrics*, `gram/server/internal/attr/conventions.go:336-339`), + plus `tool_call_blocks` in Postgres for denies. If the observability + derivation goes away, enforcement currently loses most of its audit trail. + +### Why logs-first + +Gram already runs a mature OTLP **logs** ingest for coding agents: +`POST /rpc/hooks.otel/v1/logs` (`gram/server/design/hooks/design.go:440-462`), +handled by `Service.Logs` → `writeClaudeOTELLogsToClickHouse` +(`gram/server/internal/hooks/otel.go:25`, `:354`). That path already does, +per log record: + +- attribute normalization into the shared conventions — + `normalizeClaudeLogAttributes` (`otel.go:550-563`) maps `session.id` → + `gen_ai.conversation.id`, `model` → `gen_ai.response.model`, and attribute- + or field-borne trace context onto `trace.id` / `span.id`; +- stamping of `gram.event.source=hook`, project/org, `gram.hook.source` + (product surface), and account attribution (Redis-cached per session, + `otel.go:56-160`); +- body → `gram.log.body`, scope name/version capture, native OTLP + `traceId`/`spanId` fields → `trace.id`/`span.id` attributes + (`otel.go:442-447`); +- bulk write into ClickHouse `telemetry_logs` — the same wide-event table + every hook-derived observability row lands in today + (`gram/server/internal/telemetry/README.md`); +- URN classification per row (`gram/server/internal/telemetry/event_urn.go`). + +Emitting **one OTel log record per hook event, shaped to these conventions**, +lets the agent-side stream drop into the built-out pipeline nearly unchanged: +same table, same materialized columns, same downstream readers. No new +ingest path, no new storage model. + +### What this RFC proposes + +1. **Agent-side telemetry becomes a built-in, opt-in feature of this OSS + library**: a `telemetry` package plus a runner option that emits one + **OpenTelemetry log record (wide event)** per hook event from the + long-running **hook server** process (`agenthooks server`, the + singleton the per-hook `agenthooks client` forwards events to), + batched in memory and shipped over **OTLP/HTTP** in the background — + never adding latency to the hook's critical path, which only ever + waits on the decision. (DESIGN.md §11's non-goals will simply be + rewritten when the package lands — nothing has shipped, so no formal + amendment is carried in this RFC.) +2. **The gram backend logs enforcement itself, at decision time, + independently of the agent-side telemetry stream**, and the enforcement + request slims down to decision-relevant data only. +3. **Cutover, not additive:** once telemetry ships, non-gating events stop + flowing through the enforcement endpoint, and the backend stops deriving + observability from enforcement requests. + +Correlation between the two streams uses the **existing session ID and turn +ID** (`SessionInfo.ID` / `SessionInfo.TurnID`, `event.go:105-114`) plus +trace-context fields derived **exactly** the way gram already derives its +pseudo trace IDs (§4.4). No new correlation ID is introduced. + +--- + +## 2. Goals / non-goals + +### Goals + +- **G1** — Hook telemetry is emitted from the agent side as OTel log + records, with zero added latency on the hook critical path (recorded + after the response is on the wire, batched and shipped off-path in the + hook server; fail-open, never blocks). +- **G2** — The telemetry stream carries (at minimum) everything gram derives + from hook payloads today for observability, per the inventory in §3, so + the backend can stop deriving it from enforcement requests without losing + data. +- **G3** — Emitted records are **schema-compatible with gram's existing + logs pipeline**: attribute keys reconcile with the `gram.hook.*` / + `gen_ai.*` conventions the backend derives today, and trace-context + fields reproduce gram's existing hash derivation, so downstream readers + and joins need minimal changes. +- **G4** — Enforcement decisions get their own self-contained log in gram, + written at decision time, correlatable with agent-side records by + session ID + turn ID (and tool call ID / derived trace ID where present). +- **G5** — The enforcement request payload shrinks to decision-relevant + fields; non-gating events stop being sent to the enforcement endpoint at + all. +- **G6** — The library feature is vendor-neutral: any OTLP logs endpoint + works; gram is one consumer configuration. +- **G7** — Deterministic identity makes the pipeline replay- and + double-fire-safe (Cursor duplicate events, provider re-fires, retried + exports) and forward-compatible with a future traces signal (§8). + +### Non-goals + +- **N1** — Traces or metrics as the v1 signal. Log records are the primary + signal; structured data rides attributes (gram's wide-event model is + built for exactly this). Spans are a possible future evolution (§8) and + gram may derive metrics server-side. +- **N2** — Replacing gram's enforcement transport. Gating events keep their + synchronous request/response; only its payload slims. +- **N3** — Shipping a general transcript-capture product in the library. + Content capture (prompt/tool-IO/assistant text) is an explicit, opt-in + capture level with redaction hooks — off by default (§4.6). +- **N4** — Per-record durability. There is no disk spool: the hook + server's memory is the buffer and its shutdown flush points (§4.7) are + the durability boundary. Records buffered in a server that crashes — + or recorded on the client's rare in-process fallback path, whose + process exits before a batch ships — are lost, and that is accepted: + telemetry is best-effort observability, and the enforcement rail (§5.1) + carries everything decision-critical. +- **N5** — Auth/login flows. The library takes endpoint + headers as config; + acquiring credentials stays a consumer concern (speakeasy-hooks already + has this stack: `gram/hooks/relay/auth.go`). + +--- + +## 3. Inventory: what gram gathers from hook data today → replacement + +Sources: `gram/server/design/hooks/design.go` (contract), +`gram/server/internal/hooks/ingest_hooks.go` (`writeCanonicalTelemetry`, +`recordCanonicalHook`), `gram/server/internal/telemetry/README.md` + +`gram/server/clickhouse/schema.sql` (storage), +`gram/server/internal/attr/conventions.go` (attribute keys). + +Legend for **Replacement**: `record attr` = attribute on the hook-event log +record (§4.3); `resource attr` = OTel resource attribute stamped once per +batch; `enforce` = stays on the slimmed enforcement request (§5.3); +`capture` = only present at an elevated content-capture level (§4.6). + +Where possible the replacement uses **the same attribute key gram's +pipeline already writes or normalizes**, so `telemetry_logs` materialized +columns and existing queries keep working (G3). + +| # | Ingest field (hook.ingest.v1) | Where gram persists it today | Replacement in the decoupled design | +|---|---|---|---| +| 1 | `source.adapter` (provider slug) | `gram.hook.source` attr → CH materialized `hook_source`; `skill_observations.provider`; `tool_call_blocks.provider` | record attr `gram.hook.source` (same key, emitted at source; Claude surface refinement stays a server-side enrichment, §5.2). Also resource attr `agenthooks.provider` + `agenthooks.variant`. Still on `enforce` for the decision log. | +| 2 | `source.adapter_version` | (mostly unset) | resource attrs `telemetry.sdk.*`, `service.version`, `agenthooks.version` | +| 3 | `source.raw_event_name` | `gram.hook.event` attr → CH | record attr `gram.hook.event` (native name, same key) + `event.name` = unified kind (the producer-key convention the URN deriver already reads, `event_urn.go:28-31`; dual-emitted with the top-level EventName field — §4.3) | +| 4 | `source.hostname` | `gram.hook.hostname` attr; Redis session metadata | resource attr `host.name` (OTel semconv); gram maps to `gram.hook.hostname` at ingest | +| 5 | `source.user_email` | actor resolution → PG messages, `user_email` CH column | record attr `user.email` — the logs handler already resolves and attributes by email (`otel.go:106-116`, `:454-457`); still on `enforce` for attribution of decisions | +| 6 | `session.id` | `gen_ai.conversation.id` attr → CH `chat_id`; Redis `session:metadata:*`; PG `chats` UUID derivation | record attr `session.id` — deliberately the key the logs pipeline already normalizes into `gen_ai.conversation.id` and reads for session attribution (`normalizeClaudeLogAttributes`, `extractSessionMetadata`); still on `enforce` | +| 7 | `session.turn_id` | accepted by the API but **not stamped** as a telemetry attribute today | record attr `agenthooks.turn.id`; still on `enforce`, where gram's enforcement log now stamps it too (§5.1). A strict improvement: turn ID becomes first-class. | +| 8 | `session.cwd` | not written to CH attrs (used transiently) | record attr `agenthooks.session.cwd`, default **off** (privacy); `enforce` keeps it only if policy engines need it | +| 9 | `session.model` | `gen_ai.response.model` attr | record attr `gen_ai.response.model` directly — the current semconv key and the one gram's pipeline already stores; the flat `model` spelling is Claude-dialect input to `normalizeClaudeLogAttributes`, not something this rail emits | +| 10 | `event.type` + `occurred_at` | dispatch + `time_unix_nano` | log record `Timestamp` = event receive time, `ObservedTimestamp` = emit time; record attr `agenthooks.hook.duration_ms` = dispatch-to-response duration (timing today's point events never had; namespaced under `.hook.` to keep it distinct from tool-execution duration — §4.9) | +| 11 | `data.prompt.text` | risk-scan input; PG `chat_messages` (user role) | **`enforce`** (it is the decision input). Record carries `agenthooks.prompt.length` + `agenthooks.prompt.sha256` by default; full text only at `capture` level | +| 12 | `data.tool_call.id/name` | `gen_ai.tool.call.id`, `gram.tool.name` attrs; PG tool messages | record attrs `gen_ai.tool.call.id`, `gram.tool.name` (same keys) plus the semconv twin `gen_ai.tool.name`, `agenthooks.tool.canonical`, `agenthooks.tool.synthesized`; `enforce` keeps id+name+input for gating events | +| 13 | `data.tool_call.input` | `gen_ai.tool.call.arguments` attr; risk-scan input; PG | **`enforce`** for tool.pre/permission (decision input). Record: size + hash by default; `gen_ai.tool.call.arguments` at `capture` level | +| 14 | `data.tool_call.output` / `error` / `is_interrupt` / `duration_ms` / `status` | `gen_ai.tool.call.result`, `gram.hook.error`, `gram.hook.is_interrupt` attrs; PG tool result messages | record attrs on the tool.post/tool.error record: `gram.hook.error` (same key) with the stable-semconv twin `error.type` (`tool_error`), `agenthooks.tool.duration_ms`; severity ERROR on failure (§4.3); `gen_ai.tool.call.result` at `capture` level. `is_interrupt` has no agenthooks event-model equivalent today, so no record attribute carries it (gram keeps deriving `gram.hook.is_interrupt` for rails that supply it). **Not sent to enforcement at all** (tool.post stops being POSTed). | +| 15 | `data.mcp.*` (server_name, server_identity, url, command, result_json) | `gram.mcp.match`, `gram.mcp.server_url`, `gram.tool_call.source` attrs; shadow-MCP evidence | record attrs `gram.mcp.match`, `gram.mcp.server_url` (same keys, transport redacted exactly like `gram/hooks/relay/redact.go`) + `agenthooks.mcp.server/tool/from_config`; **`enforce`** keeps url/command/identity for shadow-MCP gating | +| 16 | `data.mcp_inventory[]` | CH `shadow_mcp_inventory_urls` (schema.sql:285-297) | **`enforce`** — inventory is shadow-MCP *enforcement evidence*, not just observability. Additionally mirrored (redacted) as attributes on the session.start record for self-contained telemetry. | +| 17 | `data.usage.*` (tokens, cost, loop_count, status) | `gen_ai.usage.*` attrs; usage-metric rows (Cursor) | record attrs `gen_ai.usage.input_tokens`, `.output_tokens`, `.cache_read.input_tokens`, `.cache_creation.input_tokens` (semconv-exact, matching gram's keys) and `gen_ai.usage.cost` (gram extension, no semconv equivalent) on the stop record. Gram keeps synthesizing its usage-metric rows (`agent_hook:metric:usage`) from these attrs server-side, or the library emits a second usage record — open question O5. Not sent to enforcement. | +| 18 | `data.message` (assistant text, role, duration) | PG `chat_messages` (assistant role) | record body carries the text at `capture` level (`gram.log.body` is the established body destination); `agenthooks.message.length` otherwise. Not sent to enforcement. | +| 19 | `data.skill.*` | PG `skill_observations` + Skill telemetry row; content-upload side channel | record attrs `agenthooks.skill.name/source/...`; **but** the skill-capture product pipeline (content-required effects → `uploadSkillContent`) stays on the ingest rail for now (open question O6) | +| 20 | `data.notification` | light hook-event log row | its own log record with `agenthooks.notification.type/message` | +| 21 | `data.mcp_attribution[]` | Redis tuples that un-redact staged Claude OTEL rows (`telemetry_logs_staging`) | unchanged for now — this exists to repair *Claude's native OTEL* stream, orthogonal to this RFC (open question O6) | +| 22 | `raw` (scrubbed provider payload) | stored for debugging only (design.go:276: "The backend does not use this for feature behavior") | **dropped from the wire entirely.** Debugging moves to record attributes; local debugging uses `AGENTHOOKS_LOG`. | +| 23 | `idempotency_key` / `replayed` | Redis dedup claim; `gram.hook.replayed` attr | deterministic trace/span identity (§4.4) enables storage-level dedup of double-fires and retried exports; this rail no longer emits `gram.hook.replayed` (there is no drain path) | +| 24 | Device headers `X-Gram-Device-*` | `gram.hook.device.{os,arch,binary_version,harness,...}` attrs on endpoint spans (conventions.go:359-372) | resource attrs `os.type`, `host.arch`, `service.name`, `service.version`, `agenthooks.harness`, `agenthooks.harness.variant`, `agenthooks.harness.version` | +| 25 | Decision verdict returned to agent | `gram.hook.block_reason` on event row + companion block row; `gram.hook.decision` on metrics; PG `tool_call_blocks` | **Enforcement log only:** gram's enforcement log, written at decision time (§5.1), is the sole record of decisions (it carries `gram.hook.decision` and keeps `gram.hook.block_reason`). Agent-side records are purely observational — they log the event, never the verdict — so dual-emit parity diffs (§6) exclude decision fields. | +| 26 | hash-derived `trace_id`/`span_id` on hook rows | CH `trace_id`, `span_id` columns (trace: `hashToolCallIDToTraceID`; span: random) | log record `TraceId` field populated with **gram's exact existing derivation** (§4.4) so joins and parity diffs work; `SpanId` becomes deterministic per event (an improvement over today's random `generateSpanID`; nothing joins on span_id) | + +Items that gram gathers from **other channels** (Claude/Codex native OTEL via +`/rpc/hooks.otel/v1/{logs,metrics}`, Cursor Admin API polling) are out of +scope: they are already decoupled from enforcement and classified separately +by the URN origin vocabulary (`provider_otel`, `provider_api` — +`gram/server/internal/urn/telemetry_event.go:17-33`). + +--- + +## 4. Agent-side design (this library) + +### 4.1 Package & API surface + +New package `telemetry` (root-adjacent, like `install` and `transcript`), +plus one runner option in the root package: + +```go +// package agenthooks +// +// TelemetryRecorder is the runner-facing surface of a telemetry recorder; +// *telemetry.Recorder implements it. Defined in the root package so the root +// package never imports telemetry: only binaries that construct a recorder +// link the OTel SDK dependency tree. Its methods take an internal type +// (internal/hookrecord.Record), so external implementations are not +// possible — the interface is a linkage boundary, not an extension point. +type TelemetryRecorder interface { + // RecordHook enqueues one hook event on the in-process export pipeline. + RecordHook(hr *hookrecord.Record) error + // Shutdown flushes buffered records and stops the pipeline. The hook + // server calls it on idle shutdown, SIGINT/SIGTERM, and before a + // version-upgrade exit (§4.7). + Shutdown(ctx context.Context) error +} + +func WithTelemetry(rec TelemetryRecorder) Option + +// package telemetry +type Config struct { + // OTLP/HTTP logs endpoint, e.g. "https://app.getgram.ai/rpc/hooks.otel/v1/logs" + // or any collector's "/v1/logs". Required. + Endpoint string + // Headers added to every export request (auth: e.g. Gram-Key, Gram-Project). + Headers map[string]string + // Resource attributes merged over the library defaults + // (service.name/version, host.name, os.type, agenthooks.provider, + // gram.event.origin=agenthooks, ...). + Resource map[string]string + + // Capture selects the content level: CaptureAttributes (default), + // CaptureContent (prompt text, tool IO, assistant messages on the + // record, post-Redactor). + Capture CaptureLevel + // Redactor rewrites attribute/body values before they enter the + // export pipeline. The library always applies built-in + // transport-credential redaction (URLs, commands) first; Redactor + // runs after it. + Redactor func(key string, value string) string + // HonorTraceparent opts into W3C TRACEPARENT env-var parenting (§4.4). + // Off by default: deterministic trace IDs are what backend joins key on. + HonorTraceparent bool +} + +func New(cfg Config) (*Recorder, error) + +// Recorder methods beyond the interface: ForceFlush exports everything +// buffered without stopping the pipeline (tests, checkpoints). +func (r *Recorder) ForceFlush(ctx context.Context) error +``` + +Consumer usage (what speakeasy-hooks would do): + +```go +rec, _ := telemetry.New(telemetry.Config{ + Endpoint: cfg.ServerURL + "/rpc/hooks.otel/v1/logs", + Headers: map[string]string{"Gram-Key": key, "Gram-Project": project}, +}) +r := agenthooks.New(agenthooks.WithTelemetry(rec), ...) +``` + +Opt-in and fail-open by construction: without the option, nothing changes; +with it, a recorder failure degrades to a logged warning, never an error on +the pipeline (G1). + +Delivery needs no entry point of its own: the same Runner construction +serves every argv mode, and the recorder only becomes meaningful in the +mode that lives long enough to ship — the **hook server** +(`mybinary agenthooks server`), the singleton the per-hook +`agenthooks client` forwards events to and auto-spawns on demand (§4.7). +When the client falls back to its in-process pipeline (server unreachable), +the recorder is constructed but the process exits before a batch ships; +that loss is accepted (N4). Nothing needs external supervision or +provisioning: the server is spawned by hook traffic and retires itself +when idle. + +#### Dependencies: the OTel-Go SDK, configured for this shape + +The `telemetry` package **uses the OTel-Go libraries internally** and +accepts them as dependencies of this opt-in feature: +`go.opentelemetry.io/otel` (API), `otel/log` + `otel/sdk/log` (the logs +bridge API and SDK pipeline), `otel/sdk/resource`, +`exporters/otlp/otlploghttp` (pure `net/http` — **no gRPC**), and their +transitive `go.opentelemetry.io/proto/otlp` + `google.golang.org/protobuf`. +Rationale: + +- **The backend is already designed to ingest OTLP.** Gram's ingest, and + any generic collector a customer points the recorder at, speak OTLP; the + SDK guarantees wire-format correctness — Resource semantics, typed + attribute encoding, severity mapping, trace-context fields on log + records — instead of this library re-implementing and re-validating that + surface. Protobuf encoding comes for free. +- **The SDK's shape now matches the process shape.** The SDK's processing + defaults (in-memory `BatchProcessor` + network exporter) target + long-lived processes — which is exactly what the hook server is. The + recorder is the textbook pipeline: `BatchProcessor` feeding the + **official `otlploghttp` exporter** (gzip), flushed at the server's + shutdown points (§4.7). No custom exporter, no custom persistence. All + locked constraints hold: fail-open, zero network on the hook's + response path. +- **go.mod footprint, honestly.** The module's `go.mod` (essentially + dependency-free today) gains the OTel SDK and its transitive closure. + Binaries that never import `telemetry` do not *link* any of it (the Go + linker only includes imported packages), and with module-graph pruning, + downstream modules that don't import the package don't vendor or verify + the SDK's own dependencies — the cost to non-users is go.mod noise, not + binary size or supply-chain surface. To make that guarantee structural, + `WithTelemetry` takes the root-package `TelemetryRecorder` interface + (above) rather than `*telemetry.Recorder`: the **root package itself has + no telemetry import** (`go list -deps` on the root package shows zero + OTel packages), so nothing links the SDK until the consumer's own import + of `telemetry` does. The server's shutdown flush rides the same + boundary — the runner holds only the interface's `Shutdown` method and + calls it when a recorder was installed. **Recommendation:** keep + `telemetry` in the main module for v1 (a nested `telemetry/go.mod` + submodule would keep the root `go.mod` pristine but adds multi-module + versioning/tagging friction that isn't warranted yet; revisit if + consumers object to the requirement entries). + +### 4.2 Where the recorder taps in + +`OnAny` observers run **before** the handler pipeline +(`agenthooks.go:450-462`, `decideGuarded`), so they cannot see the full +processing timing or a handler failure. Telemetry needs both: the record's +duration must cover dispatch-to-response-encoded, and records must fire even +when handlers error. The recorder therefore taps a new **internal +end-of-processing hook** invoked by `Runner.Run` after `applyPolicy` and +wire encoding (`agenthooks.go:400-432`), by the OpenCode `serve` loop after +`encodeOpenCodeReply` (`serve.go:94-107`), and by the backfill dispatch for +synthesized reporting-only events: + +```go +// internal; WithTelemetry installs one. +type afterEvent func(typed any, base *Event, timing recordTiming, herr error) +``` + +The tap deliberately does **not** read the decision: records are purely +observational — the event plus the hook rail's own health (timing, handler +errors) — and gram's decision-time enforcement log (§5.1) is the sole +record of decisions. Recorded per event: receive time (record timestamp), +dispatch duration, and any handler error (severity + error attrs). The +recorder builds an OTel `log.Record` and `Emit`s it through the package's +`sdk/log` `LoggerProvider`, whose pipeline is a **batch processor feeding +the OTLP/HTTP exporter** (§4.5) — so the call is wrapped in the same panic +guard as observers and bounded: **one in-memory enqueue**, no I/O, no +network, no retries on the critical path. The tap fires identically in +every execution mode (server-handled requests, the client's in-process +fallback, plain `run`, the OpenCode serve loop); what differs is only +whether the process lives long enough for the batch to ship (§4.7, N4). + +`Runner.Decide` (the embedded entry point) does **not** record telemetry: +it has no wire edge and its callers own their own observability. + +### 4.3 Log record model + +**One OTLP log record per hook event** — a wide event, matching gram's +storage philosophy (`telemetry/README.md`: "store each event as a single, +richly-attributed row"). This is deliberately the same shape +`writeCanonicalTelemetry` synthesizes server-side today, so the agent-emitted +record is a drop-in replacement for the derived row. + +Record anatomy: + +- **Timestamp** = library receive time (`Event.Time`); + **ObservedTimestamp** = time the record was emitted to the pipeline. +- **Body** = `"Hook: "` — matching the synthetic + `gram.log.body` the backend writes today (research: `writeCanonicalTelemetry` + emits e.g. `"Hook: PreToolUse"`), so body-based queries keep working. At + `CaptureContent`, prompt/assistant-message records carry the text as the + body instead (the established body destination, item 18 of §3). +- **SeverityText/Number** — `INFO` for ordinary events; `ERROR` for tool + failures and handler errors — health signals only. Decision outcomes + never influence severity: records do not see decisions at all, and a + deny is successful enforcement, not a fault in the hook rail. Gram + auto-infers severity when unset (`telemetry/README.md`), so this mapping + only refines it (open question O4 confirms the exact table). +- **TraceId / SpanId** (native OTLP log fields) — §4.4. The logs handler + already lifts these onto the `trace.id`/`span.id` attributes + (`otel.go:442-447`). +- **EventName (top-level field) *and* `event.name` attribute** = unified + kind (`tool.pre`, `agent.stop`, ...), deliberately **dual-emitted**. + Current OTel conventions moved the event name from the `event.name` + attribute (now deprecated) to the top-level `EventName` LogRecord field, + and the SDK/`otlploghttp` emit it there — but gram's OTLP/JSON ingest + schema has no `eventName` field (`otel_types.go:41-47`; goa drops unknown + fields) and its URN deriver reads only the *attribute* + (`event_urn.go:28-31`). So the recorder sets both: the field for semconv + correctness and generic collectors, the attribute for gram's pipeline. + (Gram's protobuf decode branch, §5.2 item 4, should also lift proto + `event_name` into the attribute for future SDK-only producers.) + Unmapped natives (unified kind `other`) classify as `other.` — + the native event name lowercased and folded to the URN-friendly + `[a-z0-9._-]` alphabet (e.g. Claude's `Setup` → `other.setup`) — so they + do not all collapse into one `urn:telemetry:agent_hook:log:other` type; + `gram.hook.event` carries the native name verbatim alongside. +- **Attributes** (default capture level) — keys chosen to **reconcile with + what gram derives today** (§3), i.e. the record arrives pre-normalized. + `gen_ai.*` keys follow the current registry, which lives in the + [semantic-conventions-genai repo](https://github.com/open-telemetry/semantic-conventions-genai) + (all Development stability): + + | Attribute | Source | Existing gram key? | + |---|---|---| + | `gram.hook.event` | `Event.NativeName` | yes (conventions.go:340) | + | `gram.hook.source` | `Event.Provider` (+ variant refinement) | yes (conventions.go:343, CH mat column) | + | `event.name` | `Event.Kind` | producer convention read by URN deriver (dual-emitted with the EventName field, above) | + | `gram.event.origin` | fixed `"agenthooks"` | new; the plugin-rail origin marker (see below) | + | `session.id` | `Session.ID` | normalized → `gen_ai.conversation.id` by `normalizeClaudeLogAttributes` (otel.go:551-553) | + | `agenthooks.turn.id` | `Session.TurnID` | new (turn ID is dropped today — §3 row 7; no semconv turn concept exists) | + | `gen_ai.response.model` | `Session.Model` | yes — the current semconv key gram's pipeline already normalizes Claude's flat `model` into (otel.go:554-556); emitted directly, no flat `model` dialect key | + | `gen_ai.tool.call.id`, `gram.tool.name`, `gen_ai.tool.name` | `ToolCall` | id + gram name: yes. `gen_ai.tool.name` is the semconv twin carried alongside the gram-dialect key for collector/vendor interop. The id value is the provider's native tool-call id — on Claude this is the same `tool_use_id` its native OTEL events carry ("matches the `tool_use_id` passed to hooks", per Claude's monitoring docs), making it the cross-rail join key (§4.9) | + | `agenthooks.tool.canonical`, `.synthesized` | `ToolCall` | new | + | `agenthooks.tool.duration_ms` | `ToolPostEvent` duration | new (§3 row 14); agent-side counterpart of the Claude-native `tool_result.duration_ms` | + | `gram.mcp.match`, `gram.mcp.server_url`, `agenthooks.mcp.*` | `MCPCall` (redacted) | gram keys: yes (conventions.go:377-387). Note the `mcp.*` namespace is now reserved by the MCP semconv — this library never mints `mcp.*` keys | + | `gram.hook.error`, `agenthooks.handler.error`, `error.type` | `ToolPostEvent` / handler failure | `gram.hook.error`: yes (conventions.go:341). `error.type` is the stable-semconv twin, set only for genuine failures with documented low-cardinality values (`tool_error`, `handler_error`) — never for policy denies, which are successful enforcement. These are the record's *health* attributes; no decision attribute exists (§3 row 25 — the enforcement log owns the verdict) | + | `gen_ai.usage.input_tokens`, `.output_tokens`, `.cache_read.input_tokens`, `.cache_creation.input_tokens`, `gen_ai.usage.cost`, `agenthooks.loop_count` | `StopEvent.Usage` / `LoopCount` | yes. Token keys match the current semconv registry exactly (which gram's `conventions.go:439-440` already mirrors); `gen_ai.usage.cost` is a gram extension with no semconv equivalent, kept for pipeline compat. If reasoning tokens are ever carried, the semconv key is `gen_ai.usage.reasoning.output_tokens` — not gram's legacy `gen_ai.usage.reasoning_tokens`, which gram should map at ingest | + | `agenthooks.prompt.length`, `.prompt.sha256` | `PromptEvent` | new (text itself only at `CaptureContent`) | + | `agenthooks.hook.duration_ms` | dispatch timing (receive → response encoded) | new. **Changed** from `agenthooks.duration_ms`: namespaced under `.hook.` so it cannot be confused with Claude's flat `duration_ms`, which measures tool execution, not hook overhead (§4.9) | + | `agenthooks.event.backfilled` | `Event.Backfilled` | new. `true` on synthesized reporting-only events (the prompt backfill for Kimi/Cursor print modes): `Raw` is nil and any handler decision was discarded, but the record still forms — same shape, backfill-flagged | + | `agenthooks.subagent.id/type`, `gen_ai.agent.name` | `AgentInfo` when present | new. `gen_ai.agent.name` carries the subagent type as its semconv twin (the registry's "human-readable name of the agent"); the per-invocation subagent *id* stays custom — `gen_ai.agent.id` means a stable hosted-agent resource, which this is not | + | `user.email` | consumer-supplied (resolver hook) | read by logs attribution (otel.go:454) | + +- **Resource attributes** — `service.name` (consumer binary), + `service.version`, `host.name`, `os.type`, `host.arch`, + `agenthooks.provider`, `agenthooks.variant`, `agenthooks.harness.*`, and + `gram.event.origin=agenthooks` (also stamped per-record for readers that + only see flattened attributes). + +**`gram.event.origin`.** Every record carries +`gram.event.origin = "agenthooks"`, matching the established taxonomy from +prior design discussions (`gram`, `claude`, `codex`, `copilot`, +`agenthooks`). The key lives in gram's dialect namespace — alongside the +existing `gram.event.source` — rather than the bare `event.` namespace an +earlier draft used: `event.` is an existing OTel semconv namespace (its one +member, `event.name`, is deprecated), and the naming guidelines recommend +against minting custom attributes inside semconv namespaces, where a future +`event.origin` definition would collide. Gram's persisted classifier is +coarser — `urn:telemetry:::` with origins +`provider_otel | provider_api | agent_hook | gram_service | unknown` +(`gram/server/internal/urn/telemetry_event.go:10-42`, which deliberately +keeps producer identity in attributes like `gram.hook.source`). Agent-emitted +records classify as **`agent_hook` / `kind=log`** — the same origin and kind +as today's derived hook rows (`deriveHookEventURN`, `event_urn.go:91-93`), +which is exactly what makes downstream readers indifferent to the cutover; +`gram.event.origin=agenthooks` + resource attrs distinguish emitted from +derived rows during the dual-emit window (§6). + +### 4.4 Deterministic trace-context identity — matching gram's derivation exactly + +Log records still group into session/tool "traces" via the native +`TraceId`/`SpanId` fields — **without any traces ingest path**, because +gram's logs pipeline already lifts those fields onto the `trace.id`/`span.id` +attributes and `telemetry_logs` columns (`otel.go:442-447`, schema). + +Gram already derives pseudo trace IDs for hook rows, and **joins depend on +the exact derivation**: the shadow-MCP provenance lookup resolves a recorded +chat tool-call id to its telemetry rows via +`trace_id = hashToolCallIDToTraceID(recorded id)` +(`gram/server/internal/hooks/impl.go:256-284`, +`gram/server/internal/telemetry/repo/mcp_match_lookup.go`, DNO-604). The +derivation (`canonicalTraceID`, `ingest_hooks.go:1190-1203`) is: + +1. tool events with a per-call id → `hex(SHA-256(toolCallID)[:16])`; +2. tool events without one → `hex(SHA-256(len(sessionID) + "|" + sessionID + + "|" + toolName)[:16])` (`syntheticToolCallID`, `impl.go:279-284`); +3. everything else → `hex(SHA-256(sessionID)[:16])`; +4. last resort → random. + +**The agent side reproduces this derivation verbatim** (G3): + +- `TraceId`: tool events use rule 1 with `ToolCall.ID` — which is the same + value the relay sends today (native id, or the library's synthesized + `hook_synth_*` id, `event.go:269-280`), so agent-emitted and + server-derived rows for the same event get **identical trace IDs**, and + dual-emit parity diffs can join on `(trace_id, event.name)`. Non-tool + events use rule 3 (session hash), so a session's prompt/stop/session + records share one trace — the "session trace" grouping. +- `SpanId`: deterministic per event — + `hex(SHA-256("agenthooks|event" + the length-prefixed sessionID, turnID, + nativeName, toolCallID, and receiveTimeNanos)[:8])` (length prefixes keep + the encoding injective for separator-bearing values, the same reasoning + as `syntheticToolCallID`). Today gram + generates *random* span ids (`generateSpanID`, `impl.go:287-291`) and + nothing joins on them, so determinism is a strict improvement: identical + double-fires and retried exports collide onto the same + `(trace_id, span_id)` and dedupe at the storage layer. +- Turn structure is carried by the `agenthooks.turn.id` **attribute** (there + are no parent spans in a logs-only model). Gram's enforcement log stamps + the same attribute (§5.1), keeping the two streams joinable at session, + turn, and tool-call granularity. +- Sessions with an empty session ID (rare, provider bugs) fall back to + random IDs and are flagged `agenthooks.session.unidentified=true`. + +**Mechanism through the SDK.** The OTel logs API populates a record's +trace-context fields from the `context.Context` passed to `Logger.Emit` +(the spec requires implementations to "resolve trace context from the +provided context argument" — `otel/log/DESIGN.md`). The recorder therefore +constructs a synthetic span context carrying the derived IDs and injects it +into the emit context — no tracer, no spans started: + +```go +sc := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: derivedTraceID, // canonicalTraceID reproduction, above + SpanID: derivedSpanID, // deterministic per event, above +}) +logger.Emit(trace.ContextWithSpanContext(ctx, sc), rec) +``` + +The `sdk/log` pipeline stamps `TraceID`/`SpanID` onto the record from that +span context, and they survive unchanged through the batch processor and +`otlploghttp` (§4.5) onto the OTLP wire. + +This also keeps the door open for a future traces signal (§8): the same +derivation can later mint real spans with unchanged IDs, so historical log +records and future spans would share identity. + +**Ambient `TRACEPARENT` — opt-in, deterministic IDs stay the default.** +Launch environments that already run inside a distributed trace (CI +pipelines, orchestrators) may export a W3C `TRACEPARENT` env var into the +hook process. Honoring it wholesale would silently **break gram's trace-ID +joins** — the shadow-MCP provenance lookup and the session grouping both +compute `hashToolCallIDToTraceID(...)` and expect the record's `TraceId` to +match — so the precedence is deliberately conservative: + +1. **Default (`HonorTraceparent` unset): TRACEPARENT is ignored.** Records + carry the deterministic derivation above. This is the only mode where + gram's joins work unmodified, so it is the only safe default. +2. **`Config.HonorTraceparent = true`:** if the hook process carries a + *valid* traceparent (version ≠ `ff`, non-zero IDs), its **trace ID and + sampled flag** take the record's trace-context fields, parenting the + record into the ambient trace, and the deterministic trace ID moves to + the `agenthooks.deterministic_trace_id` attribute so a gram-side join + can still be recovered by mapping. Malformed or absent values fall back + to the deterministic derivation. +3. In both modes the **span ID stays deterministic per event** — the + traceparent's span ID names the *launcher's* span, not this event, and + replay dedupe relies on per-event span identity. + +The opt-in is thus double-gated: the consumer sets the config knob *and* +the launching environment sets the env var. Neither alone changes anything. + +### 4.5 Delivery: the in-process batch pipeline + +Delivery is the OTel SDK's own long-lived-process pipeline, running inside +the hook server: + +- **Pipeline:** `sdk/log.LoggerProvider` → `BatchProcessor` (SDK defaults: + bounded queue, periodic export interval) → **`otlploghttp`** exporter + (`WithEndpointURL`, `WithHeaders`, gzip compression). `RecordHook` is an + in-memory enqueue; batches ship in the background on the processor's + schedule while the server lives. +- **Buffering & backpressure:** the batch processor's queue is bounded + (SDK default 2048 records); overflow drops oldest-first inside the SDK. + No disk artifact exists at any point — content-capture payloads are + still truncated per record (max 1 MiB, flagged + `agenthooks.record.truncated=true`) so single records can't dominate + the queue or the export request. +- **Flush points:** the server calls `Recorder.Shutdown` — which force- + flushes the queue — on idle shutdown, on SIGINT/SIGTERM, and before a + version-upgrade exit (§4.7). A per-hook process (client fallback, plain + `run`) exits without a flush; those records are lost by design (N4). +- **Retry:** transient export failures (network down, 5xx, 429 with + `Retry-After`) are retried by the SDK exporter's built-in policy; + records survive retries in memory only. Endpoint downtime longer than + the server's lifetime loses the buffered tail — accepted (N4). Gram- + unreachable durability for *enforcement* traffic remains the relay's + offline ingest spool, which is orthogonal to this rail. + +### 4.6 Capture levels, privacy, redaction + +- **Default (`CaptureAttributes`):** no prompt text, no tool input/output + bodies, no assistant messages, no cwd. Sizes + SHA-256 digests stand in, + which is enough for volume/shape analytics and joins against + enforcement-side data (which still sees the full decision inputs). +- **`CaptureContent`:** prompt text, tool input/output + (`gen_ai.tool.call.arguments` / `.result`), and assistant messages (record + body) attach after (1) the built-in transport-credential redaction + (ported from `gram/hooks/relay/redact.go`: URL userinfo/query secrets, + env assigns, `Authorization`/token-shaped values) and (2) the consumer's + `Redactor`. Redaction happens before the record enters the export + pipeline, so unredacted content never leaves the tap. +- Captured content now exists only in the server's memory between emit and + export — a strictly smaller at-rest surface than rev 2's `0600` spool + files. +- `Event.Raw` is **never** exported at any level (fidelity stays local; the + backend explicitly does not use raw for behavior today, design.go:276). + +### 4.7 Server lifecycle: where the recorder lives and flushes + +The recorder's host process is the **hook server** — the client/server +architecture's long-running singleton, not a telemetry-specific daemon. +Its lifecycle is owned by hook traffic, with telemetry riding along: + +- **Placement.** Generated configs install + `mybinary [flags] agenthooks client --provider=X` as the hook command. + The client forwards each event over a per-consumer-identity unix socket + (Windows: named pipe) to `mybinary [flags] agenthooks server`; when no + server answers, the client re-execs itself as one (detached, spawn-lock + serialized) and retries. The server constructs the consumer's Runner — + including `WithTelemetry` — once, and hosts it across invocations: + handlers, caches, warm HTTP connections, and the recorder's batch + pipeline all live exactly as long as the server. +- **Singleton.** One server per consumer identity (executable + + pre-sentinel flags such as `--config=...`), enforced by the endpoint + bind plus a `internal/filelock` server lock. Distinct configs get + distinct servers, so telemetry credentials never cross deployments. +- **Shutdown = flush.** The server shuts down — and calls + `TelemetryRecorder.Shutdown`, which force-flushes the batch queue — + on three triggers: + 1. **Idle timeout** (default 10 min without a connection; + `--idle-timeout=DUR` / `AGENTHOOKS_SERVER_IDLE_TIMEOUT` override): + the server retires itself between working sessions, flushing on the + way out, so records never sit in memory unboundedly on an idle + machine. + 2. **SIGINT/SIGTERM:** graceful drain — stop accepting, finish + in-flight requests, flush, exit 0. + 3. **Version upgrade:** every client request carries a fingerprint of + the client's executable (path/size/mtime). On mismatch the server + answers that request, then drains and exits so the next client's + respawn runs the new binary — the LSP-style upgrade story. The + rendezvous (socket + lock) is released *before* the final flush so + the replacement server can bind immediately. +- **Fallback path.** If the client cannot reach or spawn a server within + its ~2 s budget, it runs the identical pipeline in-process (today's + `run` behavior). The tap still fires; the batch almost certainly does + not ship before exit. Decisions are never affected; telemetry loss on + this path is accepted (N4). +- **No external supervision.** Nothing needs provisioning, boot + registration, or a service manager: the server exists exactly when hook + traffic exists. (Consumers running under external supervision anyway — + e.g. a device agent — can keep a server warm by invoking the verb + directly; that is an optimization, not a requirement.) + +**Encoding note:** `otlploghttp` emits OTLP **protobuf** +(`application/x-protobuf`) — the Go OTLP/HTTP exporters do not offer a JSON +mode. Gram's endpoint parses OTLP/JSON today, so the SDK decision adds one +small gram-side item: a protobuf decode branch at ingest (§5.2 item 4, +resolving O10). + +Latency accounting: the hook's response path gains exactly one in-memory +enqueue — no disk, no network, ever; the network happens on the server's +background export schedule. Deployment accounting: nothing to provision — +the server (and with it, delivery) is spawned by the first hook and +retires itself when idle. + +### 4.8 Failure behavior (normative) + +- Telemetry is **fail-open, always**: recorder errors, full batch queues, + dead endpoints, export failures, and server crashes must never change a + decision, delay a response, or surface as a hook failure. This mirrors + and strengthens the fail-open discipline of OnAny observers + (`agenthooks.go`, `decideGuarded`). +- The tap is guarded by the same panic-to-error conversion as observers, + and its work is one in-memory enqueue — no I/O, no network. +- Misconfiguration (empty or non-http(s) endpoint) fails at + `telemetry.New` — construction time, in the consumer's control — not at + event time. +- Memory is bounded by the SDK batch processor's queue cap plus the + per-record truncation cap; overflow drops records inside the SDK, never + errors on the pipeline. +- Delivery failures are quiet by design: the SDK retries transient + failures per request, `Shutdown` bounds the final flush with a timeout + (a dead endpoint cannot wedge server shutdown), and anything unshipped + when the flush window closes is dropped. There is no loud failure mode + on this rail — the enforcement rail owns anything that must not be + silently lost. +- The server itself is hook infrastructure, not telemetry infrastructure: + a server crash degrades hooks to the in-process fallback (decisions + unaffected) and telemetry to per-process best-effort until the next + spawn. + +### 4.9 Positioning vs Claude Code's native OTEL events (dual-rail) + +Claude Code ships its own OpenTelemetry monitoring rail +([monitoring docs](https://code.claude.com/docs/en/monitoring-usage#events)): +with `CLAUDE_CODE_ENABLE_TELEMETRY=1` and `OTEL_LOGS_EXPORTER=otlp`, the CLI +emits `claude_code.*` log events plus cost/token/session metrics. That rail +**already feeds gram**: `Service.Logs` writes every `claude_code.*` record +into `telemetry_logs` (no event allowlist — `otel.go:354-498`), normalized by +`normalizeClaudeLogAttributes` (`session.id`→`gen_ai.conversation.id`, +`model`→`gen_ai.response.model`, trace-context lifting) and classified +`urn:telemetry:provider_otel:log:` (`event_urn.go:70-80`). The +agenthooks record schema is therefore positioned *relative to* an existing +neighbor in the same table, not designed in a vacuum. + +**Event-by-event matrix.** "gram today" = what gram does with the Claude +event beyond storing it (everything is stored). + +| Claude native event (`claude_code.*`) | gram today | Closest agenthooks record (§4.3) | Overlap & join | +|---|---|---|---| +| `user_prompt` (`prompt_length`; `prompt` text only with `OTEL_LOG_USER_PROMPTS=1`) | stored + schedules the prompt-correlation workflow (`otel.go:511-532`) | `prompt.submitted` (`agenthooks.prompt.length/.sha256`; text at `CaptureContent`) | both see the prompt; join on `session.id`. Content gating differs: Claude's is a per-device env flag, agenthooks' an org-distributed capture level (§4.6) | +| `assistant_response` (`response`, `model`, `request_id`, `message.uuid`) | stored; no feature consumer | `agent.stop` message capture (§3 row 18) | Claude adds `message.uuid` (transcript join) and per-response `request_id`; agenthooks sees final text only where the provider exposes it | +| `tool_result` (`tool_name`, `tool_use_id`, `success`, `duration_ms`, sizes, `decision_source`) | stored | `tool.post` / `tool.error` | strongest overlap. Join: `tool_use_id` = `gen_ai.tool.call.id` (same underlying id, per Claude's docs). Claude adds I/O sizes + decision provenance; agenthooks adds `gram.mcp.*` resolution, `error.type` failure classing, and output content under org-controlled capture | +| `tool_decision` (`decision` accept/reject, `source` config/hook/user_*) | stored | **none** — agenthooks records are observational and carry no verdict; gram's enforcement log (§5.1) owns decision logging | Claude's `source` taxonomy covers config/user decisions that never reach a hook; for hook-made decisions the enforcement log carries the reason/policy detail Claude flattens to accept/reject. Correlate the observational `tool.pre` record via `tool_use_id` = `gen_ai.tool.call.id` | +| `api_request`, `api_error`, `api_refusal`, `api_retries_exhausted`, `api_{request,response}_body` | stored; `api_request` feeds identity extraction (`otel.go:320-343`) and MCP-attribution staging when redacted (`otel.go:503-509`); cost/token **metrics** feed usage rollups (`pending_helpers.go:535-547`) | **none** — hooks never see API internals | Claude-only: per-request cost, tokens, cache, model, request ids, errors/refusals. agenthooks' `gen_ai.usage.*` on `agent.stop` is turn-grained and provider-dependent — a different measurement, not a substitute | +| `mcp_server_connection` (`status`, `transport_type`; `server_name` only with `OTEL_LOG_TOOL_DETAILS=1`) | stored | `session.start` MCP inventory + per-call `gram.mcp.*` | complementary angles: Claude sees connection lifecycle; agenthooks resolves server identity/URL/command per call (shadow-MCP evidence), unredacted by default | +| `skill_activated`, `compaction`, `permission_mode_changed`, `auth`, `internal_error`, `plugin_installed/loaded`, `at_mention`, `feedback_survey` | stored; no feature consumers | `compact.pre/.post` (trigger only); skill via §3 row 19; `permission.request` partially; rest: none | mostly Claude-only product internals | +| `hook_registered`, `hook_execution_start/complete`, `hook_plugin_metrics` | stored | **none — these observe agenthooks itself** | an independent external monitor of the hook rail: registration inventory, per-event `total_duration_ms`, `num_blocking`. Useful for verifying agenthooks health/latency without trusting agenthooks' own telemetry | + +Conversely, agenthooks emits kinds Claude's OTEL has no event for: +`session.start`/`session.end` (Claude has only a session *counter* metric), +`subagent.start`/`subagent.stop`, `notification`, `file.edited`, and +`model.request`/`model.response` on providers that expose them — and, the +structural difference, **the same record shape across all six providers**, +where the Claude rail covers exactly one. + +**What each rail uniquely provides:** + +- *Claude native*: model-side economics and internals (per-request cost, + tokens, cache hits, request ids, errors/refusals/retries), transcript + identity (`message.uuid`, `prompt.id`), authenticated user identity + attributes (`user.email`, `organization.id` — which gram's attribution + already consumes), decisions made by config rules or the human at the + permission prompt (paths where no hook fires), and meta-telemetry about + hook execution itself. +- *agenthooks*: provider-uniform coverage, MCP transport resolution and + shadow-MCP evidence, turn identity (`agenthooks.turn.id`), deterministic + trace-context aligned with gram's joins (§4.4), hook-rail health (dispatch + duration, handler errors), and org-controlled (rather than per-device + env-flag) content capture with built-in redaction. Full decision detail + (deny/ask/rewrite + reason + policy source, not just accept/reject) lives + on gram's enforcement log (§5.1), not on the agent-side records. + +**Naming positioning.** The agenthooks record deliberately uses gram's +derived-row dialect (`gram.hook.*`, `gen_ai.*`, §4.3) rather than Claude's +flat producer keys (`tool_name`, `duration_ms`, `success`): in +`telemetry_logs` the flat keys *are* the provider-OTEL dialect, and reusing +them would blur the rail boundary readers scope on. Alignment happens at the +value level instead: shared `session.id`, shared tool-call id, and the same +`gen_ai.*` targets post-normalization. Two cheap alignments were made in +§4.3 as a result of this comparison: `agenthooks.hook.duration_ms` (renamed +from `agenthooks.duration_ms`, so hook overhead can't be misread as Claude's +tool-execution `duration_ms`) and an explicit `agenthooks.tool.duration_ms` +mirroring `tool_result.duration_ms`. One optional gram-side alignment: +extend `normalizeClaudeLogAttributes` to map `tool_use_id` → +`gen_ai.tool.call.id` so the cross-rail join key lands in the same +materialized column for both rails (§5.2). + +**Dual-rail overlap (both rails pointed at gram).** Recommendation: +**coexistence with origin scoping — no ingest-time dedup.** + +- Separation is already structural: Claude rows classify + `provider_otel:log:*` (writer URN `claude-code:otel:logs`), agenthooks + rows `agent_hook:log:*` (§5.2), and the URN is materialized for + filtering. Note `gram.event.origin=agenthooks` marks only the agenthooks + rows — Claude's CLI emits no such attribute — so **readers should scope on + the URN origin**, with `gram.event.origin` as the finer producer marker + within `agent_hook`. +- The rails are complementary, not duplicates: per the matrix, only + `tool_result`/`tool.post` and `tool_decision`/`tool.pre` overlap + semantically, and their attribute sets differ enough that dedup would be + lossy. Correlate instead: `tool_use_id` = `gen_ai.tool.call.id` joins the + two views of one tool call. +- The reader rule that follows: any metric counting "tool calls", + "sessions", or "denies" must draw from **one rail per metric** (scoped by + URN origin) — the same discipline the dual-emit window already requires + (O2). Cost/token metrics have no conflict: only the Claude rail carries + them, and O5's server-side usage synthesis must stay scoped to providers + without a native usage rail so it never double-counts + `provider_otel:metric:usage` rows. +- One decision can legitimately produce **two records**: gram's enforcement + log row (§5.1) and Claude's `tool_decision` (`source="hook"`). Precedence + for readers: the enforcement log is authoritative for *what policy + decided*; `tool_decision` is provider-side provenance, and the only + record for config/user decisions hooks never see. The agenthooks event + record deliberately carries no verdict — join it in by trace ID or + tool-call id when the observational context around a decision (timing, + MCP resolution, payload shape) is needed. This is + documentation/MV-guidance, not a pipeline change (O11). + +--- + +## 5. Backend-side design (gram) + +Going logs-first **eliminates** the largest backend items from the traces +draft: no new `/rpc/hooks.otel/v1/traces` endpoint, no span storage +decision, no `TelemetryEventKind` extension, no span→row mapping, no new URN +kinds, no trace-view query work. What remains is small and listed below. + +### 5.1 Enforcement logging (unchanged from rev 1 — still required) + +Enforcement gets a first-class, self-contained record written **at decision +time** by the decision sites themselves — `evaluateCanonicalHook` +(`ingest_hooks.go:410`), the legacy PreToolUse/UserPromptSubmit handlers, +and the shadow-MCP evaluator — instead of decision fields being stamped +onto observability rows derived from the request. This log is the **sole +record of decisions across both rails**: agent-side telemetry records are +purely observational and never carry the verdict (§4.2, §4.3), so nothing +else re-records what policy decided. + +- **ClickHouse:** one `telemetry_logs` row per *evaluated* gating event — + allow and deny both, and allow-row logging is **required**, not merely + cheap: with no agent-side decision attributes, an unlogged allow would + be recorded nowhere (it also keeps deny-rate queries self-contained). + Classified + `urn:telemetry:gram_service:log:hook.decision`. Attributes: + `gram.hook.decision` (allow/deny/warn — key exists, + `conventions.go:339`), `gram.hook.block_reason`, `gram.hook.event`, + `gram.hook.source`, `gram.tool.name`, `gram.mcp.match`, + `gram.risk.policy_id` / rule id / engine, org/project/user, scan latency, + `gen_ai.conversation.id`, **`agenthooks.turn.id`** (new: stamp the turn + ID, which the API already accepts but drops — §3 row 7), and + **`trace_id`/`span_id` via the existing `canonicalTraceID` derivation** — + which now provably matches the agent-side records (§4.4), so a deny and + the agent's view of that deny land in the same trace. +- **Postgres:** `tool_call_blocks` (denies) and `risk_policy_challenges` + (warn acks) stay as-is — they are already decision-time, decision-owned + records. +- **Metrics:** `hooks.event.duration` with `gram.hook.decision` / + `gram.hook.risk_scanned` stays as-is (`internal/hooks/metrics.go`). + +This makes the enforcement audit trail independent of whether any +observability derivation happens afterward — the precondition for deleting +that derivation in the cutover. + +### 5.2 Logs ingest: what exists, what changes + +**Exists and is reused as-is** (the point of logs-first): + +- Endpoint + auth: `POST /rpc/hooks.otel/v1/logs`, `Gram-Key`/`Gram-Project` + (`design/hooks/design.go:440-462`). +- Per-record pipeline: attribute normalization + (`normalizeClaudeLogAttributes`), `gram.event.source=hook` stamping, + project/org stamping, session attribution with Redis caching, body/scope/ + trace-context lifting, `LogBulk` → `telemetry_logs` + (`otel.go:354-498`). +- Storage, materialized columns, TTL, downstream MVs, URN classification + (`telemetry/README.md`, `event_urn.go`). + +**Small changes required:** + +1. **Recognize agenthooks payloads.** `Service.Logs` currently branches + Claude vs Codex (`isCodexLogsPayload`, `otel.go:49-54`) and applies + Claude-specific surface resolution. Add a branch keyed on the resource + attr `gram.event.origin=agenthooks` (or `service.name`): skip the + Claude-surface machinery (the records carry `gram.hook.source` already, + §4.3), reuse the generic normalization + attribution + bulk write, and + stamp a new writer URN constant (e.g. `agenthooks:otel:logs`) alongside + the existing `claudeOTELLogsURN` (`otel.go:22`). +2. **URN mapping.** Extend `deriveHookEventURN` + (`telemetry/event_urn.go:68-94`): rows with the `agenthooks:otel:logs` + writer URN classify as `urn:telemetry:agent_hook:log:` — + the **same** origin/kind/type today's derived hook rows get, which is + what keeps existing readers working (§4.3). +3. **Turn ID persistence.** Accept and persist `agenthooks.turn.id` (a + conventions.go key + optionally a materialized column) — new capability, + small. +4. **Protobuf decode branch (resolves O10).** The SDK's `otlploghttp` + exporter emits OTLP **protobuf**; gram's endpoint parses OTLP/JSON. The + hooks request decoder (`newHooksRequestDecoder`, + `claude_hooks.go:62-148`) already buffers the whole body, decompresses + gzip, and branches per content type — add one branch: on + `application/x-protobuf` for the OTLP paths, `proto.Unmarshal` into + `go.opentelemetry.io/proto/otlp` `ExportLogsServiceRequest`, then + **protojson-transcode to canonical OTLP/JSON** and hand it to the + existing stock JSON decoder. One enrichment while transcoding: **lift + the proto `event_name` field into an `event.name` attribute** when the + attribute is absent — gram's JSON schema has no `eventName` field and + the URN deriver reads the attribute, so this future-proofs the endpoint + for standard SDK producers that only set the top-level field (this + library dual-emits both, §4.3, so it works either way). The rest of the + downstream pipeline is untouched — the decode tests already guard + exactly the canonical shape protojson produces (`otel_decode_test.go`: + collector-style stringified ints), and `google.golang.org/protobuf` is + already in the server module. Small, contained, and independently + useful (any standard OTLP client can then target the endpoint). +5. **(Optional, one line) Cross-rail tool-call join.** Extend + `normalizeClaudeLogAttributes` (`otel.go:550-563`) to also map Claude's + `tool_use_id` → `gen_ai.tool.call.id`, so the Claude-native and + agenthooks views of the same tool call share a materialized join key + (§4.9). Strictly additive; nothing depends on it. +6. **Re-source product features.** ClickHouse-reading features need no + change (same table, same keys). The **Postgres** products fed by ingest + handlers today — session capture (`chats`/`chat_messages`) and usage + rollup writes — need a consumer of agenthooks-emitted rows (or of the + OTLP payload at ingest time, mirroring how `persistCanonicalConversationEvent` + consumes ingest payloads) if they are to survive the cutover; session + capture additionally requires `CaptureContent` (open question O1). + +### 5.3 Slimmed enforcement request + +After cutover, the enforcement rail carries **only gating events** and only +decision-relevant fields. Per the enforcement analysis +(`evaluateCanonicalHook`, `risk.Scanner.ScanForEnforcement` at +`internal/risk/scanner.go:234`, shadow-MCP validator), the decision needs: +identity, event type, tool identity, the scanned text, and MCP evidence. + +Proposed `hook.enforce.v1` (successor to `hook.ingest.v1` on the same +endpoint or a sibling `/rpc/hooks.enforce`): + +| Block | Keeps | Drops (moves to telemetry) | +|---|---|---| +| `source` | `adapter`, `adapter_version`, `hostname`, `user_email` | `raw_event_name` (optional keep for decision logs) | +| `session` | `id`, `turn_id`, `model`¹, `cwd`¹ | — | +| `event` | `type` (gating types only: `prompt.submitted`, `tool.requested`), `occurred_at` | all non-gating types — **no longer POSTed at all** | +| `data.prompt` | `text` (scan input) | — | +| `data.tool_call` | `id`, `name`, `input` (scan input), `permission_type` | `output`, `error`, `duration_ms`, `status`, `is_interrupt` | +| `data.mcp` | `server_name`, `server_identity`, `url`, `command` | `result_json` | +| `data.mcp_inventory` | kept (shadow-MCP enforcement evidence, sent on session start / config change)² | — | +| `data.usage`, `data.message`, `data.notification` | — | dropped entirely | +| `data.skill`, `data.mcp_attribution` | see open questions O6 | — | +| `raw` | — | dropped entirely | + +¹ `model`/`cwd` retained only if policy predicates use them; audit before +final schema. +² Inventory delivery needs a gating carrier once `session.started` stops +being an ingest event; either keep `session.started` on the enforcement rail +as a non-gating-but-synchronous event (it already piggybacks org-settings +`effects` — see O3), or attach inventory to the first gating event of a +session. + +The `IngestHookResult` response shape (decision/reason/message/effects) is +unchanged — the `effects` channel (org fail-open settings, skill content +requests) continues to ride the gating exchanges. + +**Client-side effect:** `buildEnvelope` + `Relay.deliver` stop constructing +and sending envelopes for non-gating events entirely; observe handlers +(`onToolPost`, `onStop`, `onObserve` in `gram/hooks/relay/runner.go`) reduce +to telemetry-only. This removes a synchronous control-plane HTTP call from +every observe event — the largest latency win of the whole change (§1, +research finding 3: "in-process `deliver` is still a blocking HTTP call +unless the provider/`--async` detached the worker"). + +The relay's existing enforcement spool (`spool.go`) shrinks with the +payload; the drain replay path is unchanged for gating events. + +--- + +## 6. Cutover plan + +Ordered; each step ships independently and is verifiable before the next. +Dual-emit is the compatibility window; the end state is a hard cutover. +Re-sequenced from rev 1: with no new ingest path to build, the gram-side +prerequisite phase is much smaller, and parity verification gets easier +because both streams land in the **same table with the same trace-ID +derivation**. + +**Phase 0 — library (this repo)** +1. Land the client/server architecture (`agenthooks client`/`server` + verbs, `internal/ipc`, install-package command-line change), the + `telemetry` package with its in-process batch pipeline, `WithTelemetry` + + the recorder `Shutdown` flush points, and the internal + end-of-processing tap. Opt-in; no consumer change yet. Update + DESIGN.md to reflect both. Fixture-test record determinism per + provider (golden corpus already exists in `agenthookstest`), including + a cross-check that the emitted `TraceId` matches gram's + `canonicalTraceID` for the same inputs. + +**Phase 1 — gram, small and additive** +2. Add the agenthooks branch to `Service.Logs` + the `agenthooks:otel:logs` + writer URN + `deriveHookEventURN` mapping + `agenthooks.turn.id` + persistence + the protobuf decode branch in `newHooksRequestDecoder` + (§5.2 items 1–4). No new endpoint, no schema change beyond an attribute + key (and optional materialized column). +3. Make enforcement logging self-contained (§5.1): decision-time rows with + `gram.hook.decision`, turn ID, `canonicalTraceID`-derived trace context. + From this point the enforcement audit trail no longer depends on + `writeCanonicalTelemetry`. + +**Phase 2 — consumer, dual emit** +4. speakeasy-hooks enables `WithTelemetry` pointed at + `/rpc/hooks.otel/v1/logs` with `Gram-Key` headers (config from the + existing `speakeasy.json`/env/auth-cache stack). Bump the pinned + `hooksBinaryVersion` in `server/internal/plugins/hooks_bootstrap.go` so + org plugins roll forward. **Both** streams now flow into + `telemetry_logs`: request-derived rows (URN `agent_hook:log:*`, no + `gram.event.origin`) and agent-emitted rows (same URN class, + `gram.event.origin=agenthooks`). +5. **Parity verification** (the compatibility window's exit criterion): + same-table diffs joined on `(trace_id, event.name / gram.hook.event, + gen_ai.conversation.id)` — possible precisely because the agent side + reproduces `canonicalTraceID` (§4.4). Dashboards for the inventory items + in §3. Fix gaps while both streams exist. + +**Phase 3 — gram readers switch** +6. ClickHouse readers mostly need nothing (same table/keys); audit queries + that would double-count during dual-emit and scope them by + `gram.event.origin` where needed. Stand up the span-free replacements for the + Postgres products (session capture / usage rollups fed from the emitted + stream, per §5.2 item 6, if approved under O1). Request-derived rows are + marked deprecated to catch stragglers. + +**Phase 4 — slim the wire (the cutover)** +7. Ship `hook.enforce.v1` server support (accepting v1 *and* the slim + schema; `schema_version` already exists for exactly this). +8. Ship the consumer binary that (a) stops POSTing non-gating events, + (b) sends the slim payload for gating events. Bootstrap pin bump again. + Old binaries keep working against the v1 acceptor during the fleet + rollout window (bootstrap-pinned fleets converge on next session). +9. Remove `writeCanonicalTelemetry`'s observability derivation (and the + legacy per-provider endpoints' equivalents) once v1 traffic drains to + zero; `Ingest` keeps only: evaluate → enforcement log → respond. + +**Phase 5 — cleanup** +10. Drop dead payload fields from the Goa design, regenerate SDKs, delete + the parity dashboards, remove the dual-write flags. + +**Verifying nothing observability-critical is lost:** the §3 inventory is +the checklist; step 5's keyed same-table diff is the mechanism; the ordering +guarantees there is never a moment where an inventory item has no producer +(derivation is deleted only after record parity is proven *and* readers have +switched). + +--- + +## 7. Open questions for review + +- **O1 — Content capture & session capture.** PG `chats`/`chat_messages` + (the session-transcript product) currently gets prompt and assistant text + from enforcement requests. Keeping it requires running the fleet at + `CaptureContent` (with redaction) so records carry text, plus a gram-side + consumer that writes PG from the emitted stream (§5.2 item 6). Are we + comfortable putting conversation content on the telemetry rail (held + in server memory until export), or should session capture be + re-scoped/dropped at cutover? +- **O2 — Record volume & dual-emit scoping.** One emitted record per hook + event is roughly today's derived-row volume, but the dual-emit window + doubles hook-row volume in `telemetry_logs`, and both streams share the + URN class `agent_hook:log:*` by design. Is `gram.event.origin` scoping enough + for every reader during the window (metrics MVs aggregate without origin + filters today), or do some MVs need a temporary filter / do we sample the + emitted stream until Phase 4? +- **O3 — Org-settings distribution.** Cached fail-open posture refreshes via + ingest `effects` on *any* successful exchange + (`gram/hooks/relay/orgsettings.go`). With only gating events POSTing, + refresh frequency drops on observe-heavy sessions. Is gating-event + frequency sufficient, or does the posture need a dedicated (rare) refresh + call? +- **O4 — Severity mapping.** Proposed: INFO default; ERROR for tool + failure and handler error — health signals only, since records carry no + decisions (§4.3) and a deny is successful enforcement, not a fault. + Gram auto-infers severity when unset — should the library set severity at + all, or leave inference to the backend so agent- and server-derived rows + can't disagree during dual-emit? +- **O5 — Usage-metric rows.** Today Cursor stop events also yield synthetic + usage rows classified `agent_hook:metric:usage` + (`event_urn.go:85-88`). Post-cutover: should gram synthesize them + server-side from the emitted stop record's `gen_ai.usage.*` attrs (keeps + the library signal-pure), or should the library emit a second, dedicated + usage record per stop? Leaning: server-side synthesis at ingest. +- **O6 — Skill capture & `mcp_attribution`.** `skill.activated` + + content-required effects + `uploadSkillContent` is a product pipeline + with a synchronous effects handshake; `mcp_attribution` repairs Claude's + redacted native-OTEL rows via Redis staging. Keep both on the (gating) + enforcement rail, carry them as record attributes read at logs-ingest + time, or redesign as exporter-adjacent side channels? Leaning: keep on + the enforcement rail initially; revisit after cutover. +- **O7 — OpenCode serve mode: resolved by rev 3.** The serve daemon is + long-lived, so its in-process recorder batches and ships exactly like + the hook server's — one uniform pipeline everywhere. (The serve process + should gain the same flush-on-signal discipline as the server; today it + flushes when the consumer's process exits cleanly.) +- **O8 — Delivery entry point: resolved into the design (rev 3).** Rev 2 + answered this with a supervised `agenthooks exporter` daemon; rev 3 + removes the dedicated entry point entirely — the **hook server is the + delivery point**, spawned by hook traffic and retiring on idle, with + flush-on-shutdown replacing continuous spool polling. Idle machines + hold no unshipped backlog because the idle shutdown flushes before + exit. No decision left open. +- **O9 — Enforcement-allow row volume.** §5.1 logs allows as well as + denies. At fleet scale this multiplies gating-event rows — but sampling + allows (keep all denies/warns) is now lossy in a way earlier drafts + weren't: the enforcement log is the sole record of decisions (§5.1, + agent-side records carry no verdict), so a sampled-out allow is recorded + nowhere. Needs a gram capacity check before any sampling is considered. +- **O10 — OTLP encoding: resolved into the design.** With the OTel-Go SDK + decision (§4.1 Dependencies), the exporter ships via `otlploghttp`, + which emits OTLP **protobuf** — so gram's logs endpoint gains a small + protobuf→protojson transcode branch in its request decoder (§5.2 item 4, + Phase 1 of the cutover). Gram's existing JSON parsing is untouched and + keeps serving Claude Code and collector traffic; generic collectors work + out of the box since protobuf is the OTLP/HTTP default. No decision left + open. +- **O11 — Dual-rail reader guidance.** §4.9 recommends coexistence with + URN-origin scoping and one-rail-per-metric, but today's metrics MVs + aggregate `telemetry_logs` without origin filters. Do we (a) audit and + add origin predicates to the MVs that count tool calls/sessions/denies, + (b) publish the two-record decision precedence (enforcement log + authoritative; Claude `tool_decision` as provider-side provenance, the + agenthooks record carrying no verdict at all) as a documented convention, + and (c) constrain O5's usage-row synthesis to providers with no native + usage rail so it can't double-count `provider_otel:metric:usage`? All + three look necessary before recommending customers enable both rails. + +--- + +## 8. Alternatives considered + +- **Traces-first (rev 1 of this RFC)** — one span per hook event with + synthesized turn/session parents, shipped to a **new** + `/rpc/hooks.otel/v1/traces` ingest. Rejected for v1: gram has no traces + ingest or span storage today, so it required a new endpoint, a + `TelemetryEventKind` extension, span→row mapping, and reader migration — + all eliminated by reusing the built-out logs pipeline. **Deliberately not + precluded:** the deterministic trace/span identity in §4.4 is chosen so a + future traces signal mints spans with the *same* IDs, letting historical + log records and future spans share identity; the server's in-process SDK + pipeline would gain a second OTLP signal path, not a rewrite. +- **Hand-rolled OTLP structs instead of the OTel-Go SDK** — considered + (briefly the draft position) to keep `go.mod` dependency-free: hand-write + the OTLP/JSON mapping and skip `sdk/log`/`otlploghttp` entirely. + Rejected: the backend is already designed around OTLP ingestion, and the + SDK guarantees wire-format correctness (Resource/attribute/severity/ + trace-context semantics) plus free protobuf support, where a hand-rolled + encoder is a parallel implementation to keep conformant forever. Under + the client/server model the SDK's long-lived-process defaults fit the + host process exactly (§4.1 Dependencies, §4.5, §4.7). +- **Disk spool + externally supervised exporter daemon (rev 2 of this + RFC, built and reviewed as PR #8)** — the hook process appended + protojson records to a capped NDJSON spool; a separate + `agenthooks exporter` daemon (supervised by a service manager or device + agent) tailed, checkpointed, and shipped it. Superseded by the + client/server architecture: once the full pipeline runs in a + long-running server anyway, the spool's reason to exist — surviving + process-per-event — disappears, and with it the exporter daemon, its + supervision requirement, the checkpoint/tail/sweep machinery, and the + at-rest content surface. The durability trade (disk → server memory, + flush-on-shutdown) was accepted deliberately: telemetry is best-effort, + and the enforcement rail carries everything that must survive crashes. +- **Metrics as the primary signal** — rejected: pre-aggregation discards + the wide-event dimensionality gram's model is built on; gram already + derives metrics from rows server-side. +- **Ship telemetry synchronously with a short timeout** — rejected: any + network on the critical path eventually bites (provider timeouts are + unforgiving, §6 of DESIGN.md); spool-and-drain is already proven in this + ecosystem (relay drain). +- **Debounced detached shipper spawned from hook activity (rev 2 of this + RFC, briefly implemented)** — each hook event could re-exec the binary as + a short-lived detached shipper behind a 30 s debounce, the MCP-warm + pattern. Replaced by the supervised exporter: hooks doing process + management meant fork/exec on the hook path (however debounced), + credentials passed over stdin between processes, ad-hoc run budgets + instead of a real lifecycle, and idle machines whose spool tails only + shipped on the *next* session. A daemon under external supervision is + operationally boring in all four dimensions, and the argv contract + already had a natural place for the verb. +- **Keep telemetry in the consumer binary only (status quo of DESIGN.md + §11)** — rejected by prior decision: every consumer would rebuild the + spool/exporter/record-model machinery; the library owns the wire and the + event taxonomy, so it is the right owner of the record schema. +- **New correlation ID** — rejected by prior decision: session ID + turn ID + already exist on both rails (`SessionInfo`, `HookIngestSession`) and + reproducing gram's existing `canonicalTraceID` derivation makes them + sufficient (and keeps the shadow-MCP provenance join intact). From c9734a5749b138cbc015056bc979c01da538d71b Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Wed, 5 Aug 2026 19:31:22 -0700 Subject: [PATCH 5/9] fix(ipc): share maxSocketPath so the cross-platform tests compile on windows Co-authored-by: Cursor --- internal/ipc/endpoint_unix.go | 5 ----- internal/ipc/ipc.go | 7 +++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/ipc/endpoint_unix.go b/internal/ipc/endpoint_unix.go index 457a838..da3c2ae 100644 --- a/internal/ipc/endpoint_unix.go +++ b/internal/ipc/endpoint_unix.go @@ -11,11 +11,6 @@ import ( "time" ) -// maxSocketPath conservatively undercuts both sun_path limits (104 bytes on -// macOS/BSD, 108 on Linux). Longer derived paths fall back to the system -// temp dir, whose paths are short by construction. -const maxSocketPath = 96 - // stateDir roots the sockets and lock files: // $XDG_STATE_HOME/agenthooks, falling back to the user cache dir, then the // system temp dir. diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 58ce453..a5ffd7e 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -33,6 +33,13 @@ const ProtocolVersion = 1 // length prefix allocate unbounded memory. const MaxFrameBytes = 64 << 20 +// maxSocketPath conservatively undercuts both sun_path limits (104 bytes on +// macOS/BSD, 108 on Linux); longer derived unix socket paths fall back to +// the system temp dir, whose paths are short by construction. Windows named +// pipes have no such constraint — the constant lives here so cross-platform +// test code compiles everywhere. +const maxSocketPath = 96 + // Request is one hook invocation forwarded by the client: the client's full // argv (the server re-parses it for --provider/--timeout/--filter and the // payload-carrying positionals of the notify verb), the raw stdin payload, From 51a95069811bbba5fa3526d6e05e343d0fb8040f Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Thu, 6 Aug 2026 16:08:59 -0700 Subject: [PATCH 6/9] feat(client): fail open when the hook server is unreachable instead of running in-process Co-authored-by: Cursor --- DESIGN.md | 12 ++++++----- README.md | 6 ++++-- RFC-telemetry.md | 40 +++++++++++++++++----------------- agenthooks.go | 6 +++--- client.go | 46 ++++++++++++++++++++++------------------ clientserver_e2e_test.go | 4 ++-- clientserver_test.go | 30 ++++++++++++++++---------- install/install.go | 5 +++-- internal/ipc/ipc.go | 8 +++---- runnertelemetry.go | 6 +++--- testdata/hookbin/main.go | 4 ++-- 11 files changed, 93 insertions(+), 74 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index af9de44..c3019aa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -519,7 +519,7 @@ control: ``` mybinary agenthooks client --provider=claude-code # per-hook process: forwards to the hook server, relays the decision (default install) mybinary agenthooks server [--idle-timeout=10m] # long-running singleton hosting the pipeline (auto-spawned by client) -mybinary agenthooks run --provider=claude-code # process-per-event, stdin JSON — the client's in-process fallback path +mybinary agenthooks run --provider=claude-code # process-per-event, stdin JSON — the direct single-process mode mybinary agenthooks run --provider=cursor --argv-payload # legacy cursor-agent CLI (<2026-05-20): payload in argv mybinary agenthooks notify --provider=codex # legacy codex notify: kebab-case JSON in argv[1] mybinary agenthooks serve --provider=opencode # long-lived daemon for the OpenCode shim (§8) @@ -542,9 +542,11 @@ Runtime responsibilities per mode: the flags before the `agenthooks` sentinel, e.g. `--config=...`), so distinct deployments get distinct servers (`internal/ipc`). No server? The client re-execs itself as one (detached, spawn-lock serialized) - and retries for ~2 s; any failure past that falls back to running the - identical pipeline in-process — decisions never depend on server - health. The server early-acks non-gating events right after decode + and retries for ~2 s; any failure past that fails open — a warning on + the debug log, exit 0 with no output, which providers read as "no + opinion". The server is a hard dependency of client mode: the client + never runs the pipeline itself, and `run` remains the supported direct + single-process mode. The server early-acks non-gating events right after decode (processing continues async), shuts down after an idle period or on SIGINT/SIGTERM (flushing telemetry), and drains-and-exits when a client built from a newer binary connects, so upgrades roll forward on @@ -669,7 +671,7 @@ upstream reference. Seeded from provider research and production observation: | 7 | Cursor fail-open default; crashed hook allows action | `failClosed` in generated config on decision events | | 8 | Codex: empty stdout = allow; unknown JSON rejected; `ask`/`approve` fail the hook run | codec emits exact dialect; Ask degrades per policy | | 9 | Codex hooks require user trust of definition hash | install pre-seeds `[hooks.state]` trusted hashes | -| 10 | Codex has no async hooks (`async` parsed-but-skipped) | client mode: the hook server early-acks non-gating events after decode (§6); the in-process fallback runs them synchronously (best-effort) | +| 10 | Codex has no async hooks (`async` parsed-but-skipped) | client mode: the hook server early-acks non-gating events after decode (§6); direct `run` mode runs them synchronously (best-effort) | | 11 | Gemini: exit codes ≠ docs (any non-zero except 1 blocks); stderr parsed as decision when stdout empty | runner always writes explicit JSON to stdout; never bare non-zero exits | | 12 | Gemini `hookSpecificOutput.tool_input` is shallow-merge | `ErrLossyUpdate` when a rewrite deletes keys; docs marker | | 13 | Gemini `additionalContext` HTML-escapes `<`/`>` | documented loss; optional pre-encoding | diff --git a/README.md b/README.md index 5ba078e..ecf502c 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,10 @@ err := install.Install(ctx, m, install.Target{ Generated configs bake in the argv contract (`mybinary agenthooks client --provider=...` — a lightweight per-hook client that forwards each event to an auto-spawned, long-running `agenthooks server` singleton and relays its -decision, degrading to the in-process `run` pipeline whenever the server is -unreachable), per-provider timeout units, async workarounds (sync `Stop` on +decision; when the server cannot be reached or spawned the client fails +open — exit 0, no output — rather than running the pipeline itself, with +`run` remaining the direct single-process mode), per-provider timeout +units, async workarounds (sync `Stop` on Claude cowork; non-gating events are early-acked by the server), Cursor `failClosed`, and Codex trust-hash pre-seeding. Handlers run concurrently on the server, so they must be safe for concurrent use. diff --git a/RFC-telemetry.md b/RFC-telemetry.md index ff96341..7f1dad6 100644 --- a/RFC-telemetry.md +++ b/RFC-telemetry.md @@ -168,8 +168,8 @@ pseudo trace IDs (§4.4). No new correlation ID is introduced. - **N4** — Per-record durability. There is no disk spool: the hook server's memory is the buffer and its shutdown flush points (§4.7) are the durability boundary. Records buffered in a server that crashes — - or recorded on the client's rare in-process fallback path, whose - process exits before a batch ships — are lost, and that is accepted: + or recorded in a per-hook process (plain `run`), which exits before a + batch ships — are lost, and that is accepted: telemetry is best-effort observability, and the enforcement rail (§5.1) carries everything decision-critical. - **N5** — Auth/login flows. The library takes endpoint + headers as config; @@ -311,11 +311,12 @@ serves every argv mode, and the recorder only becomes meaningful in the mode that lives long enough to ship — the **hook server** (`mybinary agenthooks server`), the singleton the per-hook `agenthooks client` forwards events to and auto-spawns on demand (§4.7). -When the client falls back to its in-process pipeline (server unreachable), -the recorder is constructed but the process exits before a batch ships; -that loss is accepted (N4). Nothing needs external supervision or -provisioning: the server is spawned by hook traffic and retires itself -when idle. +When the server is unreachable the client fails open (exit 0, no output) +without running the pipeline, so no record is produced on that path at +all; a per-hook `run` process constructs the recorder but exits before a +batch ships, and that loss is accepted (N4). Nothing needs external +supervision or provisioning: the server is spawned by hook traffic and +retires itself when idle. #### Dependencies: the OTel-Go SDK, configured for this shape @@ -387,8 +388,8 @@ recorder builds an OTel `log.Record` and `Emit`s it through the package's the OTLP/HTTP exporter** (§4.5) — so the call is wrapped in the same panic guard as observers and bounded: **one in-memory enqueue**, no I/O, no network, no retries on the critical path. The tap fires identically in -every execution mode (server-handled requests, the client's in-process -fallback, plain `run`, the OpenCode serve loop); what differs is only +every execution mode that runs the pipeline (server-handled requests, +plain `run`, the OpenCode serve loop); what differs is only whether the process lives long enough for the batch to ship (§4.7, N4). `Runner.Decide` (the embedded entry point) does **not** record telemetry: @@ -600,8 +601,8 @@ the hook server: the queue or the export request. - **Flush points:** the server calls `Recorder.Shutdown` — which force- flushes the queue — on idle shutdown, on SIGINT/SIGTERM, and before a - version-upgrade exit (§4.7). A per-hook process (client fallback, plain - `run`) exits without a flush; those records are lost by design (N4). + version-upgrade exit (§4.7). A per-hook process (plain `run`) exits + without a flush; those records are lost by design (N4). - **Retry:** transient export failures (network down, 5xx, 429 with `Retry-After`) are retried by the SDK exporter's built-in policy; records survive retries in memory only. Endpoint downtime longer than @@ -663,11 +664,12 @@ Its lifecycle is owned by hook traffic, with telemetry riding along: respawn runs the new binary — the LSP-style upgrade story. The rendezvous (socket + lock) is released *before* the final flush so the replacement server can bind immediately. -- **Fallback path.** If the client cannot reach or spawn a server within - its ~2 s budget, it runs the identical pipeline in-process (today's - `run` behavior). The tap still fires; the batch almost certainly does - not ship before exit. Decisions are never affected; telemetry loss on - this path is accepted (N4). +- **No fallback path.** If the client cannot reach or spawn a server + within its ~2 s budget, it fails open — exit 0 with no output, a + warning on the debug log — without running the pipeline: the server is + a hard dependency of client mode. No record is produced for such an + event; the gap shows up server-side as missing traffic rather than as + a silent degraded mode. - **No external supervision.** Nothing needs provisioning, boot registration, or a service manager: the server exists exactly when hook traffic exists. (Consumers running under external supervision anyway — @@ -708,9 +710,9 @@ retires itself when idle. on this rail — the enforcement rail owns anything that must not be silently lost. - The server itself is hook infrastructure, not telemetry infrastructure: - a server crash degrades hooks to the in-process fallback (decisions - unaffected) and telemetry to per-process best-effort until the next - spawn. + after a server crash, in-flight clients fail open and the next hook + invocation respawns the server; hook events in that window produce no + records. ### 4.9 Positioning vs Claude Code's native OTEL events (dual-rail) diff --git a/agenthooks.go b/agenthooks.go index 93c7852..3360653 100644 --- a/agenthooks.go +++ b/agenthooks.go @@ -303,7 +303,7 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout case "server": return r.serverMain(ctx, inv, stderr) case "client": - return r.clientMain(ctx, inv, stdin, stdout, stderr) + return r.clientMain(inv, stdin, stdout, stderr) } return r.runEvent(ctx, inv, r.readPayload(inv, stdin), runOpts{getenv: os.Getenv}, stdout, stderr) } @@ -346,8 +346,8 @@ func gatingKind(k EventKind) bool { return false } -// runEvent is the single-event pipeline behind the run, notify, and client -// (fallback and server-side) paths: detect → decode → dispatch → encode, +// runEvent is the single-event pipeline behind the run, notify, and +// server-side paths: detect → decode → dispatch → encode, // with the response written to stdout/stderr and the exit code returned. func (r *Runner) runEvent(ctx context.Context, inv *invocation, payload []byte, opts runOpts, stdout, stderr io.Writer) int { provider, conf := detectProvider(inv, payload, opts.getenv) diff --git a/client.go b/client.go index 5c98d9f..c6427ae 100644 --- a/client.go +++ b/client.go @@ -1,7 +1,6 @@ package agenthooks import ( - "context" "fmt" "io" "net" @@ -18,17 +17,18 @@ import ( // consumer-identity socket (spawning the server first if none answers), and // relays the server's stdout/stderr/exit code back to the provider. // -// The server is an optimization, never a dependency: any failure — no -// server, spawn blocked, connect timeout, protocol mismatch, truncated -// response — degrades to running the exact same pipeline in-process, which -// is byte-for-byte today's `run` behavior. Decisions never wait on server -// health; only the warm caches and in-process telemetry do. +// In client mode the server is a hard dependency. When it cannot answer — +// no server, spawn blocked or failed, connect timeout, protocol mismatch, +// truncated response, error frame — the client fails open immediately: a +// warning on the debug log, nothing on stdout/stderr, exit 0, which every +// provider dialect reads as "no opinion". The pipeline never runs in this +// process; `run` remains the supported direct single-process mode. const ( // clientDialTimeout bounds one connection attempt. clientDialTimeout = 250 * time.Millisecond // clientSpawnBudget bounds the whole connect-spawn-reconnect dance - // before the client gives up and runs in-process. + // before the client gives up and fails open. clientSpawnBudget = 2 * time.Second // clientResponseSlack rides on top of the hook deadline when waiting // for the server's response. @@ -51,20 +51,25 @@ var forwardedEnv = []string{ } // clientMain implements the `agenthooks client` argv mode. -func (r *Runner) clientMain(ctx context.Context, inv *invocation, stdin io.Reader, stdout, stderr io.Writer) int { +func (r *Runner) clientMain(inv *invocation, stdin io.Reader, stdout, stderr io.Writer) int { payload := r.readPayload(inv, stdin) resp, err := r.callServer(inv, payload) - if err == nil { - if len(resp.Stdout) > 0 { - _, _ = stdout.Write(resp.Stdout) - } - if len(resp.Stderr) > 0 { - _, _ = stderr.Write(resp.Stderr) - } - return resp.ExitCode + if err != nil { + // Fail open, immediately: nothing on stdout/stderr, exit 0 — the + // "no opinion" shape in every provider dialect. A non-zero exit or + // error text would read as a block or a hook failure to providers, + // and running the pipeline in this process would hide server + // breakage behind a silent degraded mode. + r.logger.Warn("agenthooks: hook server unavailable; failing open", "error", err) + return 0 + } + if len(resp.Stdout) > 0 { + _, _ = stdout.Write(resp.Stdout) + } + if len(resp.Stderr) > 0 { + _, _ = stderr.Write(resp.Stderr) } - r.logger.Warn("agenthooks: hook server unavailable; running in-process", "error", err) - return r.runEvent(ctx, inv, payload, runOpts{getenv: os.Getenv}, stdout, stderr) + return resp.ExitCode } // callServer performs one framed request/response exchange, spawning the @@ -101,9 +106,8 @@ func (r *Runner) callServer(inv *invocation, payload []byte) (*ipc.Response, err } // The response can legitimately take as long as the hook deadline (the - // server runs the same policy timeouts run mode would); past that plus - // slack, falling back in-process could still answer before the provider - // gives up on us. + // server runs the same policy timeouts run mode would); the slack past + // that covers frame transit before the client gives up and fails open. wait := defaultDeadline if inv.timeout > 0 { wait = inv.timeout diff --git a/clientserver_e2e_test.go b/clientserver_e2e_test.go index 587268d..171abd8 100644 --- a/clientserver_e2e_test.go +++ b/clientserver_e2e_test.go @@ -66,8 +66,8 @@ func TestClientAutoSpawnsDetachedServer(t *testing.T) { } // One server pid for both invocations — the second client reused the - // server the first one spawned; two distinct pids would mean two - // in-process fallbacks. + // server the first one spawned; two distinct pids would mean each + // client spawned its own server. pids := readPids(t, logPath) if len(pids) != 2 || pids[0] != pids[1] { t.Errorf("handler pids = %v, want the same server pid twice", pids) diff --git a/clientserver_test.go b/clientserver_test.go index 22663a0..aa7bdd1 100644 --- a/clientserver_test.go +++ b/clientserver_test.go @@ -120,7 +120,7 @@ func TestClientServerGatingRoundTrip(t *testing.T) { exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "5s")) // The client's own handler would allow: a deny response proves the - // decision came over the wire from the server, not from the fallback. + // decision came over the wire from the server, not from this process. client := quietRunner() noSpawn(client) client.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { @@ -238,23 +238,31 @@ func TestClientSpawnRaceStartsOneServer(t *testing.T) { waitExit(t, exit, "idle server") } -func TestClientFallsBackInProcessWhenSpawnBlocked(t *testing.T) { +func TestClientFailsOpenWhenServerUnavailable(t *testing.T) { preArgs := testIdentity(t) client := quietRunner(WithDedupDir(t.TempDir()), WithoutMCPResolution(), WithoutBackfill()) noSpawn(client) + // A gating deny handler that must never run: in client mode the server + // is a hard dependency, and an unreachable server means fail open — + // never a silent in-process run of the pipeline. client.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { - return Deny("fallback says no"), nil + t.Error("client mode must never run the pipeline in-process") + return Deny("must not run"), nil }) start := time.Now() - out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code"), fixture(t, "claude/pre_tool_use.json")) - if code != 0 || !strings.Contains(out, `"permissionDecision":"deny"`) || !strings.Contains(out, "fallback says no") { - t.Fatalf("fallback must run the pipeline in-process: %q (exit %d)", out, code) - } - // A failed spawn must not burn the reconnect budget: the seam errors - // immediately, so only the initial dial cost precedes the fallback. - if elapsed := time.Since(start); elapsed > 5*time.Second { - t.Errorf("fallback took %s", elapsed) + var out, errb bytes.Buffer + code := client.Run(context.Background(), clientRunArgs(preArgs, "--provider=claude-code"), + bytes.NewReader(fixture(t, "claude/pre_tool_use.json")), &out, &errb) + if code != 0 || out.Len() != 0 || errb.Len() != 0 { + t.Fatalf("unreachable server must fail open (exit 0, no output): stdout %q, stderr %q (exit %d)", + out.String(), errb.String(), code) + } + // The fail-open must be prompt: a failed spawn errors the seam + // immediately, so at most the connect/spawn retry budget (plus slack) + // precedes the exit. + if elapsed := time.Since(start); elapsed > clientSpawnBudget+3*time.Second { + t.Errorf("fail-open took %s, want under the spawn budget plus slack", elapsed) } } diff --git a/install/install.go b/install/install.go index f093cda..55d242a 100644 --- a/install/install.go +++ b/install/install.go @@ -330,8 +330,9 @@ func mergeManagedJSON(existing, rendered []byte) ([]byte, error) { // the argv contract (--provider, --timeout) and the in-process --filter for // dialects that can't express the matcher. The verb is `client`: the hook // process forwards the event to the long-running hook server (auto-spawned -// on first use) and relays its decision; when no server can be reached it -// runs the same pipeline in-process, so behavior degrades to `run`. +// on first use) and relays its decision; when no server can be reached the +// client fails open (exit 0, no output) rather than running the pipeline +// itself. func hookCommand(m Manifest, p agenthooks.Provider, spec HookSpec) string { parts := make([]string, 0, len(m.Command)+5) for _, c := range m.Command { diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index a5ffd7e..4e440b0 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -23,8 +23,8 @@ import ( ) // ProtocolVersion is the framing/schema version. A server that receives a -// request with a different version answers with an error frame, which makes -// the client fall back to its in-process pipeline. +// request with a different version answers with an error frame, which the +// client fails open on (exit 0, no output) like any other server failure. const ProtocolVersion = 1 // MaxFrameBytes bounds one frame. The hook payload cap is 32 MiB; base64 @@ -62,8 +62,8 @@ type Request struct { // which relays it verbatim: stdout bytes, stderr bytes (Kimi's blocking // mechanism is exit 2 with the reason on stderr), and the exit code. A // non-empty Error means the server could not process the request at the -// protocol level; the client treats it like an unreachable server and runs -// in-process. +// protocol level; the client treats it like an unreachable server and +// fails open (exit 0, no output). type Response struct { V int `json:"v"` Error string `json:"error,omitempty"` diff --git a/runnertelemetry.go b/runnertelemetry.go index bec814e..7dbdaf2 100644 --- a/runnertelemetry.go +++ b/runnertelemetry.go @@ -52,9 +52,9 @@ type TelemetryRecorder interface { // The recorder batches in process and ships over OTLP/HTTP in the // background, so it is only meaningful in a long-lived process: the hook // server (`mybinary agenthooks server`), which flushes it on idle shutdown -// and on signals. In a per-hook process (the client's in-process fallback, -// plain `run`) the process usually exits before a batch ships — telemetry -// there is best-effort by design and the loss is accepted. +// and on signals. In a per-hook process (plain `run`) the process usually +// exits before a batch ships — telemetry there is best-effort by design +// and the loss is accepted. // // Opt-in and fail-open by construction — without the option nothing // changes; with it, a recorder failure degrades to a logged warning, never diff --git a/testdata/hookbin/main.go b/testdata/hookbin/main.go index a5aa915..b67f501 100644 --- a/testdata/hookbin/main.go +++ b/testdata/hookbin/main.go @@ -2,8 +2,8 @@ // end-to-end test (clientserver_e2e_test.go): a minimal consumer binary // whose tool.pre handler denies and appends its process id to // $HOOKBIN_LOG. Two client invocations answered by the same pid prove the -// decisions came from one long-running auto-spawned server rather than two -// in-process fallbacks. +// decisions came from one long-running auto-spawned server rather than +// two independently spawned servers. package main import ( From f16b538fc3859e5c30210493839f89c85d98b8ef Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Thu, 6 Aug 2026 16:08:59 -0700 Subject: [PATCH 7/9] feat(telemetry): add Config.ExportInterval as a deterministic export seam for tests Co-authored-by: Cursor --- telemetry/telemetry.go | 14 +++++++++++++- telemetry/telemetry_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go index 0c2c601..347874e 100644 --- a/telemetry/telemetry.go +++ b/telemetry/telemetry.go @@ -34,6 +34,7 @@ import ( "runtime" "runtime/debug" "strings" + "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" @@ -83,6 +84,13 @@ type Config struct { // commands, token-shaped values) first; Redactor runs after it. Redactor func(key string, value string) string + // ExportInterval overrides how often the batch processor ships buffered + // records in the background. Zero keeps the SDK default (1s). Intended + // for tests and latency tuning — a short interval lets a test observe + // exports deterministically without racing the default schedule; + // production consumers rarely need to set it. + ExportInterval time.Duration + // HonorTraceparent opts into W3C trace-context parenting: when the // recording process carries a valid TRACEPARENT environment variable // (read once at construction — in the client/server architecture that @@ -152,9 +160,13 @@ func New(cfg Config) (*Recorder, error) { if err != nil { return nil, errors.New("telemetry: building exporter: " + err.Error()) } + var procOpts []sdklog.BatchProcessorOption + if cfg.ExportInterval > 0 { + procOpts = append(procOpts, sdklog.WithExportInterval(cfg.ExportInterval)) + } provider := sdklog.NewLoggerProvider( sdklog.WithResource(res), - sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), + sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter, procOpts...)), ) r := &Recorder{ cfg: cfg, diff --git a/telemetry/telemetry_test.go b/telemetry/telemetry_test.go index 06a7ce9..795bc8a 100644 --- a/telemetry/telemetry_test.go +++ b/telemetry/telemetry_test.go @@ -140,6 +140,30 @@ func TestRecordHookAfterShutdownIsNoOp(t *testing.T) { } } +// TestExportIntervalReachesBatchProcessor asserts the Config.ExportInterval +// seam is plumbed through: with a short interval the record ships in the +// background — no ForceFlush — well before the SDK's 1s default schedule +// would have fired, so a dropped option fails the test. +func TestExportIntervalReachesBatchProcessor(t *testing.T) { + rec, exp := newTestRecorder(t, func(cfg *Config) { cfg.ExportInterval = 10 * time.Millisecond }) + if err := rec.RecordHook(toolPreRecord()); err != nil { + t.Fatalf("RecordHook: %v", err) + } + deadline := time.Now().Add(900 * time.Millisecond) + for { + exp.mu.Lock() + n := len(exp.recs) + exp.mu.Unlock() + if n >= 1 { + return + } + if time.Now().After(deadline) { + t.Fatalf("record never exported on the custom interval (records = %d)", n) + } + time.Sleep(5 * time.Millisecond) + } +} + // TestShutdownShipsOverOTLP exercises the real export path end to end: a // record is enqueued, Shutdown flushes it to an OTLP/HTTP endpoint with // gzip compression and the configured auth headers. From 4f2c29aaffe8354c618ee4b7551294267146f61d Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Thu, 6 Aug 2026 16:52:12 -0700 Subject: [PATCH 8/9] feat(client): scope hook servers per project location, add a --socket rendezvous override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rendezvous identity now includes the client's normalized working directory (filepath.Clean + best-effort EvalSymlinks), so each project gets its own server — the LSP per-workspace model — keeping cwd-dependent best-effort state (MCP config discovery, inventories) correct per project; a missing cwd degrades to the historic exe+preArgs identity. Socket names still embed only the identity hash, never the path, staying within sun_path limits. --socket= on client and server bypasses derivation entirely for external supervision, tests, containers, and machine-wide servers: the endpoint is used verbatim and the locks derive from a hash of it. Overlong unix paths are rejected loudly at server startup and fail open on the client. Spawning clients now always hand the resolved endpoint to the server via --socket, so a spawned server binds exactly what its client will dial instead of re-deriving from its own, possibly different, process state. Co-authored-by: Cursor --- agenthooks.go | 2 +- client.go | 43 ++++++-- clientserver_e2e_test.go | 9 +- clientserver_test.go | 174 +++++++++++++++++++++++++++++-- detect.go | 16 ++- detect_test.go | 14 +++ internal/ipc/endpoint_unix.go | 11 ++ internal/ipc/endpoint_windows.go | 6 ++ internal/ipc/ipc.go | 112 ++++++++++++++++---- internal/ipc/ipc_test.go | 132 +++++++++++++++++++++-- server.go | 27 +++-- 11 files changed, 487 insertions(+), 59 deletions(-) diff --git a/agenthooks.go b/agenthooks.go index 3360653..d8c222c 100644 --- a/agenthooks.go +++ b/agenthooks.go @@ -56,7 +56,7 @@ type Runner struct { interceptors []Interceptor afterEvent afterEvent telemetryShutdown func(context.Context) error - spawnServer func(preArgs []string) error + spawnServer func(preArgs []string, endpoint string) error hSessionStart []func(context.Context, *SessionStartEvent) (SessionStartDecision, error) hSessionEnd []func(context.Context, *SessionEndEvent) error diff --git a/client.go b/client.go index c6427ae..506a52c 100644 --- a/client.go +++ b/client.go @@ -14,9 +14,18 @@ import ( // The `agenthooks client` mode: the lightweight per-hook process that // generated configs install in place of `run`. It reads the payload, // forwards the invocation to the long-running hook server over the -// consumer-identity socket (spawning the server first if none answers), and +// rendezvous socket (spawning the server first if none answers), and // relays the server's stdout/stderr/exit code back to the provider. // +// The rendezvous is derived from the consumer identity — executable, +// pre-sentinel flags, and this process's normalized working directory — +// so each project location gets its own server (the LSP per-workspace +// model); `--socket=` overrides the derivation entirely for +// externally supervised or machine-wide servers. Whenever the client +// spawns, it hands the resolved endpoint to the server via `--socket`, so +// the server binds exactly what its client will dial instead of +// re-deriving from its own — possibly different — process state. +// // In client mode the server is a hard dependency. When it cannot answer — // no server, spawn blocked or failed, connect timeout, protocol mismatch, // truncated response, error frame — the client fails open immediately: a @@ -79,7 +88,7 @@ func (r *Runner) callServer(inv *invocation, payload []byte) (*ipc.Response, err if err != nil { return nil, fmt.Errorf("resolving executable: %w", err) } - addr, err := ipc.Resolve(exe, inv.preArgs) + addr, err := resolveAddress(exe, inv) if err != nil { return nil, err } @@ -126,6 +135,20 @@ func (r *Runner) callServer(inv *invocation, payload []byte) (*ipc.Response, err return &resp, nil } +// resolveAddress picks the rendezvous for one client or server invocation: +// the explicit --socket endpoint when given, otherwise the derived +// consumer identity — executable, pre-sentinel flags, and this process's +// normalized working directory, so each project location gets its own +// server. A missing working directory degrades to the location-less +// exe+preArgs identity. +func resolveAddress(exe string, inv *invocation) (ipc.Address, error) { + if inv.socket != "" { + return ipc.ResolveEndpoint(inv.socket) + } + cwd, _ := os.Getwd() + return ipc.Resolve(exe, inv.preArgs, ipc.Location(cwd)) +} + // connectOrSpawn dials the endpoint, auto-spawning the server on a miss. A // file lock serializes the spawn so a burst of hook invocations starts one // server; losers of the lock race just keep re-dialing while the winner's @@ -144,7 +167,7 @@ func (r *Runner) connectOrSpawn(addr ipc.Address, preArgs []string) (net.Conn, e if r.spawnServer == nil { return nil, fmt.Errorf("dialing hook server: %w (spawning disabled)", err) } - if spawnErr := r.spawnServer(preArgs); spawnErr != nil { + if spawnErr := r.spawnServer(preArgs, addr.Endpoint); spawnErr != nil { return nil, fmt.Errorf("spawning hook server: %w", spawnErr) } } @@ -165,12 +188,16 @@ func (r *Runner) connectOrSpawn(addr ipc.Address, preArgs []string) (net.Conn, e } // spawnServerDetached re-execs this binary as the detached hook server, -// preserving the consumer flags that define the server identity -// ("mybinary --config=x agenthooks server"). It is the default behind +// preserving the consumer flags that define the server identity and +// pinning the rendezvous the client just resolved +// ("mybinary --config=x agenthooks server --socket="): the +// spawned server binds exactly the endpoint its client will dial, whether +// that endpoint was derived or an explicit --socket, without re-deriving +// it from its own cwd or exe view. It is the default behind // Runner.spawnServer; tests substitute in-process spawns. -func spawnServerDetached(preArgs []string) error { - args := make([]string, 0, len(preArgs)+2) +func spawnServerDetached(preArgs []string, endpoint string) error { + args := make([]string, 0, len(preArgs)+3) args = append(args, preArgs...) - args = append(args, "agenthooks", "server") + args = append(args, "agenthooks", "server", "--socket="+endpoint) return startDetachedSelf(args, nil) } diff --git a/clientserver_e2e_test.go b/clientserver_e2e_test.go index 171abd8..a4e14c9 100644 --- a/clientserver_e2e_test.go +++ b/clientserver_e2e_test.go @@ -76,8 +76,13 @@ func TestClientAutoSpawnsDetachedServer(t *testing.T) { // The detached server idles out on its own (1s idle timeout). Probe // sparsely: every accepted connection — including a probe — counts as // activity, so probing faster than the idle window would keep the - // server alive forever. - addr, err := ipc.Resolve(bin, nil) + // server alive forever. The rendezvous derivation must match the + // clients': they ran with this process's cwd as their location. + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + addr, err := ipc.Resolve(bin, nil, ipc.Location(cwd)) if err != nil { t.Fatal(err) } diff --git a/clientserver_test.go b/clientserver_test.go index aa7bdd1..fa1aba0 100644 --- a/clientserver_test.go +++ b/clientserver_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "sync" "sync/atomic" @@ -33,12 +34,12 @@ func testIdentity(t *testing.T) []string { return []string{"--config=" + filepath.Join(t.TempDir(), "cfg.json")} } -func serverRunArgs(preArgs []string, idle string) []string { +func serverRunArgs(preArgs []string, idle string, extra ...string) []string { args := append(append([]string(nil), preArgs...), "agenthooks", "server") if idle != "" { args = append(args, "--idle-timeout="+idle) } - return args + return append(args, extra...) } func clientRunArgs(preArgs []string, extra ...string) []string { @@ -68,8 +69,9 @@ func startServer(t *testing.T, r *Runner, args []string) chan int { return exit } -// waitForServer polls the endpoint derived from args' pre-sentinel flags -// until something accepts. +// waitForServer polls the endpoint the args resolve to (explicit --socket, +// or the identity derived from the pre-sentinel flags and this process's +// cwd) until something accepts. func waitForServer(t *testing.T, args []string) { t.Helper() inv, err := parseArgs(args) @@ -80,7 +82,7 @@ func waitForServer(t *testing.T, args []string) { if err != nil { t.Fatal(err) } - addr, err := ipc.Resolve(exe, inv.preArgs) + addr, err := resolveAddress(exe, inv) if err != nil { t.Fatal(err) } @@ -109,10 +111,27 @@ func waitExit(t *testing.T, exit chan int, what string) int { } } +// explicitEndpoint returns a --socket value valid on this OS: a short +// unix socket path (a fresh short-prefix temp dir keeps it well under the +// sun_path budget regardless of the test TempDir's depth), or a uniquely +// named pipe on Windows. +func explicitEndpoint(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + return `\\.\pipe\agenthooks-test-` + strings.ReplaceAll(t.Name(), "/", "-") + } + dir, err := os.MkdirTemp("", "ahsock") //nolint:usetesting // t.TempDir paths can exceed the sun_path budget; this stays short + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, "s.sock") +} + // noSpawn disables auto-spawn so a client test fails fast instead of // re-execing the test binary. func noSpawn(r *Runner) { - r.spawnServer = func([]string) error { return errors.New("spawning disabled in this test") } + r.spawnServer = func([]string, string) error { return errors.New("spawning disabled in this test") } } func TestClientServerGatingRoundTrip(t *testing.T) { @@ -171,13 +190,18 @@ func TestClientSpawnsServerOnDemand(t *testing.T) { client.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) { return Allow(), nil }) - client.spawnServer = func(gotPre []string) error { + client.spawnServer = func(gotPre []string, endpoint string) error { spawns.Add(1) if len(gotPre) != len(preArgs) || gotPre[0] != preArgs[0] { t.Errorf("spawn must preserve pre-sentinel flags: %v", gotPre) } + if endpoint == "" { + t.Errorf("spawn must hand the resolved endpoint to the server") + } + // The server binds exactly the endpoint it is told, like the real + // detached spawn ("agenthooks server --socket=") does. go func() { - exit <- server.Run(context.Background(), serverRunArgs(gotPre, "5s"), strings.NewReader(""), io.Discard, io.Discard) + exit <- server.Run(context.Background(), serverRunArgs(gotPre, "5s", "--socket="+endpoint), strings.NewReader(""), io.Discard, io.Discard) }() return nil } @@ -198,12 +222,12 @@ func TestClientSpawnRaceStartsOneServer(t *testing.T) { exit := make(chan int, 1) var spawns atomic.Int32 - spawn := func(gotPre []string) error { + spawn := func(gotPre []string, endpoint string) error { if spawns.Add(1) > 1 { return errors.New("second spawn attempted; the spawn lock failed") } go func() { - exit <- server.Run(context.Background(), serverRunArgs(gotPre, "5s"), strings.NewReader(""), io.Discard, io.Discard) + exit <- server.Run(context.Background(), serverRunArgs(gotPre, "5s", "--socket="+endpoint), strings.NewReader(""), io.Discard, io.Discard) }() return nil } @@ -238,6 +262,134 @@ func TestClientSpawnRaceStartsOneServer(t *testing.T) { waitExit(t, exit, "idle server") } +// Clients running in two different project directories derive two +// different rendezvous and therefore two servers — the LSP per-workspace +// model. Spawns are in-process via the seam; each spawned server is handed +// the client's endpoint via --socket exactly like the detached re-exec. +func TestClientPerLocationServers(t *testing.T) { + preArgs := testIdentity(t) + exits := make(chan int, 2) + var mu sync.Mutex + var endpoints []string + spawn := func(gotPre []string, endpoint string) error { + mu.Lock() + endpoints = append(endpoints, endpoint) + mu.Unlock() + srv := denyServerRunner(t) + go func() { + exits <- srv.Run(context.Background(), serverRunArgs(gotPre, "5s", "--socket="+endpoint), strings.NewReader(""), io.Discard, io.Discard) + }() + return nil + } + + payload := fixture(t, "claude/pre_tool_use.json") // load before leaving the package dir + for _, dir := range []string{t.TempDir(), t.TempDir()} { + t.Chdir(dir) + c := quietRunner() + c.spawnServer = spawn + out, code := runWith(t, c, clientRunArgs(preArgs, "--provider=claude-code"), payload) + if out != denyWire || code != 0 { + t.Fatalf("client in %s: got %q (exit %d), want a per-location server's deny", dir, out, code) + } + } + + mu.Lock() + got := append([]string(nil), endpoints...) + mu.Unlock() + if len(got) != 2 || got[0] == got[1] { + t.Fatalf("distinct locations must spawn distinct servers, got endpoints %v", got) + } + for i := 0; i < 2; i++ { + waitExit(t, exits, "per-location server") + } +} + +// A server started with --socket binds exactly that endpoint and a client +// passing the same --socket reaches it — no identity derivation on either +// side — while the derived rendezvous stays untouched. +func TestClientServerExplicitSocket(t *testing.T) { + preArgs := testIdentity(t) + endpoint := explicitEndpoint(t) + exit := startServer(t, denyServerRunner(t), serverRunArgs(preArgs, "5s", "--socket="+endpoint)) + + client := quietRunner() + noSpawn(client) + out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code", "--socket="+endpoint), fixture(t, "claude/pre_tool_use.json")) + if out != denyWire || code != 0 { + t.Fatalf("got %q (exit %d), want the --socket server's deny", out, code) + } + + // A socket-less sibling derives the identity rendezvous, where nothing + // listens: it must fail open, proving the override really is a separate + // rendezvous rather than an alias of the derived one. + plain := quietRunner() + noSpawn(plain) + var pout, perr bytes.Buffer + pcode := plain.Run(context.Background(), clientRunArgs(preArgs, "--provider=claude-code"), + bytes.NewReader(fixture(t, "claude/pre_tool_use.json")), &pout, &perr) + if pcode != 0 || pout.Len() != 0 || perr.Len() != 0 { + t.Errorf("derived-rendezvous client must fail open: %q %q (exit %d)", pout.String(), perr.String(), pcode) + } + waitExit(t, exit, "idle server") +} + +// When a --socket client must spawn, the spawned server is handed the same +// --socket, so both land on the operator-chosen endpoint. +func TestClientSpawnPassesExplicitSocket(t *testing.T) { + preArgs := testIdentity(t) + endpoint := explicitEndpoint(t) + exit := make(chan int, 1) + + client := quietRunner() + client.spawnServer = func(gotPre []string, gotEndpoint string) error { + if gotEndpoint != endpoint { + t.Errorf("spawn endpoint = %q, want the client's --socket %q", gotEndpoint, endpoint) + } + srv := denyServerRunner(t) + go func() { + exit <- srv.Run(context.Background(), serverRunArgs(gotPre, "5s", "--socket="+gotEndpoint), strings.NewReader(""), io.Discard, io.Discard) + }() + return nil + } + out, code := runWith(t, client, clientRunArgs(preArgs, "--provider=claude-code", "--socket="+endpoint), fixture(t, "claude/pre_tool_use.json")) + if out != denyWire || code != 0 { + t.Fatalf("got %q (exit %d), want the spawned server's deny", out, code) + } + waitExit(t, exit, "idle server") +} + +// An overlong --socket value cannot be bound or dialed: the client fails +// open rather than surfacing an error to the provider. +func TestClientFailsOpenOnRejectedSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("named pipes have no path-length constraint") + } + client := quietRunner() + noSpawn(client) + long := "--socket=/" + strings.Repeat("x", 200) + "/s.sock" + var out, errb bytes.Buffer + code := client.Run(context.Background(), clientRunArgs(nil, "--provider=claude-code", long), + bytes.NewReader(fixture(t, "claude/pre_tool_use.json")), &out, &errb) + if code != 0 || out.Len() != 0 || errb.Len() != 0 { + t.Errorf("rejected --socket must fail open: %q %q (exit %d)", out.String(), errb.String(), code) + } +} + +// The server side of the same mistake is loud: exit 1 with the validation +// error on stderr, at startup, before any bind. +func TestServerRejectsOverlongSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("named pipes have no path-length constraint") + } + srv := quietRunner() + var out, errb bytes.Buffer + code := srv.Run(context.Background(), serverRunArgs(nil, "", "--socket=/"+strings.Repeat("x", 200)+"/s.sock"), + strings.NewReader(""), &out, &errb) + if code != 1 || !strings.Contains(errb.String(), "at most") { + t.Errorf("overlong --socket must fail server startup loudly: exit %d, stderr %q", code, errb.String()) + } +} + func TestClientFailsOpenWhenServerUnavailable(t *testing.T) { preArgs := testIdentity(t) client := quietRunner(WithDedupDir(t.TempDir()), WithoutMCPResolution(), WithoutBackfill()) @@ -418,7 +570,7 @@ func rawRequest(t *testing.T, preArgs []string, req ipc.Request) ipc.Response { if err != nil { t.Fatal(err) } - addr, err := ipc.Resolve(exe, preArgs) + addr, err := resolveAddress(exe, &invocation{preArgs: preArgs}) if err != nil { t.Fatal(err) } diff --git a/detect.go b/detect.go index 934fbf2..3a1c10c 100644 --- a/detect.go +++ b/detect.go @@ -2,6 +2,7 @@ package agenthooks import ( "encoding/json" + "errors" "fmt" "strings" "time" @@ -14,7 +15,11 @@ import ( // mybinary agenthooks run --provider=cursor --argv-payload # legacy cursor CLI // mybinary agenthooks notify --provider=codex # legacy codex notify (argv JSON) // mybinary agenthooks serve --provider=opencode # NDJSON daemon for the shim -// mybinary agenthooks server [--idle-timeout=10m] # hook server (auto-spawned by client) +// mybinary agenthooks server [--idle-timeout=10m] [--socket=P] # hook server (auto-spawned by client) +// +// --socket=P (client and server modes; never rendered by install) pins the +// rendezvous to an explicit endpoint instead of the derived identity — for +// external supervision, tests, containers, and machine-wide servers. type invocation struct { mode string // "run", "notify", "serve", "server", "client" provider Provider @@ -34,6 +39,10 @@ type invocation struct { preArgs []string // idleTimeout overrides the server's idle shutdown (server mode only). idleTimeout time.Duration + // socket is the explicit rendezvous override (client/server modes): + // the endpoint — a unix socket path, or a named-pipe name on Windows — + // used verbatim, bypassing identity derivation (internal/ipc). + socket string } var validProviders = map[Provider]bool{ @@ -103,6 +112,11 @@ func parseArgs(args []string) (*invocation, error) { return nil, fmt.Errorf("agenthooks: bad --idle-timeout: %w", err) } inv.idleTimeout = d + case strings.HasPrefix(a, "--socket="): + inv.socket = strings.TrimPrefix(a, "--socket=") + if inv.socket == "" { + return nil, errors.New("agenthooks: bad --socket: empty endpoint") + } case strings.HasPrefix(a, "--"): // Unknown flags are tolerated for forward compatibility with // newer generated configs driving older library versions. diff --git a/detect_test.go b/detect_test.go index 9c3f9cb..291c915 100644 --- a/detect_test.go +++ b/detect_test.go @@ -22,6 +22,20 @@ func TestParseArgsConsumerFlagsBeforeSentinel(t *testing.T) { } } +func TestParseArgsSocketOverride(t *testing.T) { + t.Parallel() + inv, err := parseArgs([]string{"agenthooks", "client", "--provider=claude-code", "--socket=/tmp/agenthooks.sock"}) + if err != nil { + t.Fatal(err) + } + if inv.socket != "/tmp/agenthooks.sock" { + t.Errorf("socket = %q, want %q", inv.socket, "/tmp/agenthooks.sock") + } + if _, err := parseArgs([]string{"agenthooks", "server", "--socket="}); err == nil { + t.Errorf("an empty --socket endpoint must be rejected") + } +} + func TestParseArgsNoSentinel(t *testing.T) { t.Parallel() inv, err := parseArgs([]string{"--provider=claude-code"}) diff --git a/internal/ipc/endpoint_unix.go b/internal/ipc/endpoint_unix.go index da3c2ae..6611b62 100644 --- a/internal/ipc/endpoint_unix.go +++ b/internal/ipc/endpoint_unix.go @@ -4,6 +4,7 @@ package ipc import ( "errors" + "fmt" "net" "os" "path/filepath" @@ -37,6 +38,16 @@ func lockPath(dir, id, suffix string) string { return filepath.Join(dir, "agenthooks-"+id+suffix) } +// validateEndpoint vets an explicit --socket value: a unix socket path +// over the sun_path budget must be rejected up front with a clear error, +// not left to fail the bind or dial with a confusing OS one. +func validateEndpoint(explicit string) error { + if len(explicit) > maxSocketPath { + return fmt.Errorf("ipc: socket path %q is %d bytes; unix socket paths must be at most %d bytes", explicit, len(explicit), maxSocketPath) + } + return nil +} + // Listen binds the endpoint. A socket file left behind by a crashed server // (its advisory locks died with it, so the caller holds the server lock) is // probed and swept; if something actually answers, ErrAlreadyRunning. diff --git a/internal/ipc/endpoint_windows.go b/internal/ipc/endpoint_windows.go index 9a9ffc7..554fcb6 100644 --- a/internal/ipc/endpoint_windows.go +++ b/internal/ipc/endpoint_windows.go @@ -29,6 +29,12 @@ func lockPath(dir, id, suffix string) string { return filepath.Join(dir, "agenthooks-"+id+suffix) } +// validateEndpoint vets an explicit --socket value. Named-pipe names have +// no sun_path-style length constraint, so everything passes. +func validateEndpoint(string) error { + return nil +} + // Listen creates the named pipe. Pipe instances vanish with their process, // so there is no stale-endpoint sweep here; a creation failure with a live // listener behind it maps to ErrAlreadyRunning. diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 4e440b0..3df53c6 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -1,8 +1,16 @@ // Package ipc is the transport between the per-hook client process // (`mybinary agenthooks client`) and the long-running hook server // (`mybinary agenthooks server`): endpoint derivation from the consumer -// identity, length-prefixed JSON framing, and the request/response types. -// Unix domain sockets everywhere except Windows, which uses named pipes. +// identity and location, length-prefixed JSON framing, and the +// request/response types. Unix domain sockets everywhere except Windows, +// which uses named pipes. +// +// The rendezvous is resolved one of two ways. Resolve derives it from the +// consumer identity — executable path, pre-sentinel flags, and the +// client's normalized working directory — so each deployment gets one +// server per project location (the LSP per-workspace model). +// ResolveEndpoint takes an explicit endpoint (the --socket override) +// verbatim, for externally supervised or machine-wide servers. // // The package is internal on purpose: the wire is an implementation detail // of the runner's client/server modes, versioned by ProtocolVersion, not a @@ -18,6 +26,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strconv" "time" ) @@ -116,22 +125,48 @@ func ReadFrame(r io.Reader, v any) error { return nil } -// Identity fingerprints one consumer deployment: the executable path plus -// the consumer flags that precede the "agenthooks" sentinel in the hook -// command (e.g. --config=/path/speakeasy.json). Distinct binaries or -// distinct configs get distinct identities — and therefore distinct -// servers; the per-hook flags after the sentinel (--provider, --timeout) -// deliberately do not participate. -func Identity(exe string, preArgs []string) string { +// Identity fingerprints one consumer deployment at one location: the +// executable path, the consumer flags that precede the "agenthooks" +// sentinel in the hook command (e.g. --config=/path/speakeasy.json), and +// the client's normalized working directory (see Location). Distinct +// binaries, distinct configs, or distinct project directories get distinct +// identities — and therefore distinct servers, the LSP per-workspace +// model; the per-hook flags after the sentinel (--provider, --timeout) +// deliberately do not participate. An empty location contributes nothing, +// so invocations without a usable working directory fall back to the plain +// exe+preArgs identity — byte-identical to the pre-location derivation. +func Identity(exe string, preArgs []string, location string) string { h := sha256.New() h.Write([]byte(exe)) for _, a := range preArgs { h.Write([]byte{0}) h.Write([]byte(a)) } + if location != "" { + // A distinct separator keeps the location from colliding with a + // trailing pre-sentinel flag of the same spelling. + h.Write([]byte{1}) + h.Write([]byte(location)) + } return hex.EncodeToString(h.Sum(nil))[:16] } +// Location normalizes a client working directory for identity derivation: +// filepath.Clean plus best-effort symlink resolution, so different +// spellings of the same project directory rendezvous on the same server. +// When symlinks cannot be resolved the cleaned path is used as is; an +// empty cwd stays empty (the location-less identity fallback). +func Location(cwd string) string { + if cwd == "" { + return "" + } + cleaned := filepath.Clean(cwd) + if resolved, err := filepath.EvalSymlinks(cleaned); err == nil { + return resolved + } + return cleaned +} + // BuildStamp fingerprints the executable file behind path — path, size, and // mtime — cheaply enough to compute per hook invocation. Replacing the // binary on disk changes the stamp, which is what triggers the server's @@ -165,17 +200,18 @@ type Address struct { SpawnLock string } -// Resolve derives the Address for a consumer identity and ensures the state -// directory exists (0700). -func Resolve(exe string, preArgs []string) (Address, error) { - id := Identity(exe, preArgs) - dir, err := stateDir() +// Resolve derives the Address for a consumer identity at a location (the +// client's normalized working directory — see Location; empty means +// location-less) and ensures the state directory exists (0700). The +// endpoint embeds only the identity hash, never the location path itself, +// so derived unix socket paths stay within sun_path limits no matter how +// deep the project directory is. +func Resolve(exe string, preArgs []string, location string) (Address, error) { + dir, err := ensureStateDir() if err != nil { - return Address{}, fmt.Errorf("ipc: resolving state dir: %w", err) - } - if err := os.MkdirAll(dir, 0o700); err != nil { - return Address{}, fmt.Errorf("ipc: creating state dir: %w", err) + return Address{}, err } + id := Identity(exe, preArgs, location) return Address{ ID: id, Endpoint: endpoint(dir, id), @@ -184,6 +220,46 @@ func Resolve(exe string, preArgs []string) (Address, error) { }, nil } +// ResolveEndpoint is the --socket override: the Address for an explicitly +// chosen endpoint (a unix socket path; a named-pipe name on Windows), +// used verbatim. Identity derivation is bypassed entirely; the lock files +// derive from a hash of the endpoint in the state dir, so a client and a +// server handed the same --socket agree on the whole rendezvous without +// consulting their own process state. Unix endpoints beyond the sun_path +// budget are rejected here, before any bind or dial. +func ResolveEndpoint(explicit string) (Address, error) { + if err := validateEndpoint(explicit); err != nil { + return Address{}, err + } + dir, err := ensureStateDir() + if err != nil { + return Address{}, err + } + // Domain-separated from Identity so an explicit endpoint can never + // collide with a derived identity's lock files. + sum := sha256.Sum256([]byte("endpoint\x00" + explicit)) + id := hex.EncodeToString(sum[:])[:16] + return Address{ + ID: id, + Endpoint: explicit, + ServerLock: lockPath(dir, id, ".lock"), + SpawnLock: lockPath(dir, id, ".spawn"), + }, nil +} + +// ensureStateDir resolves the state directory for sockets and locks and +// creates it (0700) if needed. +func ensureStateDir() (string, error) { + dir, err := stateDir() + if err != nil { + return "", fmt.Errorf("ipc: resolving state dir: %w", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("ipc: creating state dir: %w", err) + } + return dir, nil +} + // dialProbe reports whether something answers on the endpoint right now. func dialProbe(endpoint string) bool { conn, err := Dial(endpoint, 250*time.Millisecond) diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go index afda999..3523107 100644 --- a/internal/ipc/ipc_test.go +++ b/internal/ipc/ipc_test.go @@ -2,7 +2,9 @@ package ipc import ( "bytes" + "crypto/sha256" "encoding/binary" + "encoding/hex" "errors" "os" "path/filepath" @@ -85,23 +87,72 @@ func TestReadFrameTruncatedBody(t *testing.T) { } func TestIdentity(t *testing.T) { - base := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}) + base := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}, "/work/proj") if len(base) != 16 { t.Fatalf("identity length = %d, want 16", len(base)) } - if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}); got != base { + if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}, "/work/proj"); got != base { t.Errorf("identity must be deterministic: %s vs %s", got, base) } - if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/b.json"}); got == base { + if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/b.json"}, "/work/proj"); got == base { t.Errorf("distinct configs must get distinct identities") } - if got := Identity("/other/binary", []string{"--config=/a.json"}); got == base { + if got := Identity("/other/binary", []string{"--config=/a.json"}, "/work/proj"); got == base { t.Errorf("distinct binaries must get distinct identities") } + if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}, "/work/other"); got == base { + t.Errorf("distinct locations must get distinct identities") + } + if got := Identity("/usr/local/bin/speakeasy-hooks", []string{"--config=/a.json"}, ""); got == base { + t.Errorf("the location-less identity must differ from a located one") + } // The separator must keep the encoding injective across arg boundaries. - if Identity("/bin/x", []string{"ab", "c"}) == Identity("/bin/x", []string{"a", "bc"}) { + if Identity("/bin/x", []string{"ab", "c"}, "") == Identity("/bin/x", []string{"a", "bc"}, "") { t.Errorf("arg boundaries must participate in the identity") } + // ... and across the args/location boundary. + if Identity("/bin/x", []string{"a"}, "b") == Identity("/bin/x", []string{"a", "b"}, "") { + t.Errorf("a location must not collide with a trailing pre-sentinel flag") + } +} + +func TestIdentityLocationlessFallbackIsStable(t *testing.T) { + // An empty location must reproduce the historic exe+preArgs bytes, so + // deployments without a usable working directory do not re-key their + // rendezvous across library upgrades. + h := sha256.New() + h.Write([]byte("/usr/local/bin/myhooks")) + h.Write([]byte{0}) + h.Write([]byte("--config=/a.json")) + want := hex.EncodeToString(h.Sum(nil))[:16] + if got := Identity("/usr/local/bin/myhooks", []string{"--config=/a.json"}, ""); got != want { + t.Errorf("location-less identity = %s, want the plain exe+preArgs hash %s", got, want) + } +} + +func TestLocation(t *testing.T) { + if got := Location(""); got != "" { + t.Errorf("empty cwd must stay empty, got %q", got) + } + // Nonexistent paths cannot resolve symlinks; cleaning still applies. + sep := string(filepath.Separator) + messy := filepath.Join(sep+"nonexistent-agenthooks-test", "proj") + sep + "." + sep + "sub" + sep + ".." + if got, want := Location(messy), filepath.Join(sep+"nonexistent-agenthooks-test", "proj"); got != want { + t.Errorf("Location(%q) = %q, want the cleaned path %q", messy, got, want) + } + // Symlinked and physical spellings of one directory must agree. + dir := t.TempDir() + physical := filepath.Join(dir, "real") + if err := os.Mkdir(physical, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(physical, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) // e.g. unprivileged windows + } + if Location(link) != Location(physical) { + t.Errorf("Location must resolve symlinks: %q vs %q", Location(link), Location(physical)) + } } func TestBuildStamp(t *testing.T) { @@ -133,7 +184,7 @@ func TestResolveDerivesEndpointAndLocks(t *testing.T) { if runtime.GOOS != "windows" { t.Setenv("XDG_STATE_HOME", t.TempDir()) } - addr, err := Resolve("/usr/local/bin/myhooks", []string{"--config=/a.json"}) + addr, err := Resolve("/usr/local/bin/myhooks", []string{"--config=/a.json"}, "/work/proj") if err != nil { t.Fatalf("Resolve: %v", err) } @@ -159,13 +210,76 @@ func TestResolveDerivesEndpointAndLocks(t *testing.T) { t.Errorf("server and spawn locks must differ: %+v", addr) } - other, err := Resolve("/usr/local/bin/myhooks", []string{"--config=/b.json"}) + other, err := Resolve("/usr/local/bin/myhooks", []string{"--config=/b.json"}, "/work/proj") if err != nil { t.Fatal(err) } if other.Endpoint == addr.Endpoint { t.Errorf("distinct configs must rendezvous on distinct endpoints") } + elsewhere, err := Resolve("/usr/local/bin/myhooks", []string{"--config=/a.json"}, "/work/other") + if err != nil { + t.Fatal(err) + } + if elsewhere.Endpoint == addr.Endpoint { + t.Errorf("distinct locations must rendezvous on distinct endpoints") + } +} + +func TestResolveEndpointOverride(t *testing.T) { + if runtime.GOOS != "windows" { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + } + explicit := `\\.\pipe\agenthooks-test-override` + if runtime.GOOS != "windows" { + // A short parent keeps the explicit path within the sun_path budget + // regardless of how deep the test's own TempDir nests. + dir, err := os.MkdirTemp("", "ahsock") //nolint:usetesting // t.TempDir paths can exceed the sun_path budget; this stays short + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + explicit = filepath.Join(dir, "s.sock") + } + + addr, err := ResolveEndpoint(explicit) + if err != nil { + t.Fatalf("ResolveEndpoint: %v", err) + } + if addr.Endpoint != explicit { + t.Errorf("explicit endpoint must be used verbatim: %q, want %q", addr.Endpoint, explicit) + } + if addr.ServerLock == "" || addr.SpawnLock == "" || addr.ServerLock == addr.SpawnLock { + t.Errorf("locks must derive from the endpoint and differ: %+v", addr) + } + again, err := ResolveEndpoint(explicit) + if err != nil { + t.Fatal(err) + } + if again != addr { + t.Errorf("resolution must be deterministic so client and server agree: %+v vs %+v", again, addr) + } + + // The explicit rendezvous must not collide with any derived one for + // the same string (domain separation of the lock hash). + derived, err := Resolve(explicit, nil, "") + if err != nil { + t.Fatal(err) + } + if derived.ServerLock == addr.ServerLock { + t.Errorf("explicit and derived rendezvous must not share lock files") + } +} + +func TestResolveEndpointRejectsOverlongUnixPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("named pipes have no path-length constraint") + } + long := "/" + strings.Repeat("x", maxSocketPath) + "/server.sock" + _, err := ResolveEndpoint(long) + if err == nil || !strings.Contains(err.Error(), "at most") { + t.Errorf("overlong socket path must be rejected with a clear error, got %v", err) + } } func TestSocketPathLengthFallsBackToTempDir(t *testing.T) { @@ -173,7 +287,7 @@ func TestSocketPathLengthFallsBackToTempDir(t *testing.T) { t.Skip("named pipes have no path-length constraint") } t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), strings.Repeat("deep", 30))) - addr, err := Resolve("/usr/local/bin/myhooks", nil) + addr, err := Resolve("/usr/local/bin/myhooks", nil, "") if err != nil { t.Fatal(err) } @@ -280,7 +394,7 @@ func testAddress(t *testing.T) Address { if runtime.GOOS != "windows" { t.Setenv("XDG_STATE_HOME", t.TempDir()) } - addr, err := Resolve("/test/bin/agenthooks", []string{"--test-id=" + t.Name(), "--nonce=" + t.TempDir()}) + addr, err := Resolve("/test/bin/agenthooks", []string{"--test-id=" + t.Name(), "--nonce=" + t.TempDir()}, "") if err != nil { t.Fatal(err) } diff --git a/server.go b/server.go index d03afc0..38bf47d 100644 --- a/server.go +++ b/server.go @@ -17,14 +17,20 @@ import ( "github.com/speakeasy-api/agenthooks/internal/ipc" ) -// The `agenthooks server` mode: a long-running singleton per consumer -// identity (executable + pre-sentinel flags, see internal/ipc) that hosts -// the full pipeline — handlers, caches, warm HTTP connections, and the -// in-process telemetry recorder — across hook invocations. Each connection -// carries exactly one framed request (a hook event with its argv, payload, -// and environment snapshot) and receives one framed response (the -// provider-dialect stdout/stderr/exit code), which the paired -// `agenthooks client` relays to the provider. +// The `agenthooks server` mode: a long-running singleton per rendezvous +// that hosts the full pipeline — handlers, caches, warm HTTP connections, +// and the in-process telemetry recorder — across hook invocations. Each +// connection carries exactly one framed request (a hook event with its +// argv, payload, and environment snapshot) and receives one framed +// response (the provider-dialect stdout/stderr/exit code), which the +// paired `agenthooks client` relays to the provider. +// +// The rendezvous comes from `--socket=` when given — clients +// always pass it when they spawn, and external supervisors should always +// pass it too, rather than relying on the server's own working directory. +// Without the flag the server derives it the same way a client does: +// executable, pre-sentinel flags, and its own normalized cwd (one server +// per project location, see internal/ipc). // // The server is spawned on demand by the first client that finds no // listener, stays up while hooks keep arriving, and shuts itself down — @@ -60,8 +66,11 @@ func (r *Runner) serverMain(ctx context.Context, inv *invocation, stderr io.Writ _, _ = fmt.Fprintf(stderr, "agenthooks: server: resolving executable: %v\n", err) return 1 } - addr, err := ipc.Resolve(exe, inv.preArgs) + addr, err := resolveAddress(exe, inv) if err != nil { + // Covers a rejected --socket value (e.g. over the unix sun_path + // budget) as well as state-dir failures: a hard, loud setup error — + // the client side of the same mistake fails open instead. _, _ = fmt.Fprintf(stderr, "agenthooks: server: %v\n", err) return 1 } From 8b1a00aaffaa14dfda6d4b2e7981a80c0a881265 Mon Sep 17 00:00:00 2001 From: Subomi Oluwalana Date: Thu, 6 Aug 2026 16:52:21 -0700 Subject: [PATCH 9/9] docs: describe the per-location rendezvous and --socket override DESIGN.md's client/server section and the telemetry RFC's server lifecycle now cover the location-scoped identity, the spawn handoff (--socket passed to spawned servers), the supervisor guidance to always pin the rendezvous explicitly, and the varying-cwd worst case (multiple servers with split cache warmth; correctness holds because cross-invocation state is file-based and shared). Co-authored-by: Cursor --- DESIGN.md | 38 +++++++++++++++++++++++++++++++------- README.md | 5 +++-- RFC-telemetry.md | 37 ++++++++++++++++++++++++------------- 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index c3019aa..5cd09b6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -518,7 +518,7 @@ control: ``` mybinary agenthooks client --provider=claude-code # per-hook process: forwards to the hook server, relays the decision (default install) -mybinary agenthooks server [--idle-timeout=10m] # long-running singleton hosting the pipeline (auto-spawned by client) +mybinary agenthooks server [--idle-timeout=10m] [--socket=P] # long-running singleton hosting the pipeline (auto-spawned by client) mybinary agenthooks run --provider=claude-code # process-per-event, stdin JSON — the direct single-process mode mybinary agenthooks run --provider=cursor --argv-payload # legacy cursor-agent CLI (<2026-05-20): payload in argv mybinary agenthooks notify --provider=codex # legacy codex notify: kebab-case JSON in argv[1] @@ -539,12 +539,36 @@ Runtime responsibilities per mode: hosts the Runner across invocations: handlers, MCP/inventory caches, warm HTTP connections, and the opt-in telemetry recorder. The rendezvous is derived from the consumer identity (executable path + - the flags before the `agenthooks` sentinel, e.g. `--config=...`), so - distinct deployments get distinct servers (`internal/ipc`). No server? - The client re-execs itself as one (detached, spawn-lock serialized) - and retries for ~2 s; any failure past that fails open — a warning on - the debug log, exit 0 with no output, which providers read as "no - opinion". The server is a hard dependency of client mode: the client + the flags before the `agenthooks` sentinel, e.g. `--config=...`) plus + the client's normalized working directory, so distinct deployments — + and distinct project locations within one deployment — get distinct + servers, the LSP per-workspace model (`internal/ipc`; a missing cwd + degrades to the location-less exe+flags identity). Socket names embed + only the identity hash, never the location path, so they stay within + sun_path limits however deep the project nests. `--socket=` + on either mode bypasses the derivation entirely and uses the endpoint + verbatim (locks derive from a hash of it) — for external supervision + (e.g. a daemon running `mybinary --config=... agenthooks server + --socket=...`), tests, containers, or a deliberate machine-wide + server; supervisors should always pass `--socket` explicitly rather + than relying on the server's own cwd. An overlong unix socket path is + rejected loudly at server startup and fails open on the client. No + server? The client re-execs itself as one (detached, spawn-lock + serialized), handing over the endpoint it just resolved via + `--socket` — client and spawned server always agree on the rendezvous + without the server re-deriving it from its own, possibly different, + process state — and retries for ~2 s; any failure past that fails + open — a warning on the debug log, exit 0 with no output, which + providers read as "no opinion". Providers launch hook processes from + the session's project directory (Claude Code documents hooks running + in the session cwd with `CLAUDE_PROJECT_DIR` exported; Cursor uses + the workspace root; Gemini's hook cwd matches `GEMINI_CWD`), so the + location is stable within a session; if a provider ever fires hooks + from varying cwds mid-session, the worst case is multiple servers + with split cache warmth — correctness holds, because cross-invocation + state (dedup markers, backfill markers, MCP inventories) is + file-based and shared across servers. The server is a hard + dependency of client mode: the client never runs the pipeline itself, and `run` remains the supported direct single-process mode. The server early-acks non-gating events right after decode (processing continues async), shuts down after an idle period or on diff --git a/README.md b/README.md index ecf502c..254094f 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,9 @@ err := install.Install(ctx, m, install.Target{ Generated configs bake in the argv contract (`mybinary agenthooks client --provider=...` — a lightweight per-hook client that forwards each event to -an auto-spawned, long-running `agenthooks server` singleton and relays its -decision; when the server cannot be reached or spawned the client fails +an auto-spawned, long-running `agenthooks server` singleton (one per +project directory) and relays its decision; when the server cannot be +reached or spawned the client fails open — exit 0, no output — rather than running the pipeline itself, with `run` remaining the direct single-process mode), per-provider timeout units, async workarounds (sync `Stop` on diff --git a/RFC-telemetry.md b/RFC-telemetry.md index 7f1dad6..08b42d5 100644 --- a/RFC-telemetry.md +++ b/RFC-telemetry.md @@ -637,17 +637,24 @@ Its lifecycle is owned by hook traffic, with telemetry riding along: - **Placement.** Generated configs install `mybinary [flags] agenthooks client --provider=X` as the hook command. - The client forwards each event over a per-consumer-identity unix socket - (Windows: named pipe) to `mybinary [flags] agenthooks server`; when no - server answers, the client re-execs itself as one (detached, spawn-lock - serialized) and retries. The server constructs the consumer's Runner — - including `WithTelemetry` — once, and hosts it across invocations: - handlers, caches, warm HTTP connections, and the recorder's batch - pipeline all live exactly as long as the server. -- **Singleton.** One server per consumer identity (executable + - pre-sentinel flags such as `--config=...`), enforced by the endpoint - bind plus a `internal/filelock` server lock. Distinct configs get - distinct servers, so telemetry credentials never cross deployments. + The client forwards each event over a per-consumer-identity, + per-project-location unix socket (Windows: named pipe) to + `mybinary [flags] agenthooks server`; when no server answers, the + client re-execs itself as one (detached, spawn-lock serialized, + handed the resolved endpoint via `--socket` so both always agree on + the rendezvous) and retries. The server constructs the consumer's + Runner — including `WithTelemetry` — once, and hosts it across + invocations: handlers, caches, warm HTTP connections, and the + recorder's batch pipeline all live exactly as long as the server. +- **Singleton.** One server per consumer identity and location + (executable + pre-sentinel flags such as `--config=...` + the + client's normalized working directory — one server per project, the + LSP per-workspace model; no usable cwd degrades to the location-less + identity), enforced by the endpoint bind plus a `internal/filelock` + server lock. Distinct configs get distinct servers, so telemetry + credentials never cross deployments; distinct project directories get + distinct servers, so cwd-dependent best-effort state (MCP config + discovery, inventories) is derived per project. - **Shutdown = flush.** The server shuts down — and calls `TelemetryRecorder.Shutdown`, which force-flushes the batch queue — on three triggers: @@ -673,8 +680,12 @@ Its lifecycle is owned by hook traffic, with telemetry riding along: - **No external supervision.** Nothing needs provisioning, boot registration, or a service manager: the server exists exactly when hook traffic exists. (Consumers running under external supervision anyway — - e.g. a device agent — can keep a server warm by invoking the verb - directly; that is an optimization, not a requirement.) + e.g. a device agent — can keep a server warm by running + `mybinary [flags] agenthooks server --socket=`; supervisors + should always pin the rendezvous with an explicit `--socket` rather + than relying on the supervised server's own working directory, and + point clients at it with the same flag. That is an optimization, not + a requirement.) **Encoding note:** `otlploghttp` emits OTLP **protobuf** (`application/x-protobuf`) — the Go OTLP/HTTP exporters do not offer a JSON