Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions cmd/fence/hooks_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func newHooksPrintCmd() *cobra.Command {
cursor bool
opencode bool
hermes bool
openclaw bool
hookOptions hookFenceOptions
)

Expand All @@ -41,7 +42,8 @@ Examples:
fence hooks print --claude --settings ./fence.json
fence hooks print --cursor --template code
fence hooks print --opencode
fence hooks print --hermes`,
fence hooks print --hermes
fence hooks print --openclaw`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
resolvedHookOptions, err := hookOptions.normalized()
Expand All @@ -61,8 +63,10 @@ Examples:
return writeOpencodeHooksConfig(cmd.OutOrStdout())
case hermes:
return writeHermesHooksConfig(cmd.OutOrStdout(), resolvedHookOptions)
case openclaw:
return writeOpenclawHooksGuidance(cmd.OutOrStdout(), resolvedHookOptions)
default:
return fmt.Errorf("no hook target specified. Use --claude, --cursor, --opencode, or --hermes")
return fmt.Errorf("no hook target specified. Use --claude, --cursor, --opencode, --hermes, or --openclaw")
}
},
}
Expand All @@ -71,8 +75,9 @@ Examples:
cmd.Flags().BoolVar(&cursor, "cursor", false, "Print Cursor hook config")
cmd.Flags().BoolVar(&opencode, "opencode", false, "Print OpenCode plugin config")
cmd.Flags().BoolVar(&hermes, "hermes", false, "Print Hermes shell-hook config (~/.hermes/config.yaml)")
cmd.Flags().BoolVar(&openclaw, "openclaw", false, "Print install instructions for the OpenClaw plugin (@use-tusk/openclaw-fence)")
addHookPolicyFlags(cmd, &hookOptions)
cmd.MarkFlagsMutuallyExclusive("claude", "cursor", "opencode", "hermes")
cmd.MarkFlagsMutuallyExclusive("claude", "cursor", "opencode", "hermes", "openclaw")
return cmd
}

Expand All @@ -82,6 +87,7 @@ func newHooksInstallCmd() *cobra.Command {
cursor bool
opencode bool
hermes bool
openclaw bool
path string
force bool
hookOptions hookFenceOptions
Expand All @@ -102,7 +108,11 @@ Examples:
fence hooks install --opencode --force # skip prompt
fence hooks install --hermes
fence hooks install --hermes --settings ./fence.json
fence hooks install --hermes --file ./project-hermes-config.yaml`,
fence hooks install --hermes --file ./project-hermes-config.yaml

Note: --openclaw is not supported because OpenClaw uses an imperative
plugin manager. Run 'openclaw plugins install @use-tusk/openclaw-fence'
instead. See 'fence hooks print --openclaw' for the full instructions.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
resolvedHookOptions, err := hookOptions.normalized()
Expand Down Expand Up @@ -213,8 +223,10 @@ Examples:
}
}
return nil
case openclaw:
return errOpenclawUseImperativeInstall
default:
return fmt.Errorf("no hook target specified. Use --claude, --cursor, --opencode, or --hermes")
return fmt.Errorf("no hook target specified. Use --claude, --cursor, --opencode, --hermes, or --openclaw")
}
},
}
Expand All @@ -223,10 +235,11 @@ Examples:
cmd.Flags().BoolVar(&cursor, "cursor", false, "Install Cursor hook config")
cmd.Flags().BoolVar(&opencode, "opencode", false, "Install OpenCode plugin config")
cmd.Flags().BoolVar(&hermes, "hermes", false, "Install Hermes shell-hook config")
cmd.Flags().BoolVar(&openclaw, "openclaw", false, "Not supported (run `openclaw plugins install @use-tusk/openclaw-fence` instead)")
cmd.Flags().StringVarP(&path, "file", "f", "", "Path to the settings file to modify (default: ~/.claude/settings.json for --claude, ~/.cursor/hooks.json for --cursor, existing ~/.config/opencode/opencode.{jsonc,json} for --opencode, ~/.hermes/config.yaml for --hermes)")
cmd.Flags().BoolVarP(&force, "force", "y", false, "Skip the confirmation prompt when comments would be stripped")
addHookPolicyFlags(cmd, &hookOptions)
cmd.MarkFlagsMutuallyExclusive("claude", "cursor", "opencode", "hermes")
cmd.MarkFlagsMutuallyExclusive("claude", "cursor", "opencode", "hermes", "openclaw")
return cmd
}

Expand All @@ -236,6 +249,7 @@ func newHooksUninstallCmd() *cobra.Command {
cursor bool
opencode bool
hermes bool
openclaw bool
path string
force bool
)
Expand All @@ -251,7 +265,10 @@ Examples:
fence hooks uninstall --cursor --file ./.cursor/hooks.json
fence hooks uninstall --opencode
fence hooks uninstall --opencode --force # skip prompt
fence hooks uninstall --hermes`,
fence hooks uninstall --hermes

Note: --openclaw is not supported. Run 'openclaw plugins uninstall
openclaw-fence' instead.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
switch {
Expand Down Expand Up @@ -349,8 +366,10 @@ Examples:
}
}
return nil
case openclaw:
return errOpenclawUseImperativeUninstall
default:
return fmt.Errorf("no hook target specified. Use --claude, --cursor, --opencode, or --hermes")
return fmt.Errorf("no hook target specified. Use --claude, --cursor, --opencode, --hermes, or --openclaw")
}
},
}
Expand All @@ -359,9 +378,10 @@ Examples:
cmd.Flags().BoolVar(&cursor, "cursor", false, "Remove Cursor hook config")
cmd.Flags().BoolVar(&opencode, "opencode", false, "Remove OpenCode plugin config")
cmd.Flags().BoolVar(&hermes, "hermes", false, "Remove Hermes shell-hook config")
cmd.Flags().BoolVar(&openclaw, "openclaw", false, "Not supported (run `openclaw plugins uninstall openclaw-fence` instead)")
cmd.Flags().StringVarP(&path, "file", "f", "", "Path to the settings file to modify (default: ~/.claude/settings.json for --claude, ~/.cursor/hooks.json for --cursor, existing ~/.config/opencode/opencode.{jsonc,json} for --opencode, ~/.hermes/config.yaml for --hermes)")
cmd.Flags().BoolVarP(&force, "force", "y", false, "Skip the confirmation prompt when comments would be stripped")
cmd.MarkFlagsMutuallyExclusive("claude", "cursor", "opencode", "hermes")
cmd.MarkFlagsMutuallyExclusive("claude", "cursor", "opencode", "hermes", "openclaw")
return cmd
}

