This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 3c80f89 feat(agents): watch where each agent's own processes send data
3c80f89 is described below
commit 3c80f8951bd6f3a76a7b0a6070ea1ac33a5c3749
Author: Yang Luo <[email protected]>
AuthorDate: Thu Sep 24 23:29:15 2026 +0800
feat(agents): watch where each agent's own processes send data
---
agent/fingerprints/zcode.json | 12 +
agent/scan.go | 8 +
agentegress/classify.go | 99 +++++++++
agentegress/egress.go | 475 ++++++++++++++++++++++++++++++++++++++++
agentegress/sample_other.go | 33 +++
agentegress/sample_windows.go | 266 ++++++++++++++++++++++
agentegress/zcode.go | 170 ++++++++++++++
controllers/agent.go | 5 +
controllers/agent_egress.go | 69 ++++++
main.go | 5 +
object/agent_record.go | 22 ++
routers/router.go | 1 +
web/src/backend/AgentBackend.ts | 5 +
web/src/types.ts | 58 +++++
14 files changed, 1228 insertions(+)
diff --git a/agent/fingerprints/zcode.json b/agent/fingerprints/zcode.json
new file mode 100644
index 0000000..0cccbf6
--- /dev/null
+++ b/agent/fingerprints/zcode.json
@@ -0,0 +1,12 @@
+{
+ "id": "zcode",
+ "displayName": "ZCode",
+ "installUrl": "https://zcode.z.ai",
+ "execName": "ZCode",
+ "desktop": true,
+ "linkScheme": "zcode",
+ "stateDir": "zcode",
+ "windowsProgramDirs": [
+ "ZCode"
+ ]
+}
diff --git a/agent/scan.go b/agent/scan.go
index 0529be5..2fad5e8 100644
--- a/agent/scan.go
+++ b/agent/scan.go
@@ -39,6 +39,14 @@ var installationCache = struct {
current *scanCall
}{}
+// Cached is the last scan's result without scanning, for a caller that polls
+// too often to pay for a scan of its own.
+func Cached() []Installation {
+ installationCache.Lock()
+ defer installationCache.Unlock()
+ return cloneInstallations(installationCache.result)
+}
+
// Scan returns installations visible to Gateway and caches successful results.
func Scan(forceRefresh bool) ([]Installation, error) {
installationCache.Lock()
diff --git a/agentegress/classify.go b/agentegress/classify.go
new file mode 100644
index 0000000..85695e9
--- /dev/null
+++ b/agentegress/classify.go
@@ -0,0 +1,99 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentegress
+
+import "strings"
+
+const (
+ KindModel = "model"
+ KindStorage = "storage"
+ KindTelemetry = "telemetry"
+ // KindLocal is another process on this host, usually a proxy, which
hides
+ // where the bytes go next.
+ KindLocal = "local"
+ KindOther = "other"
+)
+
+// Model APIs are where an agent is expected to send its context, so volume
+// there is not an upload.
+var modelSuffixes = []string{
+ "anthropic.com", "claude.ai", "openai.com", "chatgpt.com",
"bigmodel.cn", "api.z.ai",
+ "deepseek.com", "moonshot.cn", "moonshot.ai", "kimi.com",
"dashscope.aliyuncs.com",
+ "generativelanguage.googleapis.com", "aiplatform.googleapis.com",
"x.ai", "mistral.ai",
+ "openrouter.ai", "groq.com", "together.xyz", "fireworks.ai",
"cohere.com", "cohere.ai",
+ "minimaxi.com", "minimax.io", "siliconflow.cn", "volces.com",
"hunyuan.cloud.tencent.com",
+ "githubcopilot.com", "cursor.sh", "cursor.com", "codeium.com",
"windsurf.com",
+ "openai.azure.com", "bedrock-runtime.amazonaws.com",
"qianfan.baidubce.com",
+}
+
+func classify(host string) string {
+ host = strings.ToLower(strings.TrimSuffix(host, "."))
+ if host == "" {
+ return KindOther
+ }
+ if isStorage(host) {
+ return KindStorage
+ }
+ if hasSuffix(host, modelSuffixes) {
+ return KindModel
+ }
+ if isTelemetry(host) {
+ return KindTelemetry
+ }
+ return KindOther
+}
+
+// isStorage recognises object storage upload endpoints, where a client posts a
+// file directly with a credential its vendor signed.
+func isStorage(host string) bool {
+ switch {
+ case strings.HasSuffix(host, ".aliyuncs.com"):
+ return strings.HasPrefix(host, "oss-") ||
strings.Contains(host, ".oss-") || strings.Contains(host, ".oss.")
+ case strings.HasSuffix(host, ".amazonaws.com"):
+ return strings.HasPrefix(host, "s3.") ||
strings.HasPrefix(host, "s3-") || strings.Contains(host, ".s3.") ||
strings.Contains(host, ".s3-")
+ case strings.HasSuffix(host, ".myqcloud.com"):
+ return strings.Contains(host, ".cos.")
+ case strings.HasSuffix(host, ".volces.com"), strings.HasSuffix(host,
".volccdn.com"):
+ return strings.HasPrefix(host, "tos-") ||
strings.Contains(host, ".tos-")
+ case strings.HasSuffix(host, ".myhuaweicloud.com"):
+ return strings.HasPrefix(host, "obs.") ||
strings.Contains(host, ".obs.")
+ }
+ return hasSuffix(host, []string{
+ "storage.googleapis.com", "blob.core.windows.net",
"r2.cloudflarestorage.com",
+ "qiniucs.com", "qbox.me", "bcebos.com", "ksyuncs.com",
"backblazeb2.com",
+ "digitaloceanspaces.com", "wasabisys.com",
+ })
+}
+
+func isTelemetry(host string) bool {
+ if strings.HasSuffix(host, ".log.aliyuncs.com") {
+ return true
+ }
+ return hasSuffix(host, []string{
+ "sentry.io", "mixpanel.com", "segment.io", "segment.com",
"amplitude.com",
+ "datadoghq.com", "datadoghq.eu", "posthog.com", "statsig.com",
"statsigapi.net",
+ "honeycomb.io", "googletagmanager.com", "google-analytics.com",
"bugsnag.com",
+ "newrelic.com", "nr-data.net", "growthbook.io",
"launchdarkly.com",
+ })
+}
+
+func hasSuffix(host string, suffixes []string) bool {
+ for _, suffix := range suffixes {
+ if host == suffix || strings.HasSuffix(host, "."+suffix) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/agentegress/egress.go b/agentegress/egress.go
new file mode 100644
index 0000000..93811de
--- /dev/null
+++ b/agentegress/egress.go
@@ -0,0 +1,475 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package agentegress watches where each agent's own processes send data. The
+// proxy only sees what an agent sends its model; an agent that packs up a
+// repository and posts it to its vendor's storage goes around it, and this is
+// where that shows.
+package agentegress
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/netip"
+ "os"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/agentmonitor"
+ "github.com/beego/beego"
+)
+
+const (
+ sampleInterval = 500 * time.Millisecond
+ pidInterval = 5 * time.Second
+ // A burst is this much sent to one destination within burstWindow.
+ burstBytes = 5 << 20
+ burstWindow = time.Minute
+ // An episode ends, and is recorded, once its destination has been quiet
+ // this long.
+ quietAfter = time.Minute
+ maxDestinations = 200
+
+ EventType = "egress"
+ ActionLargeUpload = "large-upload"
+ ActionObjectUpload = "object-storage"
+)
+
+// Destination is one place an agent's processes connected to since Gateway
+// started watching.
+type Destination struct {
+ // Host is the name the agent resolved, empty when the DNS cache had
none.
+ Host string `json:"host,omitempty"`
+ Address string `json:"address"`
+ Kind string `json:"kind"`
+ // Via names the local process a KindLocal connection was made to.
+ Via string `json:"via,omitempty"`
+ Connections int `json:"connections"`
+ BytesOut int64 `json:"bytesOut"`
+ BytesIn int64 `json:"bytesIn"`
+ FirstSeen time.Time `json:"firstSeen"`
+ LastSeen time.Time `json:"lastSeen"`
+ // Flagged means an episode is open against it right now.
+ Flagged bool `json:"flagged,omitempty"`
+}
+
+// Report is what the watcher saw of one agent.
+type Report struct {
+ Supported bool `json:"supported"`
+ // Counted means bytes are measured; without it only destinations are.
+ Counted bool `json:"counted"`
+ Since time.Time `json:"since"`
+ Destinations []Destination `json:"destinations"`
+}
+
+// Detail is the body of an egress record.
+type Detail struct {
+ Host string `json:"host,omitempty"`
+ Address string `json:"address"`
+ Kind string `json:"kind"`
+ Via string `json:"via,omitempty"`
+ BytesOut int64 `json:"bytesOut"`
+ BytesIn int64 `json:"bytesIn"`
+ Connections int `json:"connections"`
+ Counted bool `json:"counted"`
+ Start time.Time `json:"start"`
+ End time.Time `json:"end"`
+}
+
+// socket is one established TCP connection of an agent process. row is the
+// platform's own record of it, which its byte counters are read through.
+type socket struct {
+ pid int
+ local, remote netip.AddrPort
+ row any
+}
+
+func (s socket) key() string {
+ return fmt.Sprintf("%d|%s|%s", s.pid, s.local, s.remote)
+}
+
+type sample struct {
+ at time.Time
+ bytes int64
+}
+
+type episode struct {
+ start, last time.Time
+ bytesOut int64
+ bytesIn int64
+ conns int
+}
+
+type destination struct {
+ Destination
+ window []sample
+ episode *episode
+}
+
+type conn struct {
+ agentId string
+ dest *destination
+ sock socket
+ counting bool
+ out, in uint64
+ seen time.Time
+}
+
+type watcher struct {
+ sync.Mutex
+ since time.Time
+ counted bool
+ agents map[string]map[string]*destination
+ conns map[string]*conn
+ pids map[int]string
+ names map[netip.Addr]string
+ missed map[netip.Addr]time.Time
+ lookedUp time.Time
+}
+
+var state = &watcher{
+ agents: map[string]map[string]*destination{},
+ conns: map[string]*conn{},
+ pids: map[int]string{},
+ names: map[netip.Addr]string{},
+ missed: map[netip.Addr]time.Time{},
+}
+
+var stop context.CancelFunc
+
+// Start watches until Stop. agentPids maps each live process of an agent to
+// its agent id; it is read every few seconds.
+func Start(agentPids func() map[int]string) {
+ if !supported || stop != nil {
+ return
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ stop = cancel
+ state.Lock()
+ state.since = time.Now()
+ state.counted = elevated()
+ state.Unlock()
+
+ go func() {
+ ticker := time.NewTicker(sampleInterval)
+ defer ticker.Stop()
+ var pidsAt time.Time
+ for {
+ select {
+ case <-ctx.Done():
+ state.Lock()
+ state.flush(time.Now(), true)
+ state.Unlock()
+ return
+ case now := <-ticker.C:
+ if now.Sub(pidsAt) >= pidInterval {
+ pidsAt = now
+ tick(func() {
+ pids := agentPids()
+ state.Lock()
+ state.pids = pids
+ state.Unlock()
+ })
+ }
+ tick(func() { state.sample(now) })
+ }
+ }
+ }()
+}
+
+// tick runs one step of the watch, which must not take Gateway down with it.
+func tick(step func()) {
+ defer func() {
+ if err := recover(); err != nil {
+ beego.Error("agent egress watch:", err)
+ }
+ }()
+ step()
+}
+
+// Stop ends the watch and records every episode still open.
+func Stop() {
+ if stop != nil {
+ stop()
+ stop = nil
+ }
+}
+
+// ReportOf is what the watcher has seen of one agent.
+func ReportOf(agentId string) Report {
+ state.Lock()
+ defer state.Unlock()
+
+ report := Report{Supported: supported, Counted: state.counted, Since:
state.since, Destinations: []Destination{}}
+ for _, dest := range state.agents[agentId] {
+ item := dest.Destination
+ item.Flagged = dest.episode != nil
+ report.Destinations = append(report.Destinations, item)
+ }
+ sort.Slice(report.Destinations, func(i, j int) bool {
+ a, b := report.Destinations[i], report.Destinations[j]
+ if a.BytesOut != b.BytesOut {
+ return a.BytesOut > b.BytesOut
+ }
+ return a.LastSeen.After(b.LastSeen)
+ })
+ return report
+}
+
+func (w *watcher) sample(now time.Time) {
+ w.Lock()
+ defer w.Unlock()
+ if len(w.pids) == 0 {
+ w.flush(now, false)
+ return
+ }
+
+ sockets, listeners := listSockets(w.pids)
+ w.resolve(sockets, now)
+ self := os.Getpid()
+ for i := range sockets {
+ sock := sockets[i]
+ agentId := w.pids[sock.pid]
+ key := sock.key()
+ c := w.conns[key]
+ if c == nil {
+ dest := w.destinationOf(agentId, sock, listeners, self)
+ if dest == nil {
+ continue
+ }
+ c = &conn{agentId: agentId, dest: dest, sock: sock}
+ c.counting = w.counted && startCounting(&c.sock)
+ w.conns[key] = c
+ dest.Connections++
+ if dest.FirstSeen.IsZero() {
+ dest.FirstSeen = now
+ }
+ if dest.Kind == KindStorage && dest.episode == nil {
+ dest.episode = &episode{start: now}
+ }
+ if dest.episode != nil {
+ dest.episode.conns++
+ dest.episode.last = now
+ }
+ }
+ c.seen = now
+ c.dest.LastSeen = now
+ if c.dest.Host == "" && c.dest.Kind != KindLocal {
+ if host := w.names[sock.remote.Addr()]; host != "" {
+ c.dest.Host, c.dest.Kind = host, classify(host)
+ }
+ }
+ if c.dest.episode != nil {
+ c.dest.episode.last = now
+ }
+ if !c.counting {
+ continue
+ }
+ out, in, ok := readCounts(&c.sock)
+ if !ok {
+ continue
+ }
+ deltaOut, deltaIn := int64(out-min(out, c.out)),
int64(in-min(in, c.in))
+ c.out, c.in = max(out, c.out), max(in, c.in)
+ w.add(c.dest, now, deltaOut, deltaIn)
+ }
+
+ for key, c := range w.conns {
+ if now.Sub(c.seen) > 2*sampleInterval {
+ delete(w.conns, key)
+ }
+ }
+ w.flush(now, false)
+}
+
+func (w *watcher) add(dest *destination, now time.Time, out, in int64) {
+ if out == 0 && in == 0 {
+ return
+ }
+ dest.BytesOut += out
+ dest.BytesIn += in
+
+ cut := 0
+ for cut < len(dest.window) && now.Sub(dest.window[cut].at) >
burstWindow {
+ cut++
+ }
+ dest.window = append(dest.window[cut:], sample{at: now, bytes: out})
+
+ if dest.episode == nil && dest.Kind != KindModel {
+ var sum int64
+ for _, s := range dest.window {
+ sum += s.bytes
+ }
+ if sum >= burstBytes {
+ dest.episode = &episode{start: dest.window[0].at,
bytesOut: sum - out, conns: 1}
+ }
+ }
+ if dest.episode != nil {
+ dest.episode.bytesOut += out
+ dest.episode.bytesIn += in
+ dest.episode.last = now
+ }
+}
+
+// flush records every episode whose destination has gone quiet, or all of them
+// when the watch is ending.
+func (w *watcher) flush(now time.Time, all bool) {
+ for agentId, dests := range w.agents {
+ for _, dest := range dests {
+ ep := dest.episode
+ if ep == nil || (!all && now.Sub(ep.last) < quietAfter)
{
+ continue
+ }
+ dest.episode = nil
+ w.record(agentId, dest, ep)
+ }
+ }
+}
+
+func (w *watcher) record(agentId string, dest *destination, ep *episode) {
+ action := ActionObjectUpload
+ if ep.bytesOut >= burstBytes {
+ action = ActionLargeUpload
+ }
+ detail := Detail{
+ Host: dest.Host, Address: dest.Address, Kind: dest.Kind, Via:
dest.Via,
+ BytesOut: ep.bytesOut, BytesIn: ep.bytesIn, Connections:
ep.conns, Counted: w.counted,
+ Start: ep.start, End: ep.last,
+ }
+ body, _ := json.Marshal(detail)
+ object := dest.Host
+ if object == "" {
+ object = dest.Address
+ }
+ title := fmt.Sprintf("Connected to object storage at %s", object)
+ if action == ActionLargeUpload {
+ title = fmt.Sprintf("Sent %.1f MB to %s",
float64(ep.bytesOut)/(1<<20), object)
+ }
+ agentmonitor.AddRecord(&agentmonitor.Record{
+ CreatedTime: ep.last.Format(time.RFC3339Nano),
+ Agent: agentId,
+ EventType: EventType,
+ Action: action,
+ Object: object,
+ Title: title,
+ Detail: string(body),
+ DurationMs: ep.last.Sub(ep.start).Milliseconds(),
+ })
+}
+
+func (w *watcher) destinationOf(agentId string, sock socket, listeners
map[uint16]int, self int) *destination {
+ var host, kind, via, key string
+ remote := sock.remote.Addr()
+ if remote.IsLoopback() {
+ owner, ok := listeners[sock.remote.Port()]
+ if !ok || owner == self {
+ return nil
+ }
+ // A process of any agent: its own helpers talking among
themselves.
+ if _, internal := w.pids[owner]; internal {
+ return nil
+ }
+ kind, via = KindLocal, processName(owner)
+ key = fmt.Sprintf("local|%d", sock.remote.Port())
+ } else {
+ host = w.names[remote]
+ kind = classify(host)
+ key = host
+ if key == "" {
+ key = remote.String()
+ }
+ }
+
+ dests := w.agents[agentId]
+ if dests == nil {
+ dests = map[string]*destination{}
+ w.agents[agentId] = dests
+ }
+ dest := dests[key]
+ // A destination first seen before its name was known moves under the
name.
+ if byAddr := dests[remote.String()]; dest == nil && host != "" &&
byAddr != nil {
+ delete(dests, remote.String())
+ byAddr.Host, byAddr.Kind = host, kind
+ dests[key], dest = byAddr, byAddr
+ }
+ if dest == nil {
+ if len(dests) >= maxDestinations {
+ evictOldest(dests)
+ }
+ dest = &destination{Destination: Destination{Host: host, Kind:
kind, Via: via}}
+ dests[key] = dest
+ }
+ dest.Address = sock.remote.String()
+ return dest
+}
+
+func evictOldest(dests map[string]*destination) {
+ oldestKey := ""
+ var oldest time.Time
+ for key, dest := range dests {
+ if dest.episode != nil {
+ continue
+ }
+ if oldestKey == "" || dest.LastSeen.Before(oldest) {
+ oldestKey, oldest = key, dest.LastSeen
+ }
+ }
+ if oldestKey != "" {
+ delete(dests, oldestKey)
+ }
+}
+
+// resolve names the remote addresses the DNS cache knows, going back to the
+// cache for a new address at most once a second and giving up on one after a
+// few tries: an agent that connects by address has no name to find.
+func (w *watcher) resolve(sockets []socket, now time.Time) {
+ var missing []netip.Addr
+ for _, sock := range sockets {
+ addr := sock.remote.Addr()
+ if addr.IsLoopback() {
+ continue
+ }
+ if _, ok := w.names[addr]; ok {
+ continue
+ }
+ // A new connection starts the retries over: its name was just
looked up.
+ if _, known := w.conns[sock.key()]; !known {
+ w.missed[addr] = now
+ } else if first, ok := w.missed[addr]; ok && now.Sub(first) >
10*time.Second {
+ continue
+ }
+ missing = append(missing, addr)
+ }
+ if len(missing) == 0 || now.Sub(w.lookedUp) < time.Second {
+ return
+ }
+ w.lookedUp = now
+ found := cachedNames()
+ for addr, name := range found {
+ w.names[addr] = name
+ }
+ for _, addr := range missing {
+ if _, ok := found[addr]; ok {
+ delete(w.missed, addr)
+ }
+ }
+ if len(w.names) > 4096 {
+ w.names = found
+ }
+ if len(w.missed) > 4096 {
+ w.missed = map[netip.Addr]time.Time{}
+ }
+}
diff --git a/agentegress/sample_other.go b/agentegress/sample_other.go
new file mode 100644
index 0000000..da2d21d
--- /dev/null
+++ b/agentegress/sample_other.go
@@ -0,0 +1,33 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+
+package agentegress
+
+import "net/netip"
+
+const supported = false
+
+func listSockets(map[int]string) ([]socket, map[uint16]int) { return nil, nil }
+
+func startCounting(*socket) bool { return false }
+
+func readCounts(*socket) (uint64, uint64, bool) { return 0, 0, false }
+
+func elevated() bool { return false }
+
+func cachedNames() map[netip.Addr]string { return nil }
+
+func processName(int) string { return "" }
diff --git a/agentegress/sample_windows.go b/agentegress/sample_windows.go
new file mode 100644
index 0000000..fbdaa47
--- /dev/null
+++ b/agentegress/sample_windows.go
@@ -0,0 +1,266 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package agentegress
+
+import (
+ "net/netip"
+ "path/filepath"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+const supported = true
+
+var (
+ iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
+ procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable")
+ procSetPerTcpConnEStats = iphlpapi.NewProc("SetPerTcpConnectionEStats")
+ procGetPerTcpConnEStats = iphlpapi.NewProc("GetPerTcpConnectionEStats")
+ procSetPerTcp6ConnEStats =
iphlpapi.NewProc("SetPerTcp6ConnectionEStats")
+ procGetPerTcp6ConnEStats =
iphlpapi.NewProc("GetPerTcp6ConnectionEStats")
+ dnsapi = windows.NewLazySystemDLL("dnsapi.dll")
+ procDnsGetCacheDataTable = dnsapi.NewProc("DnsGetCacheDataTable")
+ procDnsFree = dnsapi.NewProc("DnsFree")
+)
+
+const (
+ afInet = 2
+ afInet6 = 23
+ tcpTableOwnerPidAll = 5
+ stateListen = 2
+ stateEstablished = 5
+ estatsData = 1
+ dnsTypeA = 1
+ dnsTypeCname = 5
+ dnsTypeAAAA = 28
+ dnsQueryNoWire = 0x10
+)
+
+// MIB_TCPROW and MIB_TCP6ROW: what the EStats calls take.
+type tcpRow struct{ State, LocalAddr, LocalPort, RemoteAddr, RemotePort uint32
}
+
+type tcp6Row struct {
+ State uint32
+ LocalAddr [16]byte
+ LocalScopeId uint32
+ LocalPort uint32
+ RemoteAddr [16]byte
+ RemoteScopeId uint32
+ RemotePort uint32
+}
+
+type tcpRowOwnerPid struct {
+ tcpRow
+ Pid uint32
+}
+
+type tcp6RowOwnerPid struct {
+ LocalAddr [16]byte
+ LocalScopeId uint32
+ LocalPort uint32
+ RemoteAddr [16]byte
+ RemoteScopeId uint32
+ RemotePort uint32
+ State uint32
+ Pid uint32
+}
+
+// TCP_ESTATS_DATA_ROD_v0; the call rejects any other size.
+type estatsDataRod struct {
+ DataBytesOut, DataSegsOut, DataBytesIn, DataSegsIn, SegsOut, SegsIn
uint64
+ Rest
[48]byte
+}
+
+type dnsCacheEntry struct {
+ Next *dnsCacheEntry
+ Name *uint16
+ Type uint16
+ Length uint16
+ Flags uint32
+}
+
+func port(raw uint32) uint16 { return uint16(raw>>8&0xff | raw&0xff<<8) }
+
+func tcpTable(family uintptr) []byte {
+ var size uint32
+ procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0,
family, tcpTableOwnerPidAll, 0)
+ for i := 0; i < 4; i++ {
+ size += 8192
+ buf := make([]byte, size)
+ r, _, _ :=
procGetExtendedTcpTable.Call(uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&size)), 0, family, tcpTableOwnerPidAll, 0)
+ if r == 0 {
+ return buf
+ }
+ }
+ return nil
+}
+
+// listSockets returns the established connections of the given processes, and
+// which process listens on each port, so a loopback peer can be named.
+func listSockets(pids map[int]string) ([]socket, map[uint16]int) {
+ var sockets []socket
+ listeners := map[uint16]int{}
+
+ if buf := tcpTable(afInet); buf != nil {
+ count := *(*uint32)(unsafe.Pointer(&buf[0]))
+ for _, row := range
unsafe.Slice((*tcpRowOwnerPid)(unsafe.Pointer(&buf[4])), count) {
+ if row.State == stateListen {
+ listeners[port(row.LocalPort)] = int(row.Pid)
+ continue
+ }
+ if _, ok := pids[int(row.Pid)]; !ok || row.State !=
stateEstablished {
+ continue
+ }
+ sockets = append(sockets, socket{
+ pid: int(row.Pid),
+ local: netip.AddrPortFrom(ipv4(row.LocalAddr),
port(row.LocalPort)),
+ remote:
netip.AddrPortFrom(ipv4(row.RemoteAddr), port(row.RemotePort)),
+ row: row.tcpRow,
+ })
+ }
+ }
+
+ if buf := tcpTable(afInet6); buf != nil {
+ count := *(*uint32)(unsafe.Pointer(&buf[0]))
+ for _, row := range
unsafe.Slice((*tcp6RowOwnerPid)(unsafe.Pointer(&buf[4])), count) {
+ if row.State == stateListen {
+ if _, taken := listeners[port(row.LocalPort)];
!taken {
+ listeners[port(row.LocalPort)] =
int(row.Pid)
+ }
+ continue
+ }
+ if _, ok := pids[int(row.Pid)]; !ok || row.State !=
stateEstablished {
+ continue
+ }
+ sockets = append(sockets, socket{
+ pid: int(row.Pid),
+ local:
netip.AddrPortFrom(netip.AddrFrom16(row.LocalAddr).Unmap(),
port(row.LocalPort)),
+ remote:
netip.AddrPortFrom(netip.AddrFrom16(row.RemoteAddr).Unmap(),
port(row.RemotePort)),
+ row: tcp6Row{
+ State: row.State, LocalAddr:
row.LocalAddr, LocalScopeId: row.LocalScopeId, LocalPort: row.LocalPort,
+ RemoteAddr: row.RemoteAddr,
RemoteScopeId: row.RemoteScopeId, RemotePort: row.RemotePort,
+ },
+ })
+ }
+ }
+ return sockets, listeners
+}
+
+func ipv4(raw uint32) netip.Addr {
+ return netip.AddrFrom4([4]byte{byte(raw), byte(raw >> 8), byte(raw >>
16), byte(raw >> 24)})
+}
+
+// startCounting turns on the connection's byte counters, which only an
+// administrator may do. They count from here on.
+func startCounting(sock *socket) bool {
+ enable := [1]byte{1}
+ var r uintptr
+ switch row := sock.row.(type) {
+ case tcpRow:
+ r, _, _ =
procSetPerTcpConnEStats.Call(uintptr(unsafe.Pointer(&row)), estatsData,
uintptr(unsafe.Pointer(&enable[0])), 0, 1, 0)
+ case tcp6Row:
+ r, _, _ =
procSetPerTcp6ConnEStats.Call(uintptr(unsafe.Pointer(&row)), estatsData,
uintptr(unsafe.Pointer(&enable[0])), 0, 1, 0)
+ default:
+ return false
+ }
+ return r == 0
+}
+
+func readCounts(sock *socket) (uint64, uint64, bool) {
+ var rod estatsDataRod
+ var r uintptr
+ switch row := sock.row.(type) {
+ case tcpRow:
+ r, _, _ =
procGetPerTcpConnEStats.Call(uintptr(unsafe.Pointer(&row)), estatsData, 0, 0,
0, 0, 0, 0, uintptr(unsafe.Pointer(&rod)), 0, unsafe.Sizeof(rod))
+ case tcp6Row:
+ r, _, _ =
procGetPerTcp6ConnEStats.Call(uintptr(unsafe.Pointer(&row)), estatsData, 0, 0,
0, 0, 0, 0, uintptr(unsafe.Pointer(&rod)), 0, unsafe.Sizeof(rod))
+ default:
+ return 0, 0, false
+ }
+ return rod.DataBytesOut, rod.DataBytesIn, r == 0
+}
+
+func elevated() bool {
+ return windows.GetCurrentProcessToken().IsElevated()
+}
+
+// cachedNames maps each address in the resolver cache to the name a program
+// asked for. An alias beats the name it points at: bucket.oss-cn-beijing.
+// aliyuncs.com says more than the CDN node behind it.
+func cachedNames() map[netip.Addr]string {
+ result := map[netip.Addr]string{}
+ var head *dnsCacheEntry
+ if ok, _, _ :=
procDnsGetCacheDataTable.Call(uintptr(unsafe.Pointer(&head))); ok == 0 {
+ return result
+ }
+
+ alias := map[netip.Addr]bool{}
+ for entry := head; entry != nil; {
+ next := entry.Next
+ if entry.Type == dnsTypeA || entry.Type == dnsTypeAAAA {
+ name := windows.UTF16PtrToString(entry.Name)
+ addrs, viaCname := cachedAddrs(name, entry.Type)
+ for _, addr := range addrs {
+ if _, ok := result[addr]; !ok || (viaCname &&
!alias[addr]) {
+ result[addr] = name
+ alias[addr] = viaCname
+ }
+ }
+ }
+ procDnsFree.Call(uintptr(unsafe.Pointer(entry.Name)), 0)
+ procDnsFree.Call(uintptr(unsafe.Pointer(entry)), 0)
+ entry = next
+ }
+ return result
+}
+
+func cachedAddrs(name string, kind uint16) ([]netip.Addr, bool) {
+ var records *windows.DNSRecord
+ if windows.DnsQuery(name, kind, dnsQueryNoWire, nil, &records, nil) !=
nil {
+ return nil, false
+ }
+ defer windows.DnsRecordListFree(records, 1)
+
+ var addrs []netip.Addr
+ viaCname := false
+ for record := records; record != nil; record = record.Next {
+ switch record.Type {
+ case dnsTypeCname:
+ viaCname = true
+ case dnsTypeA:
+ addrs = append(addrs,
netip.AddrFrom4([4]byte(record.Data[:4])))
+ case dnsTypeAAAA:
+ addrs = append(addrs,
netip.AddrFrom16([16]byte(record.Data[:16])).Unmap())
+ }
+ }
+ return addrs, viaCname
+}
+
+func processName(pid int) string {
+ handle, err :=
windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false,
uint32(pid))
+ if err != nil {
+ return ""
+ }
+ defer windows.CloseHandle(handle)
+ buf := make([]uint16, windows.MAX_PATH)
+ size := uint32(len(buf))
+ if windows.QueryFullProcessImageName(handle, 0, &buf[0], &size) != nil {
+ return ""
+ }
+ return filepath.Base(windows.UTF16ToString(buf[:size]))
+}
diff --git a/agentegress/zcode.go b/agentegress/zcode.go
new file mode 100644
index 0000000..ea3a17c
--- /dev/null
+++ b/agentegress/zcode.go
@@ -0,0 +1,170 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentegress
+
+import (
+ "bytes"
+ "encoding/json"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Snapshot is one workspace an agent packed up whole, read back from what the
+// packing left on disk. It outlives the upload, which the watch only sees
while
+// it happens.
+type Snapshot struct {
+ Workspace string `json:"workspace"`
+ EncryptedBytes int64 `json:"encryptedBytes,omitempty"`
+ WorkspaceBytes int64 `json:"workspaceBytes,omitempty"`
+ RecordedAt time.Time `json:"recordedAt,omitempty"`
+ // Accepted means the vendor confirmed it received the package.
+ Accepted bool `json:"accepted"`
+ // Pending means a package is queued, mid-upload, or waiting on a retry.
+ Pending bool `json:"pending"`
+ Failures int `json:"failures,omitempty"`
+ // The rest is read from the plaintext manifest of the last package.
+ Files int `json:"files,omitempty"`
+ GitFiles int `json:"gitFiles,omitempty"`
+ GitBytes int64 `json:"gitBytes,omitempty"`
+}
+
+// ZCode's closed-source builds up to 3.12.x pack the worktree with its whole
+// .git before each prompt and post it to Aliyun OSS; v2/checkpoints keeps a
+// state.json and the manifests per workspace. ZCode's git checkpoints share
the
+// directory as <id>.json files, which carry none of these fields.
+type zcodeState struct {
+ WorkspacePath string `json:"workspacePath"`
+ FailureCount *int `json:"failureCount"`
+ LastCompressedSize *struct {
+ EncryptedSizeBytes int64 `json:"encryptedSizeBytes"`
+ WorkspaceSizeBytes int64 `json:"workspaceSizeBytes"`
+ ManifestHash string `json:"manifestHash"`
+ RecordedAt int64 `json:"recordedAt"`
+ } `json:"lastCompressedSize"`
+ LastAcceptedManifestHash string
`json:"lastAcceptedManifestHash"`
+ PendingUpload json.RawMessage `json:"pendingUpload"`
+ ActiveUpload json.RawMessage `json:"activeUpload"`
+}
+
+type zcodeManifest struct {
+ Files []struct {
+ Path string `json:"path"`
+ SizeBytes int64 `json:"sizeBytes"`
+ } `json:"files"`
+}
+
+const maxJsonBytes = 64 << 20
+
+// SnapshotsOf reads the snapshots an agent left in the given home, nil for an
+// agent known to leave none.
+func SnapshotsOf(agentId, home string) []Snapshot {
+ if agentId != "zcode" || home == "" {
+ return nil
+ }
+ root := filepath.Join(home, ".zcode", "v2", "checkpoints")
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil
+ }
+
+ snapshots := []Snapshot{}
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ if snapshot, ok := readZcodeWorkspace(filepath.Join(root,
entry.Name())); ok {
+ snapshots = append(snapshots, snapshot)
+ }
+ }
+ sort.Slice(snapshots, func(i, j int) bool { return
snapshots[i].RecordedAt.After(snapshots[j].RecordedAt) })
+ return snapshots
+}
+
+func readZcodeWorkspace(dir string) (Snapshot, bool) {
+ var state zcodeState
+ if !readJson(filepath.Join(dir, "state.json"), &state) {
+ return Snapshot{}, false
+ }
+ archives := hasArchive(dir)
+ pending := present(state.PendingUpload) || present(state.ActiveUpload)
|| archives
+ if state.FailureCount == nil && state.LastCompressedSize == nil &&
state.LastAcceptedManifestHash == "" && !pending {
+ return Snapshot{}, false
+ }
+
+ snapshot := Snapshot{
+ Workspace: state.WorkspacePath,
+ Accepted: state.LastAcceptedManifestHash != "",
+ Pending: pending,
+ }
+ if snapshot.Workspace == "" {
+ snapshot.Workspace = dir
+ }
+ if state.FailureCount != nil {
+ snapshot.Failures = *state.FailureCount
+ }
+ hash := state.LastAcceptedManifestHash
+ if size := state.LastCompressedSize; size != nil {
+ snapshot.EncryptedBytes = size.EncryptedSizeBytes
+ snapshot.WorkspaceBytes = size.WorkspaceSizeBytes
+ if size.RecordedAt > 0 {
+ snapshot.RecordedAt = time.UnixMilli(size.RecordedAt)
+ }
+ if size.ManifestHash != "" {
+ hash = size.ManifestHash
+ }
+ }
+
+ var manifest zcodeManifest
+ if hash != "" && readJson(filepath.Join(dir, "manifests",
hash+".json"), &manifest) {
+ snapshot.Files = len(manifest.Files)
+ for _, file := range manifest.Files {
+ if file.Path == ".git" || strings.HasPrefix(file.Path,
".git/") {
+ snapshot.GitFiles++
+ snapshot.GitBytes += file.SizeBytes
+ }
+ }
+ }
+ return snapshot, true
+}
+
+func hasArchive(dir string) bool {
+ found := false
+ _ = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err
error) error {
+ if err == nil && !entry.IsDir() &&
strings.HasSuffix(entry.Name(), ".enc") {
+ found = true
+ return fs.SkipAll
+ }
+ return nil
+ })
+ return found
+}
+
+func readJson(path string, into any) bool {
+ info, err := os.Stat(path)
+ if err != nil || info.Size() > maxJsonBytes {
+ return false
+ }
+ data, err := os.ReadFile(path)
+ return err == nil && json.Unmarshal(data, into) == nil
+}
+
+func present(raw json.RawMessage) bool {
+ raw = bytes.TrimSpace(raw)
+ return len(raw) > 0 && !bytes.Equal(raw, []byte("null"))
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index a688951..62cac6d 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -26,6 +26,7 @@ import (
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agentauth"
+ "github.com/apache/casbin-gateway/agentegress"
"github.com/apache/casbin-gateway/agenthistory"
"github.com/apache/casbin-gateway/agentinstall"
"github.com/apache/casbin-gateway/agentmonitor"
@@ -65,6 +66,8 @@ type discoveredAgent struct {
// SupportsAccounts says whether Gateway knows where this agent keeps
its
// sign-in, which is what lets it hold more than one and swap between
them.
SupportsAccounts bool `json:"supportsAccounts"`
+ // EgressFindings counts the uploads the egress watch recorded for this
agent.
+ EgressFindings int `json:"egressFindings,omitempty"`
}
// GetAgents scans known installation locations and returns the AI agents
@@ -93,6 +96,7 @@ func (c *ApiController) GetAgents() {
beego.Error("some connections could not be written again:", err)
}
+ egressFindings := object.CountAgentRecords(agentegress.EventType)
result := make([]*discoveredAgent, 0, len(installations))
baseUrls := map[string]string{}
for _, installation := range installations {
@@ -121,6 +125,7 @@ func (c *ApiController) GetAgents() {
baseUrls[installation.AgentId] = baseUrl
}
item.ProxyBaseUrl = baseUrl
+ item.EgressFindings = egressFindings[installation.AgentId]
if stored, ok := agents[installation.AgentId]; ok {
item.Provider = stored.Provider
item.Mode = stored.Mode
diff --git a/controllers/agent_egress.go b/controllers/agent_egress.go
new file mode 100644
index 0000000..a81e976
--- /dev/null
+++ b/controllers/agent_egress.go
@@ -0,0 +1,69 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/agentegress"
+ "github.com/apache/casbin-gateway/agenthome"
+ "github.com/apache/casbin-gateway/agentprocess"
+ "github.com/apache/casbin-gateway/object"
+)
+
+const egressFindingLimit = 20
+
+type agentEgress struct {
+ agentegress.Report
+ // Snapshots are packages the agent left on disk, whether or not the
watch
+ // was running when they went out.
+ Snapshots []agentegress.Snapshot `json:"snapshots"`
+ Findings []*object.AgentRecord `json:"findings"`
+}
+
+// GetAgentEgress returns where one agent's processes have been sending data.
+func (c *ApiController) GetAgentEgress() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ agentId := c.GetString("agentId")
+ if agentId == "" {
+ c.ResponseError("the agent id is empty")
+ return
+ }
+ result := agentEgress{
+ Report: agentegress.ReportOf(agentId),
+ Snapshots: []agentegress.Snapshot{},
+ Findings:
object.GetAgentRecords(object.AgentRecordFilter{Agent: agentId, EventType:
agentegress.EventType, Limit: egressFindingLimit}),
+ }
+ if home, err := agenthome.Resolve(c.GetString("owner")); err == nil {
+ if snapshots := agentegress.SnapshotsOf(agentId, home);
snapshots != nil {
+ result.Snapshots = snapshots
+ }
+ }
+ c.ResponseOk(result)
+}
+
+// AgentPids maps the live processes of every installation found by the last
+// scan to its agent id. It is what the egress watch polls, so it never scans.
+func AgentPids() map[int]string {
+ pids := map[int]string{}
+ for _, installation := range agent.Cached() {
+ for _, pid := range
agentprocess.StatusOf(processTarget(installation)).Pids {
+ pids[pid] = installation.AgentId
+ }
+ }
+ return pids
+}
diff --git a/main.go b/main.go
index adcfa4a..ad6cd84 100644
--- a/main.go
+++ b/main.go
@@ -20,6 +20,7 @@ import (
"os"
"time"
+ "github.com/apache/casbin-gateway/agentegress"
"github.com/apache/casbin-gateway/agenthook"
"github.com/apache/casbin-gateway/agentlink"
"github.com/apache/casbin-gateway/agentmonitor"
@@ -125,6 +126,9 @@ func main() {
beego.Error("agent monitor could not start:", err)
}
defer agentmonitor.Stop()
+ // Where each agent's own processes send data, which the proxy never
sees.
+ agentegress.Start(controllers.AgentPids)
+ defer agentegress.Stop()
// The conversations Gateway drives on somebody's behalf, put back
where a
// restart left them.
@@ -149,6 +153,7 @@ func main() {
// An update ends this process without unwinding main, so what the
deferred
// calls above would have flushed has to be flushed there instead.
version.BeforeRestart = func() {
+ agentegress.Stop()
agentmonitor.Stop()
object.StopAgentRecordWriter()
object.StopLlmRecordWriter()
diff --git a/object/agent_record.go b/object/agent_record.go
index f930f0a..20fded3 100644
--- a/object/agent_record.go
+++ b/object/agent_record.go
@@ -344,3 +344,25 @@ func agentSessionTitle(newestFirst []*AgentRecord) string {
oldest := newestFirst[len(newestFirst)-1]
return strings.TrimSpace(oldest.EventType + " " + oldest.Action)
}
+
+// CountAgentRecords counts the records of one event type per agent.
+func CountAgentRecords(eventType string) map[string]int {
+ counts := map[string]int{}
+ if ormer == nil || ormer.Engine == nil {
+ return counts
+ }
+ rows := []struct {
+ Agent string
+ Count int
+ }{}
+ err := ormer.Engine.Table(&AgentRecord{}).Select("agent, count(*) as
count").
+ Where("lower(event_type) = ?",
strings.ToLower(eventType)).GroupBy("agent").Find(&rows)
+ if err != nil {
+ beego.Error("agent record count failed:", err)
+ return counts
+ }
+ for _, row := range rows {
+ counts[row.Agent] = row.Count
+ }
+ return counts
+}
diff --git a/routers/router.go b/routers/router.go
index e88dade..652e0c8 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -79,6 +79,7 @@ func initAPI() {
beego.Router("/api/get-agent-permissions",
&controllers.ApiController{}, "GET:GetAgentPermissions")
beego.Router("/api/update-agent-permission",
&controllers.ApiController{}, "POST:UpdateAgentPermission")
beego.Router("/api/get-agent-processes", &controllers.ApiController{},
"GET:GetAgentProcesses")
+ beego.Router("/api/get-agent-egress", &controllers.ApiController{},
"GET:GetAgentEgress")
beego.Router("/api/start-agent", &controllers.ApiController{},
"POST:StartAgent")
beego.Router("/api/stop-agent", &controllers.ApiController{},
"POST:StopAgent")
beego.Router("/api/get-agent-instances", &controllers.ApiController{},
"GET:GetAgentInstances")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 1c3701c..d376282 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -17,6 +17,7 @@ import type {
AccountUsageResult,
Agent,
AgentCatalogEntry,
+ AgentEgress,
AgentInstallJob,
AgentInstance,
AgentProviderConfig,
@@ -131,6 +132,10 @@ export function getAgentProcesses(forceRefresh = false) {
return request<AgentRuntime[]>(`/api/get-agent-processes${forceRefresh ?
"?refresh=true" : ""}`);
}
+export function getAgentEgress(agentId: string, owner: string) {
+ return request<AgentEgress>(`/api/get-agent-egress${query({agentId: agentId,
owner: owner})}`);
+}
+
export function startAgent(target: PatchTarget) {
return request<AgentRuntime>("/api/start-agent", "POST", target);
}
diff --git a/web/src/types.ts b/web/src/types.ts
index 6186a3e..63abf44 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -220,6 +220,64 @@ export interface Agent {
/** Whether Gateway knows where this agent keeps its sign-in, which is what
* lets it hold several and swap between them. */
supportsAccounts?: boolean;
+ /** How many uploads the egress watch recorded for this agent. */
+ egressFindings?: number;
+}
+
+/** One place an agent's own processes connected to, as the egress watch saw
it. */
+export interface EgressDestination {
+ host?: string;
+ address: string;
+ /** "model", "storage", "telemetry", "local" or "other". */
+ kind: string;
+ /** The local process a "local" connection went to, usually a proxy. */
+ via?: string;
+ connections: number;
+ bytesOut: number;
+ bytesIn: number;
+ firstSeen: string;
+ lastSeen: string;
+ /** An upload to it is in progress and not recorded yet. */
+ flagged?: boolean;
+}
+
+/** A workspace the agent packed up whole, read from what it left on disk. */
+export interface EgressSnapshot {
+ workspace: string;
+ encryptedBytes?: number;
+ workspaceBytes?: number;
+ recordedAt?: string;
+ accepted: boolean;
+ pending: boolean;
+ failures?: number;
+ files?: number;
+ gitFiles?: number;
+ gitBytes?: number;
+}
+
+/** The detail of an egress record. */
+export interface EgressDetail {
+ host?: string;
+ address: string;
+ kind: string;
+ via?: string;
+ bytesOut: number;
+ bytesIn: number;
+ connections: number;
+ counted: boolean;
+ start: string;
+ end: string;
+}
+
+export interface AgentEgress {
+ /** False on a platform the watch does not run on. */
+ supported: boolean;
+ /** Whether bytes are measured; without it only destinations are. */
+ counted: boolean;
+ since: string;
+ destinations: EgressDestination[];
+ snapshots: EgressSnapshot[];
+ findings: AgentRecord[];
}
/** One sign-in Gateway keeps aside for an agent. The credential itself stays
on
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]