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]

Reply via email to