Expand Down
144 changes: 144 additions & 0 deletions cmd/fence/hooks_openclaw.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package main

import (
"encoding/json"
"fmt"
"io"
"os"

"github.com/Use-Tusk/fence/internal/toolcall"
)

// openclawPreToolUseMode is invoked by the @use-tusk/openclaw-fence plugin.
// Wire protocol: JSON envelope on stdin, JSON response on stdout, empty
// stdout = allow.
const openclawPreToolUseMode = "--openclaw-pre-tool-use"

type openclawPreToolUseEvent struct {
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name"`
ToolInput map[string]any `json:"tool_input"`
CWD string `json:"cwd,omitempty"`
}

// openclawPreToolUseResponse is the deny shape the plugin reads. We only
// emit denies; allow is signalled by empty stdout.
type openclawPreToolUseResponse struct {
Decision string `json:"decision"`
Reason string `json:"reason,omitempty"`
}

func runOpenclawPreToolUseMode() error {
return runOpenclawPreToolUse(os.Stdin, os.Stdout, os.Args[2:])
}

func runOpenclawPreToolUse(stdin io.Reader, stdout io.Writer, extraFenceArgs []string) error {
response, changed, err := buildOpenclawPreToolUseResponse(stdin, extraFenceArgs)
if err != nil {
return err
}
if !changed {
return nil
}
_, err = fmt.Fprintln(stdout, string(response))
return err
}

// buildOpenclawPreToolUseResponse decodes the envelope, evaluates against
// the dispatch table, and emits a deny on block. changed=false on allow /
// skip / non-before_tool_call event.
func buildOpenclawPreToolUseResponse(stdin io.Reader, extraFenceArgs []string) ([]byte, bool, error) {
var event openclawPreToolUseEvent
decoder := json.NewDecoder(stdin)
decoder.UseNumber()
if err := decoder.Decode(&event); err != nil {
return nil, false, fmt.Errorf("failed to decode OpenClaw hook JSON: %w", err)
}

if event.HookEventName != "" && event.HookEventName != "before_tool_call" {
return nil, false, nil
}

hookOptions, err := parseHookFenceOptionsArgs(extraFenceArgs)
if err != nil {
return nil, false, err
}

cwd := extractHookCommandCWD(event.ToolInput, event.CWD)
activeConfig, err := loadActiveConfigAudit(cwd, hookOptions.SettingsPath, hookOptions.TemplateName)
if err != nil {
return nil, false, err
}

evaluator := &toolcall.Evaluator{
Table: openclawDispatchTable,
Config: activeConfig.Config,
}

decision := evaluator.Evaluate(toolcall.ToolCall{
ToolName: event.ToolName,
Params: event.ToolInput,
CWD: cwd,
})

if decision.Outcome != toolcall.OutcomeDeny {
return nil, false, nil
}

response := openclawPreToolUseResponse{
Decision: "deny",
Reason: openclawDenyMessage(event.ToolName, decision),
}
data, err := json.Marshal(response)
if err != nil {
return nil, false, fmt.Errorf("failed to encode OpenClaw hook response: %w", err)
}
return data, true, nil
}

