blob: 10078764f5dc48ce13ff236844de317f944cfd08 [file]
// 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"
"gopkg.in/yaml.v3"
"github.com/apache/casbin-gateway/agenthome"
"github.com/apache/casbin-gateway/agentmonitor"
"github.com/apache/casbin-gateway/internal/hermes"
)
const (
hermesPluginName = gatewayEntryName
hermesConfigSchema = 1
minHermesAgentVersion = "0.19.0"
)
//go:embed hermes_observer.py
var hermesObserver []byte
//go:embed hermes_plugin.yaml
var hermesPluginManifest []byte
// hermesPluginVersion comes from the manifest so the two never drift apart.
var hermesPluginVersion = hermesManifestVersion()
func hermesManifestVersion() string {
var manifest struct {
Version string `yaml:"version"`
}
if err := yaml.Unmarshal(hermesPluginManifest, &manifest); err != nil {
return ""
}
return manifest.Version
}
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"`
DecisionURL string `json:"decisionUrl"`
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
}
if info, statErr := os.Stat(layout.pluginDir); statErr == nil {
if !info.IsDir() {
return fmt.Errorf("%s exists and is not a directory", layout.pluginDir)
}
// What the directory holds decides this, not whether this Gateway's own
// state remembers writing it. A reinstall or a move to another data
// directory leaves a previous Gateway's observer behind, and refusing
// that one left Hermes unmonitorable with nothing to do about it.
if hermesPluginForeign(layout.pluginDir) {
return fmt.Errorf("%s holds an observer that is not Gateway's; remove that directory to let Gateway install its own", 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: Unpatch deletes the one
// entry Gateway added rather than restore a snapshot of the user's file.
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, so a home directory that no longer
// resolves still gets its plugin removed.
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.
// Decides: the plugin's pre_tool_call callback returns a block directive, which
// Hermes honours by refusing the call and handing the message to the model.
func (hermesPatcher) Decides() bool { return true }
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, and with it the check before each tool call. 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. It also refuses a tool call this agent's permissions do not allow; an agent nobody has restricted is never held up. 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
}
// A hook Hermes does not know simply never fires, so the version gate is
// the only compatibility check the observer needs.
if !hermes.VersionAtLeast(project.Version, minHermesAgentVersion) {
return hermesLayout{}, fmt.Errorf(
"Hermes Agent %s is incompatible; upgrade to %s or later",
project.Version, minHermesAgentVersion,
)
}
return layout, nil
}
func (hermesPatcher) layoutOf(target Target) (hermesLayout, error) {
home, err := agenthome.Resolve(target.Owner)
if err != nil {
return hermesLayout{}, err
}
hermesHome := hermes.Home(home)
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
}
decision, err := decisionURL()
if err != nil {
return nil, err
}
data, err := json.MarshalIndent(hermesObserverConfig{
SchemaVersion: hermesConfigSchema,
Owner: "casbin-gateway",
PluginVersion: hermesPluginVersion,
RecordsURL: url,
DecisionURL: decision,
AgentPath: target.Path,
User: target.Owner,
IngestToken: token,
IngestTokenHeader: agentmonitor.IngestTokenHeader,
}, "", " ")
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
// hermesPluginForeign reports a plugin directory holding somebody else's
// gateway.json. A missing, unreadable or stale-schema file is not evidence of
// that, so a refresh can still repair it.
func hermesPluginForeign(pluginDir string) bool {
data, err := os.ReadFile(filepath.Join(pluginDir, "gateway.json"))
if err != nil {
return false
}
var config hermesObserverConfig
if json.Unmarshal(data, &config) != nil {
return false
}
return config.Owner != "casbin-gateway"
}
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
}