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 04b0aab feat: support Hermes Agent monitoring (#221)
04b0aab is described below
commit 04b0aab6a6ef8e66e89c4ff189a1b17a1dc222c4
Author: Paperlz <[email protected]>
AuthorDate: Sat Aug 22 15:44:27 2026 +0800
feat: support Hermes Agent monitoring (#221)
---
agentpatch/hermes_agent.go | 329 ++++++++++++++++++++++++++
agentpatch/hermes_config.go | 515 ++++++++++++++++++++++++++++++++++++++++
agentpatch/hermes_observer.py | 530 ++++++++++++++++++++++++++++++++++++++++++
agentpatch/hermes_plugin.yaml | 18 ++
go.mod | 1 +
5 files changed, 1393 insertions(+)
diff --git a/agentpatch/hermes_agent.go b/agentpatch/hermes_agent.go
new file mode 100644
index 0000000..4d1b817
--- /dev/null
+++ b/agentpatch/hermes_agent.go
@@ -0,0 +1,329 @@
+// 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 agentpatch
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/apache/casbin-gateway/agenthome"
+ "github.com/apache/casbin-gateway/agentmonitor"
+ "github.com/apache/casbin-gateway/internal/hermes"
+)
+
+const (
+ hermesPluginName = gatewayEntryName
+ hermesPluginVersion = "1.1.0"
+ hermesConfigSchema = 1
+ minHermesPluginVersion = "0.19.0"
+)
+
+//go:embed hermes_observer.py
+var hermesObserver []byte
+
+//go:embed hermes_plugin.yaml
+var hermesPluginManifest []byte
+
+type hermesPatcher struct{}
+
+type hermesLayout struct {
+ home string
+ configPath string
+ pluginDir string
+}
+
+type hermesObserverConfig struct {
+ SchemaVersion int `json:"schemaVersion"`
+ Owner string `json:"owner"`
+ PluginVersion string `json:"pluginVersion"`
+ RecordsURL string `json:"recordsUrl"`
+ AgentPath string `json:"agentPath"`
+ User string `json:"user"`
+ IngestToken string `json:"ingestToken"`
+ IngestTokenHeader string `json:"ingestTokenHeader"`
+}
+
+func init() {
+ register(hermesPatcher{})
+}
+
+func (hermesPatcher) AgentId() string { return hermes.AgentID }
+
+func (hermesPatcher) Supported() bool { return true }
+
+func (p hermesPatcher) Patch(target Target) error {
+ layout, err := p.validateTarget(target)
+ if err != nil {
+ return err
+ }
+ applied := IsApplied(target)
+ if info, statErr := os.Stat(layout.pluginDir); statErr == nil {
+ if !info.IsDir() {
+ return fmt.Errorf("%s exists and is not a directory",
layout.pluginDir)
+ }
+ if !applied {
+ return fmt.Errorf("%s already exists and is not owned
by this Gateway patch state", layout.pluginDir)
+ }
+ if owned, ownershipErr := hermesPluginOwned(layout.pluginDir);
ownershipErr != nil {
+ return ownershipErr
+ } else if !owned {
+ return fmt.Errorf("%s no longer contains a
Gateway-owned observer; refusing to refresh it", layout.pluginDir)
+ }
+ } else if !os.IsNotExist(statErr) {
+ return statErr
+ }
+
+ token, err := IssueIngestToken(target)
+ if err != nil {
+ return err
+ }
+ observerConfig, err := renderHermesObserverConfig(target, token)
+ if err != nil {
+ return errors.Join(err, RevokeIngestToken(target))
+ }
+ pluginFiles := []struct {
+ name string
+ data []byte
+ mode os.FileMode
+ }{
+ {"plugin.yaml", hermesPluginManifest, 0o644},
+ {"__init__.py", hermesObserver, 0o644},
+ {"gateway.json", observerConfig, 0o600},
+ }
+
+ if err := Apply(target, func(changes *ChangeSet) error {
+ if err := changes.MkdirAll(layout.pluginDir); err != nil {
+ return err
+ }
+ for _, file := range pluginFiles {
+ path := filepath.Join(layout.pluginDir, file.name)
+ if err := changes.WriteFile(path, file.data,
file.mode); err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ return errors.Join(err, RevokeIngestToken(target))
+ }
+
+ // config.yaml stays out of the backup journal on purpose. It is the
user's
+ // own file and keeps changing for as long as the patch is on, so
Unpatch
+ // deletes the one list entry Gateway added instead of restoring a whole
+ // file snapshot taken at Patch time. Only the installed plugin, which
is
+ // entirely Gateway's, is journalled - and that is what undoes this if
+ // enabling the plugin fails.
+ if err := writeHermesPluginMembership(layout.configPath, true); err !=
nil {
+ return errors.Join(err, Revert(target),
RevokeIngestToken(target))
+ }
+ return nil
+}
+
+func (p hermesPatcher) Unpatch(target Target) error {
+ // Revert works from the saved paths and does not need the current
launcher
+ // to still be valid, so the layout is needed only to reach
config.yaml. A
+ // host whose home directory no longer resolves still gets its plugin
+ // removed; the entry left in config.yaml then names a plugin that is
gone,
+ // which Hermes ignores.
+ var membershipErr error
+ if layout, err := p.layoutOf(target); err == nil {
+ membershipErr = writeHermesPluginMembership(layout.configPath,
false)
+ }
+ return errors.Join(membershipErr, Revert(target),
RevokeIngestToken(target))
+}
+
+// PatchNotice is the copy the agents table shows before and after the button,
+// so the Web UI does not need a branch of its own for how Hermes loads
plugins.
+func (hermesPatcher) PatchNotice(patched bool) (string, string) {
+ if patched {
+ return "Removes the behaviour observer from the default Hermes
profile and drops its entry from plugins.enabled. Nothing else in config.yaml
changes.",
+ "Restart running Hermes processes to unload it."
+ }
+ return "Installs a behaviour observer with redacted tool inputs in the
default Hermes profile and adds it to plugins.enabled. Named profiles are left
alone.",
+ "Restart running Hermes processes to load it."
+}
+
+func (p hermesPatcher) Status(target Target) (Status, error) {
+ layout, err := p.layoutOf(target)
+ if err != nil {
+ return Status{}, err
+ }
+ config, owned, err := readHermesObserverConfig(layout.pluginDir)
+ if err != nil {
+ return Status{}, err
+ }
+ if !owned {
+ if _, statErr := os.Stat(layout.pluginDir); statErr == nil {
+ return Status{Detail: "plugin directory exists but is
not owned by Gateway"}, nil
+ }
+ return Status{Detail: "not patched"}, nil
+ }
+
+ issuedAgent, tokenValid := ValidateIngestToken(config.IngestToken)
+ if !tokenValid || !strings.EqualFold(issuedAgent, target.AgentId) {
+ return Status{Detail: "observer files are stale or incomplete;
Patch again to refresh them"}, nil
+ }
+ expectedConfig, err := renderHermesObserverConfig(target,
config.IngestToken)
+ if err != nil {
+ return Status{}, err
+ }
+ expectedFiles := map[string][]byte{
+ "plugin.yaml": hermesPluginManifest, "__init__.py":
hermesObserver,
+ "gateway.json": expectedConfig,
+ }
+ for name, expected := range expectedFiles {
+ actual, readErr := os.ReadFile(filepath.Join(layout.pluginDir,
name))
+ if readErr != nil || !bytes.Equal(actual, expected) {
+ return Status{Detail: "observer files are stale or
incomplete; Patch again to refresh them"}, nil
+ }
+ }
+ enabled, disabled, err := hermesPluginMembership(layout.configPath)
+ if err != nil {
+ return Status{}, err
+ }
+ if !enabled || disabled {
+ return Status{Detail: "observer is installed but not enabled in
config.yaml; Patch again to repair it"}, nil
+ }
+ if !IsApplied(target) {
+ return Status{
+ Patched: true,
+ Detail: "observer is enabled outside this Gateway
patch state; an automatic Unpatch is unsafe",
+ }, nil
+ }
+ return Status{
+ Patched: true,
+ Detail: fmt.Sprintf(
+ "behaviour observer installed in %s; start a new Hermes
process or restart a running one to load it",
+ layout.home,
+ ),
+ }, nil
+}
+
+func (p hermesPatcher) validateTarget(target Target) (hermesLayout, error) {
+ layout, err := p.layoutOf(target)
+ if err != nil {
+ return hermesLayout{}, err
+ }
+ roots := []string{filepath.Join(layout.home, hermes.ProjectDir)}
+ if runtime.GOOS == "linux" && filepath.Clean(target.Path) ==
"/usr/local/bin/hermes" {
+ roots = append(roots, "/usr/local/lib/hermes-agent",
"/root/.hermes/hermes-agent")
+ }
+ project, err := hermes.InspectLauncher(target.Path, roots...)
+ if err != nil {
+ return hermesLayout{}, err
+ }
+ // The version gate is the compatibility check. InspectLauncher has
already
+ // confirmed the launcher belongs to a real hermes-agent project, and
every
+ // release from here on exposes the observer hooks the plugin registers;
+ // a hook Hermes does not know simply never fires, which the fail-open
+ // observer is built for.
+ if !hermes.VersionAtLeast(project.Version, minHermesPluginVersion) {
+ return hermesLayout{}, fmt.Errorf(
+ "Hermes Agent %s is incompatible; upgrade to %s or
later",
+ project.Version, minHermesPluginVersion,
+ )
+ }
+ return layout, nil
+}
+
+func (hermesPatcher) layoutOf(target Target) (hermesLayout, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return hermesLayout{}, err
+ }
+ hermesHome := filepath.Join(home, ".hermes")
+ if runtime.GOOS == "windows" {
+ hermesHome = filepath.Join(home, "AppData", "Local", "hermes")
+ if isProcessHome(home) {
+ if local := os.Getenv("LOCALAPPDATA"); local != "" {
+ hermesHome = filepath.Join(local, "hermes")
+ }
+ }
+ }
+ if configured := os.Getenv("HERMES_HOME"); configured != "" &&
isProcessHome(home) {
+ hermesHome = configured
+ }
+ return hermesLayout{
+ home: hermesHome,
+ configPath: filepath.Join(hermesHome, "config.yaml"),
+ pluginDir: filepath.Join(hermesHome, "plugins",
hermesPluginName),
+ }, nil
+}
+
+func renderHermesObserverConfig(target Target, token string) ([]byte, error) {
+ url, err := recordsURL()
+ if err != nil {
+ return nil, err
+ }
+ data, err := json.MarshalIndent(hermesObserverConfig{
+ SchemaVersion: hermesConfigSchema,
+ Owner: "casbin-gateway",
+ PluginVersion: hermesPluginVersion,
+ RecordsURL: url,
+ AgentPath: target.Path,
+ User: target.Owner,
+ IngestToken: token,
+ IngestTokenHeader: agentmonitor.IngestTokenHeader,
+ }, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append(data, '\n'), nil
+}
+
+func hermesPluginOwned(pluginDir string) (bool, error) {
+ _, owned, err := readHermesObserverConfig(pluginDir)
+ return owned, err
+}
+
+func readHermesObserverConfig(pluginDir string) (hermesObserverConfig, bool,
error) {
+ data, err := os.ReadFile(filepath.Join(pluginDir, "gateway.json"))
+ if os.IsNotExist(err) {
+ return hermesObserverConfig{}, false, nil
+ }
+ if err != nil {
+ return hermesObserverConfig{}, false, err
+ }
+ var config hermesObserverConfig
+ if err := json.Unmarshal(data, &config); err != nil {
+ return hermesObserverConfig{}, false, nil
+ }
+ return config, config.Owner == "casbin-gateway" &&
+ config.SchemaVersion == hermesConfigSchema, nil
+}
+
+func isProcessHome(home string) bool {
+ current, err := os.UserHomeDir()
+ if err != nil {
+ return false
+ }
+ left, right := filepath.Clean(home), filepath.Clean(current)
+ if resolved, err := filepath.EvalSymlinks(left); err == nil {
+ left = resolved
+ }
+ if resolved, err := filepath.EvalSymlinks(right); err == nil {
+ right = resolved
+ }
+ if runtime.GOOS == "windows" {
+ return strings.EqualFold(left, right)
+ }
+ return left == right
+}
diff --git a/agentpatch/hermes_config.go b/agentpatch/hermes_config.go
new file mode 100644
index 0000000..d1d6a00
--- /dev/null
+++ b/agentpatch/hermes_config.go
@@ -0,0 +1,515 @@
+// 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 agentpatch
+
+// Hermes' config.yaml belongs to the user, not to Gateway: a normal install is
+// well over a thousand lines of section banners, trailing notes and blank-line
+// separated blocks. Round-tripping that through a YAML marshaller to flip one
+// list entry rewrites every line of the file, so enabling the observer inserts
+// a single list item into the text instead. Everything outside that one line
+// stays byte for byte identical, which is also what lets Unpatch delete just
+// that line rather than restore a whole-file snapshot taken at Patch time.
+//
+// Reading stays with the real YAML parser: hermesPluginMembership is what
+// Status trusts, and writeHermesPluginMembership re-parses its own output
+// before committing it, so a document shape the line editor gets wrong is
+// caught here instead of by Hermes at startup.
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// writeHermesPluginMembership adds the observer to plugins.enabled, or removes
+// it, editing configPath in place. An existing config keeps its current mode.
+func writeHermesPluginMembership(configPath string, enabled bool) error {
+ data, err := os.ReadFile(configPath)
+ missing := os.IsNotExist(err)
+ if err != nil && !missing {
+ return err
+ }
+ if missing && !enabled {
+ return nil
+ }
+
+ updated, changed, err := setHermesPluginMembership(data, enabled)
+ if err != nil {
+ return fmt.Errorf("update %s: %w", configPath, err)
+ }
+ if !changed {
+ return nil
+ }
+ if err := verifyHermesPluginMembership(updated, enabled); err != nil {
+ return fmt.Errorf("update %s: %w", configPath, err)
+ }
+
+ if err := os.WriteFile(configPath, updated, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
+
+// verifyHermesPluginMembership re-reads an edited document with the YAML
parser
+// and checks the observer ended up where the edit intended. It is the guard
+// that keeps a line edit from writing a document Hermes would read
differently.
+func verifyHermesPluginMembership(data []byte, enabled bool) error {
+ listed, blocked, err := hermesPluginMembershipOf(data)
+ if err != nil {
+ return err
+ }
+ if listed != enabled || (enabled && blocked) {
+ return errors.New("the edited plugins list does not read back
as expected")
+ }
+ return nil
+}
+
+// hermesPluginEdit is one list membership the observer needs in config.yaml.
+type hermesPluginEdit struct {
+ key string
+ want bool
+}
+
+// setHermesPluginMembership returns config.yaml with the observer added to or
+// removed from plugins.enabled. Enabling also drops it from plugins.disabled,
+// which Hermes treats as a deny list that wins over plugins.enabled. Disabling
+// leaves plugins.disabled alone: an entry there is the user's, never ours.
+func setHermesPluginMembership(data []byte, enabled bool) ([]byte, bool,
error) {
+ edits := []hermesPluginEdit{{key: "enabled", want: enabled}}
+ if enabled {
+ edits = append(edits, hermesPluginEdit{key: "disabled", want:
false})
+ }
+
+ current, changed := data, false
+ for _, edit := range edits {
+ next, stepChanged, err := setHermesPluginListMember(current,
edit.key, edit.want)
+ if err != nil {
+ return nil, false, err
+ }
+ if stepChanged {
+ current, changed = next, true
+ }
+ }
+ return current, changed, nil
+}
+
+// setHermesPluginListMember adds or removes the observer in plugins.<key>. The
+// document is re-read for each edit so one edit's line numbers never depend on
+// another's.
+func setHermesPluginListMember(data []byte, key string, want bool) ([]byte,
bool, error) {
+ document := newYAMLLines(data)
+
+ plugins := document.findMappingKey("plugins", 0, 0, len(document.lines))
+ if plugins < 0 {
+ if !want {
+ return data, false, nil
+ }
+ document.appendBlock("plugins:", " "+key+":", " -
"+hermesPluginName)
+ return document.bytes(), true, nil
+ }
+ if _, value, _ := yamlMappingKey(document.lines[plugins], 0); value !=
"" {
+ return nil, false, fmt.Errorf("plugins must be a block mapping,
found %q", value)
+ }
+
+ pluginsEnd := document.blockEnd(0, plugins+1)
+ childIndent := document.childIndent(plugins, 0, pluginsEnd)
+ keyLine := document.findMappingKey(key, childIndent, plugins+1,
pluginsEnd)
+ if keyLine < 0 {
+ if !want {
+ return data, false, nil
+ }
+ indent := strings.Repeat(" ", childIndent)
+ document.insert(document.lastContent(plugins+1, pluginsEnd)+1,
+ indent+key+":", indent+" - "+hermesPluginName)
+ return document.bytes(), true, nil
+ }
+ if _, value, _ := yamlMappingKey(document.lines[keyLine], childIndent);
value != "" {
+ return setHermesPluginFlowMember(document, keyLine, key, want)
+ }
+
+ items := document.sequenceItems(keyLine, childIndent)
+ if len(items) == 0 && document.lastContent(keyLine+1,
document.blockEnd(childIndent, keyLine+1)) > keyLine {
+ return nil, false, fmt.Errorf("plugins.%s must be a list", key)
+ }
+ existing := -1
+ for _, item := range items {
+ if value, _, _ := yamlSequenceItem(document.lines[item]); value
== hermesPluginName {
+ existing = item
+ break
+ }
+ }
+
+ if !want {
+ if existing < 0 {
+ return data, false, nil
+ }
+ document.remove(existing)
+ return document.bytes(), true, nil
+ }
+ if existing >= 0 {
+ return data, false, nil
+ }
+ itemIndent, insertAt := childIndent+2, keyLine+1
+ if len(items) > 0 {
+ last := items[len(items)-1]
+ _, itemIndent, _ = yamlSequenceItem(document.lines[last])
+ // An item may span more than its first line, so append after
the last
+ // line that still belongs to it rather than after its opening
dash.
+ insertAt = document.lastContent(last,
document.blockEnd(itemIndent, last+1)) + 1
+ }
+ document.insert(insertAt, strings.Repeat(" ", itemIndent)+"-
"+hermesPluginName)
+ return document.bytes(), true, nil
+}
+
+// setHermesPluginFlowMember edits an inline "key: [a, b]" list without parsing
+// or rewriting its existing values. Patch appends its own scalar at the end;
+// Unpatch removes only that exact trailing scalar. If an operator moves or
+// rewrites the entry, Unpatch reports the conflict instead of risking the rest
+// of the flow sequence.
+func setHermesPluginFlowMember(document *yamlLines, keyLine int, key string,
want bool) ([]byte, bool, error) {
+ line := document.lines[keyLine]
+ open := strings.IndexByte(line, '[')
+ end := strings.LastIndexByte(line, ']')
+ if open < 0 || end < open {
+ return nil, false, fmt.Errorf("plugins.%s must be a list", key)
+ }
+
+ enabled, disabled, err := hermesPluginMembershipOf(document.bytes())
+ if err != nil {
+ return nil, false, err
+ }
+ found := enabled
+ if key == "disabled" {
+ found = disabled
+ }
+ if found == want {
+ return document.bytes(), false, nil
+ }
+
+ content := line[open+1 : end]
+ core := strings.TrimRight(content, " \t")
+ trailing := content[len(core):]
+ if want {
+ separator := ""
+ if strings.TrimSpace(core) != "" {
+ separator = ", "
+ }
+ document.lines[keyLine] = line[:open+1] + core + separator +
hermesPluginName + trailing + line[end:]
+ return document.bytes(), true, nil
+ }
+
+ if strings.TrimSpace(core) == hermesPluginName {
+ document.lines[keyLine] = line[:open+1] + trailing + line[end:]
+ return document.bytes(), true, nil
+ }
+ separator := strings.LastIndexByte(core, ',')
+ if separator < 0 || strings.TrimSpace(core[separator+1:]) !=
hermesPluginName {
+ return nil, false, fmt.Errorf("plugins.%s Gateway entry is not
the final flow-list item", key)
+ }
+ document.lines[keyLine] = line[:open+1] + core[:separator] + trailing +
line[end:]
+ return document.bytes(), true, nil
+}
+
+// yamlLines is a line-oriented view of a YAML document. It understands just
+// enough of the syntax to find a mapping key and the block sequence under it;
+// every shape beyond that it declines to touch, leaving the caller to report a
+// config it should not be editing blind.
+type yamlLines struct {
+ lines []string
+ newline string
+}
+
+func newYAMLLines(data []byte) *yamlLines {
+ newline := "\n"
+ if bytes.Contains(data, []byte("\r\n")) {
+ newline = "\r\n"
+ }
+ text := strings.ReplaceAll(string(data), "\r\n", "\n")
+ return &yamlLines{lines: strings.Split(text, "\n"), newline: newline}
+}
+
+func (y *yamlLines) bytes() []byte {
+ return []byte(strings.Join(y.lines, y.newline))
+}
+
+func (y *yamlLines) insert(at int, lines ...string) {
+ y.lines = append(y.lines[:at:at], append(lines, y.lines[at:]...)...)
+}
+
+func (y *yamlLines) remove(at int) {
+ y.lines = append(y.lines[:at:at], y.lines[at+1:]...)
+}
+
+// appendBlock adds lines at the end of the document, keeping the file's final
+// newline and separating the new block from whatever content came before it.
+func (y *yamlLines) appendBlock(lines ...string) {
+ at := len(y.lines)
+ if at > 0 && y.lines[at-1] == "" {
+ // The empty last element stands for the file's final newline.
+ at--
+ } else {
+ y.lines = append(y.lines, "")
+ }
+ if at > 0 && strings.TrimSpace(y.lines[at-1]) != "" {
+ lines = append([]string{""}, lines...)
+ }
+ y.insert(at, lines...)
+}
+
+// findMappingKey returns the line index of key at indent inside [start, end),
+// or -1. The scan stops at the first content line shallower than indent, which
+// is where the surrounding block ends.
+func (y *yamlLines) findMappingKey(key string, indent, start, end int) int {
+ for index := start; index < end && index < len(y.lines); index++ {
+ current, ok := yamlContentIndent(y.lines[index])
+ if !ok || current > indent {
+ continue
+ }
+ if current < indent {
+ return -1
+ }
+ if name, _, ok := yamlMappingKey(y.lines[index], indent); ok &&
name == key {
+ return index
+ }
+ }
+ return -1
+}
+
+// blockEnd returns the first line index at or after start that leaves the
block
+// owned by a key at indent.
+func (y *yamlLines) blockEnd(indent, start int) int {
+ for index := start; index < len(y.lines); index++ {
+ if current, ok := yamlContentIndent(y.lines[index]); ok &&
current <= indent {
+ return index
+ }
+ }
+ return len(y.lines)
+}
+
+// childIndent is the indentation of the first content line inside the block
+// under keyLine, or two past the key's own indent when the block is empty.
+func (y *yamlLines) childIndent(keyLine, indent, end int) int {
+ for index := keyLine + 1; index < end && index < len(y.lines); index++ {
+ if current, ok := yamlContentIndent(y.lines[index]); ok {
+ return current
+ }
+ }
+ return indent + 2
+}
+
+// lastContent is the index of the last content line in [start, end), or
+// start-1 when the range holds none, so callers can insert right after it.
+func (y *yamlLines) lastContent(start, end int) int {
+ last := start - 1
+ for index := start; index < end && index < len(y.lines); index++ {
+ if _, ok := yamlContentIndent(y.lines[index]); ok {
+ last = index
+ }
+ }
+ return last
+}
+
+// sequenceItems returns the line index of every item of the block sequence
+// under the key at keyLine. Items may be indented deeper than their key or, as
+// YAML also allows, sit at the key's own indentation.
+func (y *yamlLines) sequenceItems(keyLine, keyIndent int) []int {
+ var items []int
+ for index := keyLine + 1; index < len(y.lines); index++ {
+ current, ok := yamlContentIndent(y.lines[index])
+ if !ok {
+ continue
+ }
+ if _, itemIndent, isItem := yamlSequenceItem(y.lines[index]);
isItem && itemIndent >= keyIndent {
+ items = append(items, index)
+ continue
+ }
+ // Anything deeper is a continuation of the item above it;
anything at
+ // or above the key's own indent ends the sequence.
+ if current <= keyIndent {
+ break
+ }
+ }
+ return items
+}
+
+// yamlContentIndent returns the indentation of a line that carries content.
+// Blank lines and whole-line comments belong to whichever block surrounds
them,
+// so they report no content and every scan above skips over them.
+func yamlContentIndent(line string) (int, bool) {
+ trimmed := strings.TrimLeft(line, " ")
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ return 0, false
+ }
+ return len(line) - len(trimmed), true
+}
+
+// yamlMappingKey reports the key and the inline value of a "<key>: <value>"
+// line sitting at exactly indent. Only plain unquoted keys are recognized,
+// which covers every key Gateway looks for.
+func yamlMappingKey(line string, indent int) (string, string, bool) {
+ current, ok := yamlContentIndent(line)
+ if !ok || current != indent {
+ return "", "", false
+ }
+ key, value, ok := strings.Cut(line[indent:], ":")
+ if !ok || !isPlainYAMLKey(key) {
+ return "", "", false
+ }
+ return key, yamlInlineValue(value), true
+}
+
+func isPlainYAMLKey(key string) bool {
+ if key == "" {
+ return false
+ }
+ for _, char := range key {
+ switch {
+ case char >= 'a' && char <= 'z', char >= 'A' && char <= 'Z',
+ char >= '0' && char <= '9', char == '_', char == '-',
char == '.':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// yamlInlineValue strips the trailing comment from the value half of a mapping
+// line. A flow sequence ends at its closing bracket; anything else ends where
a
+// comment starts.
+func yamlInlineValue(value string) string {
+ value = strings.TrimSpace(value)
+ if strings.HasPrefix(value, "#") {
+ return ""
+ }
+ if strings.HasPrefix(value, "[") {
+ if end := strings.LastIndex(value, "]"); end >= 0 {
+ return value[:end+1]
+ }
+ return value
+ }
+ if comment := strings.Index(value, " #"); comment >= 0 {
+ return strings.TrimSpace(value[:comment])
+ }
+ return value
+}
+
+// yamlSequenceItem reports the scalar of a "- value" line and its indent.
+// Quotes are stripped so a quoted plugin name matches the plain one Gateway
+// writes.
+func yamlSequenceItem(line string) (string, int, bool) {
+ indent, ok := yamlContentIndent(line)
+ if !ok {
+ return "", 0, false
+ }
+ rest := line[indent:]
+ if rest != "-" && !strings.HasPrefix(rest, "- ") {
+ return "", 0, false
+ }
+ return strings.Trim(yamlInlineValue(rest[1:]), `"'`), indent, true
+}
+
+// hermesPluginMembership reports whether config.yaml lists the observer in
+// plugins.enabled and in plugins.disabled. A missing config lists neither.
+func hermesPluginMembership(configPath string) (bool, bool, error) {
+ data, err := os.ReadFile(configPath)
+ if os.IsNotExist(err) {
+ return false, false, nil
+ }
+ if err != nil {
+ return false, false, err
+ }
+ return hermesPluginMembershipOf(data)
+}
+
+func hermesPluginMembershipOf(data []byte) (bool, bool, error) {
+ root, err := parseYAMLMapping(data)
+ if err != nil {
+ return false, false, err
+ }
+ plugins, ok := mappingValue(root, "plugins")
+ if !ok {
+ return false, false, nil
+ }
+ if plugins.Kind != yaml.MappingNode {
+ return false, false, errors.New("plugins must be a mapping")
+ }
+ enabled, err := pluginSequence(plugins, "enabled")
+ if err != nil {
+ return false, false, err
+ }
+ disabled, err := pluginSequence(plugins, "disabled")
+ if err != nil {
+ return false, false, err
+ }
+ return sequenceContains(enabled, hermesPluginName),
+ sequenceContains(disabled, hermesPluginName), nil
+}
+
+// pluginSequence returns the plugins.<key> list, or nil when the key is absent
+// or empty. Removing the last entry leaves the key behind with nothing under
+// it, which Hermes reads the same way it reads a missing key, so an empty list
+// written that way is not an error here either.
+func pluginSequence(plugins *yaml.Node, key string) (*yaml.Node, error) {
+ value, ok := mappingValue(plugins, key)
+ if !ok || value.Tag == "!!null" {
+ return nil, nil
+ }
+ if value.Kind != yaml.SequenceNode {
+ return nil, fmt.Errorf("plugins.%s must be a sequence", key)
+ }
+ return value, nil
+}
+
+func parseYAMLMapping(data []byte) (*yaml.Node, error) {
+ if len(bytes.TrimSpace(data)) == 0 {
+ return &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}, nil
+ }
+ var document yaml.Node
+ if err := yaml.Unmarshal(data, &document); err != nil {
+ return nil, err
+ }
+ if len(document.Content) != 1 || document.Content[0].Kind !=
yaml.MappingNode {
+ return nil, errors.New("config root must be a mapping")
+ }
+ return document.Content[0], nil
+}
+
+func mappingValue(mapping *yaml.Node, key string) (*yaml.Node, bool) {
+ if mapping == nil || mapping.Kind != yaml.MappingNode {
+ return nil, false
+ }
+ for index := 0; index+1 < len(mapping.Content); index += 2 {
+ if mapping.Content[index].Value == key {
+ return mapping.Content[index+1], true
+ }
+ }
+ return nil, false
+}
+
+func sequenceContains(sequence *yaml.Node, value string) bool {
+ if sequence == nil || sequence.Kind != yaml.SequenceNode {
+ return false
+ }
+ for _, item := range sequence.Content {
+ if item.Kind == yaml.ScalarNode && item.Value == value {
+ return true
+ }
+ }
+ return false
+}
diff --git a/agentpatch/hermes_observer.py b/agentpatch/hermes_observer.py
new file mode 100644
index 0000000..efe3bed
--- /dev/null
+++ b/agentpatch/hermes_observer.py
@@ -0,0 +1,530 @@
+"""Casbin Gateway's behaviour observer for Hermes Agent.
+
+This module intentionally never serializes prompts, responses, reasoning,
+tool results, or subagent text. Tool arguments are redacted and bounded before
+hook callbacks enqueue records without waiting for the Gateway HTTP endpoint.
+"""
+
+from __future__ import annotations
+
+import atexit
+import json
+import logging
+import queue
+import re
+import threading
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+
+_LOGGER = logging.getLogger(__name__)
+_CONFIG_PATH = Path(__file__).with_name("gateway.json")
+_QUEUE: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=1024)
+_STOP = threading.Event()
+_SENDER: threading.Thread | None = None
+_REGISTER_LOCK = threading.Lock()
+_MAX_OBJECT_BYTES = 64 * 1024
+
+_CREDENTIAL_PATTERN = re.compile(
+ r"\b(?:sk-(?:ant-|proj-)?[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{20,}|"
+ r"github_pat_[a-z0-9_]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9a-z_-]{30,}|"
+ r"xox[baprs]-[0-9a-z-]{12,}|eyJ[a-z0-9_-]{10,}\.[a-z0-9_-]{10,}\."
+ r"[a-z0-9_-]{10,})\b",
+ re.IGNORECASE,
+)
+_BEARER_PATTERN = re.compile(r"(bearer\s+)[a-z0-9._~+/=-]{12,}", re.IGNORECASE)
+_PRIVATE_KEY_PATTERN = re.compile(
+ r"-----BEGIN [^-\n]*PRIVATE KEY-----.*?-----END [^-\n]*PRIVATE KEY-----",
+ re.DOTALL,
+)
+_SENSITIVE_KEY_MARKERS = (
+ "secret",
+ "token",
+ "password",
+ "passwd",
+ "credential",
+ "privatekey",
+ "apikey",
+ "accesskey",
+ "authorization",
+ "cookie",
+)
+
+_TOKEN_COUNT_KEYS = {
+ "maxtokens",
+ "maxcompletiontokens",
+ "maxtokenstosample",
+ "maxoutputtokens",
+ "prompttokens",
+ "completiontokens",
+ "totaltokens",
+ "reasoningtokens",
+ "inputtokens",
+ "outputtokens",
+ "tokencount",
+ "numtokens",
+ "tokens",
+ "cachecreationinputtokens",
+ "cachereadinputtokens",
+ "cachedtokens",
+}
+
+_USAGE_KEYS = {
+ "input_tokens",
+ "output_tokens",
+ "total_tokens",
+ "prompt_tokens",
+ "completion_tokens",
+ "cache_read_input_tokens",
+ "cache_creation_input_tokens",
+ "reasoning_tokens",
+ "inputTokens",
+ "outputTokens",
+ "totalTokens",
+ "promptTokens",
+ "completionTokens",
+ "cacheReadInputTokens",
+ "cacheCreationInputTokens",
+ "reasoningTokens",
+}
+
+
+def _load_config() -> dict[str, Any]:
+ try:
+ value = json.loads(_CONFIG_PATH.read_text(encoding="utf-8"))
+ return value if isinstance(value, dict) else {}
+ except Exception:
+ return {}
+
+
+_CONFIG = _load_config()
+_RECORDS_URL = str(_CONFIG.get("recordsUrl") or "")
+_AGENT_PATH = str(_CONFIG.get("agentPath") or "")
+_USER = str(_CONFIG.get("user") or "")
+_INGEST_TOKEN = str(_CONFIG.get("ingestToken") or "")
+_INGEST_TOKEN_HEADER = str(_CONFIG.get("ingestTokenHeader") or "")
+
+
+def _text(value: Any, limit: int = 512) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, (str, int, float, bool)):
+ return str(value)[:limit]
+ return ""
+
+
+def _integer(value: Any) -> int | None:
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float)):
+ return int(value)
+ return None
+
+
+def _duration_ms(value: Any, *, seconds: bool = False) -> int:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return 0
+ if seconds:
+ number *= 1000
+ return max(0, int(number))
+
+
+def _length(value: Any) -> int:
+ if value is None:
+ return 0
+ try:
+ return len(value)
+ except (TypeError, ValueError):
+ return len(str(value))
+
+
+def _count(value: Any) -> int:
+ if isinstance(value, (list, tuple, set, dict)):
+ return len(value)
+ return 0
+
+
+def _metadata(**values: Any) -> dict[str, Any]:
+ return {key: value for key, value in values.items() if value not in ("",
None)}
+
+
+def _sanitize_value(key: str, value: Any) -> Any:
+ normalized_key = key.lower().replace("_", "").replace("-",
"").replace(".", "")
+ if normalized_key not in _TOKEN_COUNT_KEYS and any(
+ marker in normalized_key for marker in _SENSITIVE_KEY_MARKERS
+ ):
+ return "[REDACTED]"
+ if isinstance(value, dict):
+ return {child_key: _sanitize_value(child_key, child) for child_key,
child in value.items()}
+ if isinstance(value, list):
+ return [_sanitize_value("", child) for child in value]
+ if isinstance(value, str):
+ value = _PRIVATE_KEY_PATTERN.sub("[REDACTED PRIVATE KEY]", value)
+ value = _BEARER_PATTERN.sub(r"\1[REDACTED]", value)
+ return _CREDENTIAL_PATTERN.sub("[REDACTED]", value)
+ return value
+
+
+def _sensitive_path(file_path: str) -> bool:
+ normalized = file_path.lower().replace("\\", "/")
+ base = normalized.rsplit("/", 1)[-1]
+ if base.startswith(".env") and base not in {".env.example", ".env.sample",
".env.template"}:
+ return True
+ if normalized.startswith(".ssh/") or "/.ssh/" in normalized:
+ return True
+ if normalized == ".aws/credentials" or
normalized.endswith("/.aws/credentials"):
+ return True
+ if base in {".npmrc", ".pypirc", "credentials", "id_rsa", "id_ed25519"}:
+ return True
+ return base.endswith((".pem", ".key"))
+
+
+def _sanitize_tool_arguments(tool_name: str, arguments: dict[str, Any]) ->
dict[str, Any]:
+ sanitized = _sanitize_value("", arguments)
+ normalized_tool = tool_name.lower()
+ if (normalized_tool == "patch" or normalized_tool.endswith("__patch")) and
"patch" in sanitized:
+ sanitized["patch"] = "[OMITTED: patch content]"
+ sensitive_write = normalized_tool in {"write", "edit", "write_file",
"edit_file", "patch"} or normalized_tool.endswith(
+ ("__write_file", "__edit_file", "__patch")
+ )
+ file_path = arguments.get("path", "") or arguments.get("file_path", "")
+ if not sensitive_write or not _sensitive_path(file_path):
+ return sanitized
+ for key in ("content", "old_string", "new_string"):
+ if key in sanitized:
+ sanitized[key] = "[REDACTED: sensitive file content]"
+ return sanitized
+
+
+def _usage(value: Any) -> dict[str, int]:
+ if not isinstance(value, dict):
+ return {}
+ result: dict[str, int] = {}
+ for key in _USAGE_KEYS:
+ number = _integer(value.get(key))
+ if number is not None:
+ result[key] = number
+ return result
+
+
+def _mcp_identity(tool_name: str) -> tuple[str, str]:
+ parts = tool_name.split("__", 2)
+ if len(parts) == 3 and parts[0].lower() == "mcp" and parts[1] and parts[2]:
+ return parts[1], parts[2]
+ return "", ""
+
+
+def _base_record(
+ event_type: str,
+ action: str,
+ outcome: str,
+ values: dict[str, Any],
+ metadata: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ record: dict[str, Any] = {
+ "agent": "hermes-agent",
+ "agentPath": _AGENT_PATH,
+ "user": _USER,
+ "eventType": event_type,
+ "action": action,
+ "sessionKey": _text(
+ values.get("session_id")
+ or values.get("parent_session_id")
+ or values.get("child_session_id")
+ ),
+ "model": _text(values.get("model")),
+ "promptId": _text(
+ values.get("api_request_id")
+ or values.get("turn_id")
+ or values.get("task_id")
+ ),
+ }
+ if outcome:
+ record["outcome"] = outcome
+ if metadata:
+ encoded = json.dumps(
+ metadata, ensure_ascii=False, separators=(",", ":"), sort_keys=True
+ )
+ encoded_bytes = encoded.encode("utf-8")
+ if len(encoded_bytes) > _MAX_OBJECT_BYTES:
+ encoded = json.dumps(
+ {
+ "truncated": True,
+ "originalBytes": len(encoded_bytes),
+ "preview": encoded_bytes[: _MAX_OBJECT_BYTES // 3].decode(
+ "utf-8", errors="ignore"
+ ),
+ },
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ )
+ record["object"] = encoded
+ return {key: value for key, value in record.items() if value not in ("",
None)}
+
+
+def _enqueue(record: dict[str, Any]) -> None:
+ if not _RECORDS_URL:
+ return
+ try:
+ _QUEUE.put_nowait(record)
+ except queue.Full:
+ _LOGGER.debug("Gateway observer queue is full; dropping one audit
record")
+
+
+def _send(record: dict[str, Any]) -> None:
+ body = json.dumps(record, ensure_ascii=False, separators=(",",
":")).encode("utf-8")
+ headers = {"Content-Type": "application/json"}
+ if _INGEST_TOKEN_HEADER and _INGEST_TOKEN:
+ headers[_INGEST_TOKEN_HEADER] = _INGEST_TOKEN
+ request = urllib.request.Request(
+ _RECORDS_URL,
+ data=body,
+ headers=headers,
+ method="POST",
+ )
+ with urllib.request.urlopen(request, timeout=2.0) as response:
+ response.read(1)
+
+
+def _sender_loop() -> None:
+ while not _STOP.is_set() or not _QUEUE.empty():
+ try:
+ record = _QUEUE.get(timeout=0.2)
+ except queue.Empty:
+ continue
+ try:
+ _send(record)
+ except Exception as exc:
+ _LOGGER.debug("Gateway record delivery failed: %s",
type(exc).__name__)
+ finally:
+ _QUEUE.task_done()
+
+
+def _start_sender() -> None:
+ global _SENDER
+ if not _RECORDS_URL:
+ return
+ with _REGISTER_LOCK:
+ if _SENDER is not None and _SENDER.is_alive():
+ return
+ _SENDER = threading.Thread(
+ target=_sender_loop,
+ name="casbin-gateway-agent-monitor",
+ daemon=True,
+ )
+ _SENDER.start()
+
+
+def _shutdown() -> None:
+ _STOP.set()
+ sender = _SENDER
+ if sender is not None and sender.is_alive():
+ sender.join(timeout=1.0)
+
+
+atexit.register(_shutdown)
+
+
+def _on_pre_api_request(**values: Any) -> None:
+ data = _metadata(
+ provider=_text(values.get("provider")),
+ apiMode=_text(values.get("api_mode")),
+ apiCallCount=_integer(values.get("api_call_count")),
+ retryCount=_integer(values.get("retry_count")),
+ messageCount=_integer(values.get("message_count")),
+ toolCount=_integer(values.get("tool_count")),
+ approxInputTokens=_integer(values.get("approx_input_tokens")),
+ requestCharCount=_integer(values.get("request_char_count")),
+ maxTokens=_integer(values.get("max_tokens")),
+ )
+ _enqueue(_base_record("llm", "request", "attempted", values, data))
+
+
+def _on_post_api_request(**values: Any) -> None:
+ data = _metadata(
+ provider=_text(values.get("provider")),
+ apiMode=_text(values.get("api_mode")),
+ apiCallCount=_integer(values.get("api_call_count")),
+ retryCount=_integer(values.get("retry_count")),
+ finishReason=_text(values.get("finish_reason")),
+ responseModel=_text(values.get("response_model")),
+ messageCount=_integer(values.get("message_count")),
+ assistantContentChars=_integer(values.get("assistant_content_chars")),
+
assistantToolCallCount=_integer(values.get("assistant_tool_call_count")),
+ usage=_usage(values.get("usage")),
+ )
+ record = _base_record("llm", "response", "success", values, data)
+ record["durationMs"] = _duration_ms(values.get("api_duration"),
seconds=True)
+ _enqueue(record)
+
+
+def _error_identity(values: dict[str, Any]) -> tuple[str, Any]:
+ """Split Hermes' error report into a type name and the message itself.
+
+ ``api_request_error`` reports a structured ``error = {"type", "message"}``;
+ the tool hooks report flat ``error_type`` / ``error_message`` instead. Only
+ the type name and the message's length ever leave this module.
+ """
+ error = values.get("error")
+ error_type = _text(values.get("error_type"))
+ message = values.get("error_message")
+ if isinstance(error, dict):
+ error_type = error_type or _text(error.get("type"))
+ if message is None:
+ message = error.get("message")
+ elif message is None and error is not None:
+ message = error
+ if not error_type and message is not None:
+ error_type = type(message).__name__
+ return error_type, message
+
+
+def _on_api_request_error(**values: Any) -> None:
+ error_type, error_message = _error_identity(values)
+ data = _metadata(
+ provider=_text(values.get("provider")),
+ apiMode=_text(values.get("api_mode")),
+ apiCallCount=_integer(values.get("api_call_count")),
+ retryCount=_integer(values.get("retry_count")),
+ maxRetries=_integer(values.get("max_retries")),
+ retryable=values.get("retryable") if
isinstance(values.get("retryable"), bool) else None,
+ statusCode=_integer(values.get("status_code")),
+ reason=_text(values.get("reason")),
+ errorType=error_type,
+ errorMessageLength=_length(error_message) if error_message is not None
else None,
+ )
+ record = _base_record("llm", "error", "failure", values, data)
+ if values.get("api_duration") is not None:
+ record["durationMs"] = _duration_ms(values.get("api_duration"),
seconds=True)
+ _enqueue(record)
+
+
+def _tool_record(action: str, outcome: str, values: dict[str, Any]) ->
dict[str, Any]:
+ tool_name = _text(values.get("tool_name"))
+ server, tool = _mcp_identity(tool_name)
+ event_type = "mcp" if server else "tool"
+ error_type, error_message = _error_identity(values)
+ data = _metadata(
+ taskId=_text(values.get("task_id")),
+ turnId=_text(values.get("turn_id")),
+ errorType=error_type,
+ errorMessageLength=_length(error_message) if error_message is not None
else None,
+ middlewareStageCount=_count(values.get("middleware_trace")),
+ )
+ if action == "call":
+ data["arguments"] = _sanitize_tool_arguments(tool_name, values["args"])
+ record = _base_record(event_type, action, outcome, values, data)
+ record["toolName"] = tool_name
+ record["toolUseId"] = _text(values.get("tool_call_id"))
+ if server:
+ record["mcpServer"] = server
+ record["mcpTool"] = tool
+ duration = _duration_ms(values.get("duration_ms"))
+ if duration:
+ record["durationMs"] = duration
+ return {key: value for key, value in record.items() if value not in ("",
None)}
+
+
+def _on_pre_tool_call(**values: Any) -> None:
+ _enqueue(_tool_record("call", "attempted", values))
+
+
+def _on_post_tool_call(**values: Any) -> None:
+ status = _text(values.get("status")).lower()
+ outcome = {"ok": "success", "blocked": "denied", "error": "failure"}.get(
+ status, "failure"
+ )
+ _enqueue(_tool_record("result", outcome, values))
+
+
+def _on_session_start(**values: Any) -> None:
+ data = _metadata(taskId=_text(values.get("task_id")))
+ _enqueue(_base_record("session", "start", "", values, data))
+
+
+def _on_session_reset(**values: Any) -> None:
+ normalized = dict(values)
+ normalized["session_id"] = values.get("new_session_id") or
values.get("session_id")
+ data = _metadata(
+ oldSessionId=_text(values.get("old_session_id")),
+ reason=_text(values.get("reason")),
+ )
+ _enqueue(_base_record("session", "reset", "success", normalized, data))
+
+
+def _on_session_end(**values: Any) -> None:
+ failed = bool(values.get("failed"))
+ interrupted = bool(values.get("interrupted"))
+ outcome = "failure" if failed or interrupted else "success"
+ data = _metadata(
+ taskId=_text(values.get("task_id")),
+ turnId=_text(values.get("turn_id")),
+ completed=bool(values.get("completed")),
+ failed=failed,
+ interrupted=interrupted,
+ exitReason=_text(values.get("turn_exit_reason")),
+ )
+ _enqueue(_base_record("session", "end", outcome, values, data))
+
+
+def _on_subagent_start(**values: Any) -> None:
+ normalized = dict(values)
+ normalized["session_id"] = values.get("parent_session_id") or
values.get("session_id")
+ normalized["turn_id"] = values.get("parent_turn_id") or
values.get("turn_id")
+ data = _metadata(
+ subagentId=_text(values.get("child_subagent_id") or
values.get("subagent_id")),
+ childSessionId=_text(values.get("child_session_id")),
+ childRole=_text(values.get("child_role")),
+ goalLength=_length(values.get("child_goal")),
+ )
+ _enqueue(_base_record("subagent", "start", "attempted", normalized, data))
+
+
+def _on_subagent_stop(**values: Any) -> None:
+ normalized = dict(values)
+ normalized["session_id"] = values.get("parent_session_id") or
values.get("session_id")
+ normalized["turn_id"] = values.get("parent_turn_id") or
values.get("turn_id")
+ status = _text(values.get("child_status") or values.get("status")).lower()
+ outcome = "success" if status in {"ok", "success", "completed"} else
"failure"
+ data = _metadata(
+ subagentId=_text(values.get("child_subagent_id") or
values.get("subagent_id")),
+ childSessionId=_text(values.get("child_session_id")),
+ childRole=_text(values.get("child_role")),
+ status=status,
+ summaryLength=_length(values.get("child_summary")),
+ toolCallCount=_count(values.get("tool_call_history")),
+ )
+ # "stop", not "end": the rest of Gateway's record vocabulary already spells
+ # the end of a subagent that way (see agenthook's SubagentStop).
+ record = _base_record("subagent", "stop", outcome, normalized, data)
+ duration = values.get("duration_ms")
+ if duration is None and values.get("duration") is not None:
+ record["durationMs"] = _duration_ms(values.get("duration"),
seconds=True)
+ elif duration is not None:
+ record["durationMs"] = _duration_ms(duration)
+ _enqueue(record)
+
+
+_HOOKS = {
+ "pre_api_request": _on_pre_api_request,
+ "post_api_request": _on_post_api_request,
+ "api_request_error": _on_api_request_error,
+ "pre_tool_call": _on_pre_tool_call,
+ "post_tool_call": _on_post_tool_call,
+ "on_session_start": _on_session_start,
+ "on_session_end": _on_session_end,
+ "on_session_reset": _on_session_reset,
+ "subagent_start": _on_subagent_start,
+ "subagent_stop": _on_subagent_stop,
+}
+
+
+def register(ctx: Any) -> None:
+ """Register observational hooks. Return values are deliberately ignored."""
+ _start_sender()
+ for name, callback in _HOOKS.items():
+ ctx.register_hook(name, callback)
diff --git a/agentpatch/hermes_plugin.yaml b/agentpatch/hermes_plugin.yaml
new file mode 100644
index 0000000..83951cc
--- /dev/null
+++ b/agentpatch/hermes_plugin.yaml
@@ -0,0 +1,18 @@
+name: casbin-gateway-agent-monitor
+version: "1.1.0"
+description: "Behaviour audit with redacted tool inputs for Casbin Gateway."
+author: Casbin
+kind: standalone
+# "provides_hooks" is the key Hermes' manifest parser reads; the callbacks
+# themselves are registered from register() in __init__.py.
+provides_hooks:
+ - pre_api_request
+ - post_api_request
+ - api_request_error
+ - pre_tool_call
+ - post_tool_call
+ - on_session_start
+ - on_session_end
+ - on_session_reset
+ - subagent_start
+ - subagent_stop
diff --git a/go.mod b/go.mod
index 0bc0062..b27972b 100644
--- a/go.mod
+++ b/go.mod
@@ -27,6 +27,7 @@ require (
golang.org/x/sys v0.17.0
golang.org/x/text v0.14.0
golang.org/x/time v0.3.0
+ gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.18.1
)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]