// openclawDenyMessage formats a deny reason. The plugin echoes this into
// the agent's tool-result stream so the LLM can recover with a different call.
func openclawDenyMessage(toolName string, decision toolcall.Decision) string {
if decision.Reason != "" {
return fmt.Sprintf("blocked by Fence policy (%s): %s", toolName, decision.Reason)
}
if decision.MatchedRule != "" {
return fmt.Sprintf("blocked by Fence policy (%s): %s matches %q", toolName, decision.Domain, decision.MatchedRule)
}
return fmt.Sprintf("blocked by Fence policy (%s)", toolName)
}

// openclawDispatchTable maps OpenClaw tool names (from
// src/agents/tool-catalog.ts) to their policy domain. Keep curated: only
// tools whose primary risk maps to one of Fence's existing config domains.
// Tools needing their own policy vocabulary (channel sends, MCP, subagent
// spawning, image/media generation) wait for that vocabulary to land.
//
// "bash" is here because external agents may emit it before OpenClaw's
// normalizeToolName collapses it to "exec".
var openclawDispatchTable = toolcall.Table{
"exec": {
Domain: toolcall.DomainCommand,
Extract: toolcall.StringExtractor("command"),
},
"bash": {
Domain: toolcall.DomainCommand,
Extract: toolcall.StringExtractor("command"),
},
"write": {
Domain: toolcall.DomainFilesystemWrite,
Extract: toolcall.StringExtractor("path"),
},
"edit": {
Domain: toolcall.DomainFilesystemWrite,
Extract: toolcall.StringExtractor("path"),
},
"apply_patch": {
Domain: toolcall.DomainFilesystemWrite,
Extract: toolcall.StringExtractor("path"),
},
"web_fetch": {
Domain: toolcall.DomainNetworkURL,
Extract: toolcall.StringExtractor("url"),
},
}
59 changes: 59 additions & 0 deletions cmd/fence/hooks_openclaw_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"errors"
"fmt"
"io"
"strings"

"github.com/Use-Tusk/fence/internal/sandbox"
)

// openclawPluginPackageName is the published plugin; shared between tests
// and the print helper.
const openclawPluginPackageName = "@use-tusk/openclaw-fence"

// OpenClaw's plugin manager is imperative, so Fence editing the config
// file would only be half the work - the package still has to be fetched.
// We delegate to `openclaw plugins install` instead of coupling Fence to
// OpenClaw's CLI.
var (
errOpenclawUseImperativeInstall = errors.New(
"`fence hooks install --openclaw` is not supported because OpenClaw uses an imperative plugin manager; " +
"run `openclaw plugins install " + openclawPluginPackageName + "` instead, then restart the gateway " +
"(see `fence hooks print --openclaw` for the full instructions)",
)
errOpenclawUseImperativeUninstall = errors.New(
"`fence hooks uninstall --openclaw` is not supported; " +
"run `openclaw plugins uninstall openclaw-fence` instead",
)
)

// writeOpenclawHooksGuidance prints the install one-liner, plus optional
// settings/template advice. Print-only by design - the install itself
// stays on the OpenClaw side.
func writeOpenclawHooksGuidance(w io.Writer, hookOptions hookFenceOptions) error {
var b strings.Builder
b.WriteString("# OpenClaw uses an imperative plugin manager.\n")
b.WriteString("# Run:\n")
fmt.Fprintf(&b, "openclaw plugins install %s\n", openclawPluginPackageName)
b.WriteString("openclaw gateway restart\n")

if hookOptions.SettingsPath != "" || hookOptions.TemplateName != "" {
b.WriteString("\n")
b.WriteString("# To pin a Fence config or template, set plugin options after install:\n")
b.WriteString("# (in your OpenClaw config under plugins.entries.openclaw-fence.config)\n")
if hookOptions.SettingsPath != "" {
fmt.Fprintf(&b, "# settingsPath: %s\n", sandbox.ShellQuote([]string{hookOptions.SettingsPath}))
}
if hookOptions.TemplateName != "" {
fmt.Fprintf(&b, "# template: %s\n", hookOptions.TemplateName)
}
} else {
b.WriteString("\n")
b.WriteString("# Recommended: also pin the bundled `openclaw` template:\n")
b.WriteString("# plugins.entries.openclaw-fence.config.template: openclaw\n")
}
_, err := io.WriteString(w, b.String())
return err
}
Loading