blob: 85fa2bd0be5270f6aa7b312e21b6070d2caec75d [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 (
_ "embed"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/apache/casbin-gateway/agenthome"
"github.com/apache/casbin-gateway/agentmonitor"
)
const (
openclawHookName = "casbin-gateway-agent-monitor"
openclawStateDir = ".openclaw"
openclawConfigFile = "openclaw.json"
)
//go:embed openclaw_hook.js
var openclawHookHandler string
//go:embed openclaw_hook.md
var openclawHookManifest string
type openclawPatcher struct{}
func init() {
register(openclawPatcher{})
}
func (openclawPatcher) AgentId() string { return "openclaw" }
func (openclawPatcher) Supported() bool { return true }
func (p openclawPatcher) Patch(target Target) error {
layout, err := p.layoutOf(target)
if err != nil {
return err
}
token, err := IssueIngestToken(target)
if err != nil {
return err
}
handler, err := renderOpenclawHandler(target, token)
if err != nil {
return err
}
return Apply(target, func(changes *ChangeSet) error {
if err := changes.MkdirAll(layout.hookDir); err != nil {
return err
}
if err := changes.WriteFile(filepath.Join(layout.hookDir, "HOOK.md"), []byte(openclawHookManifest), 0o644); err != nil {
return err
}
if err := changes.WriteFile(filepath.Join(layout.hookDir, "handler.js"), []byte(handler), 0o644); err != nil {
return err
}
return enableOpenclawHook(changes, layout.configPath)
})
}
func (openclawPatcher) Unpatch(target Target) error {
if err := Revert(target); err != nil {
return err
}
return RevokeIngestToken(target)
}
func (p openclawPatcher) Status(target Target) (Status, error) {
layout, err := p.layoutOf(target)
if err != nil {
return Status{}, err
}
if _, err := os.Stat(filepath.Join(layout.hookDir, "handler.js")); err != nil {
if os.IsNotExist(err) {
return Status{Detail: "OpenClaw hook is not installed"}, nil
}
return Status{}, err
}
enabled, err := openclawHookEnabled(layout.configPath)
if err != nil {
return Status{}, err
}
if !enabled {
return Status{Detail: "OpenClaw hook is installed but disabled"}, nil
}
if !IsApplied(target) {
return Status{Patched: true, Detail: "OpenClaw hook was installed outside Gateway"}, nil
}
return Status{Patched: true, Detail: "OpenClaw hook active after gateway restart"}, nil
}
func (openclawPatcher) PatchNotice(patched bool) (string, string) {
if patched {
return "Removes Gateway's audit-only OpenClaw hook.", "Restart the OpenClaw gateway after removing the hook."
}
return "Installs an audit-only OpenClaw hook.", "Restart the OpenClaw gateway to load the hook."
}
type openclawLayout struct {
hookDir string
configPath string
}
func (p openclawPatcher) layoutOf(target Target) (openclawLayout, error) {
home, err := agenthome.Resolve(target.Owner)
if err != nil {
return openclawLayout{}, err
}
stateDir := filepath.Join(home, openclawStateDir)
return openclawLayout{
hookDir: filepath.Join(stateDir, "hooks", openclawHookName),
configPath: filepath.Join(stateDir, openclawConfigFile),
}, nil
}
func renderOpenclawHandler(target Target, ingestToken string) (string, error) {
url, err := recordsURL()
if err != nil {
return "", err
}
replacements := map[string]string{
"__CASBIN_GATEWAY_RECORDS_URL__": jsonString(url),
"__CASBIN_GATEWAY_AGENT_PATH__": jsonString(target.Path),
"__CASBIN_GATEWAY_OWNER__": jsonString(target.Owner),
"__CASBIN_GATEWAY_INGEST_TOKEN__": jsonString(ingestToken),
"__CASBIN_GATEWAY_INGEST_TOKEN_HEADER__": jsonString(agentmonitor.IngestTokenHeader),
}
handler := openclawHookHandler
for placeholder, value := range replacements {
handler = strings.ReplaceAll(handler, placeholder, value)
}
return handler, nil
}
func enableOpenclawHook(changes *ChangeSet, configPath string) error {
if err := changes.MkdirAll(filepath.Dir(configPath)); err != nil {
return err
}
config, err := readJSONObject(changes, configPath)
if err != nil {
return err
}
hooks, err := ensureObject(config, "hooks")
if err != nil {
return err
}
internal, err := ensureObject(hooks, "internal")
if err != nil {
return err
}
entries, err := ensureObject(internal, "entries")
if err != nil {
return err
}
entry, err := ensureObject(entries, openclawHookName)
if err != nil {
return err
}
internal["enabled"] = true
entry["enabled"] = true
updated, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return changes.WriteFile(configPath, append(updated, '\n'), 0o600)
}
func openclawHookEnabled(configPath string) (bool, error) {
config, _, exists, err := readJSONConfig(configPath)
if err != nil || !exists {
return false, err
}
hooks, ok := objectAt(config["hooks"])
if !ok {
return false, nil
}
internal, ok := objectAt(hooks["internal"])
if !ok || internal["enabled"] != true {
return false, nil
}
entries, ok := objectAt(internal["entries"])
if !ok {
return false, nil
}
entry, ok := objectAt(entries[openclawHookName])
return ok && entry["enabled"] == true, nil
}
func ensureObject(parent map[string]any, key string) (map[string]any, error) {
if value, exists := parent[key]; exists {
object, ok := objectAt(value)
if !ok {
return nil, fmt.Errorf("%s must be a JSON object", key)
}
return object, nil
}
object := map[string]any{}
parent[key] = object
return object, nil
}
func jsonString(value string) string {
encoded, _ := json.Marshal(value)
return string(encoded)
}