diff --git a/cmd/fence/hooks_cmd.go b/cmd/fence/hooks_cmd.go index d04ebc4..eb30b5a 100644 --- a/cmd/fence/hooks_cmd.go +++ b/cmd/fence/hooks_cmd.go @@ -28,6 +28,7 @@ func newHooksPrintCmd() *cobra.Command { cursor bool opencode bool hermes bool + openclaw bool hookOptions hookFenceOptions ) @@ -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() @@ -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") } }, } @@ -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 } @@ -82,6 +87,7 @@ func newHooksInstallCmd() *cobra.Command { cursor bool opencode bool hermes bool + openclaw bool path string force bool hookOptions hookFenceOptions @@ -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() @@ -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") } }, } @@ -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 } @@ -236,6 +249,7 @@ func newHooksUninstallCmd() *cobra.Command { cursor bool opencode bool hermes bool + openclaw bool path string force bool ) @@ -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 { @@ -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") } }, } @@ -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 } diff --git a/cmd/fence/hooks_openclaw.go b/cmd/fence/hooks_openclaw.go new file mode 100644 index 0000000..90ab203 --- /dev/null +++ b/cmd/fence/hooks_openclaw.go @@ -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"), + }, +} diff --git a/cmd/fence/hooks_openclaw_install.go b/cmd/fence/hooks_openclaw_install.go new file mode 100644 index 0000000..ab39fd0 --- /dev/null +++ b/cmd/fence/hooks_openclaw_install.go @@ -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 +} diff --git a/cmd/fence/hooks_openclaw_test.go b/cmd/fence/hooks_openclaw_test.go new file mode 100644 index 0000000..4797760 --- /dev/null +++ b/cmd/fence/hooks_openclaw_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeOpenclawFenceConfig(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "fence.json") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} + +func TestBuildOpenclawPreToolUseResponse_AllowsExecNotInDeny(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{ + "command": {"deny": ["git push"], "useDefaults": false} +}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "exec", + "tool_input": {"command": "git status"} + }` + resp, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if changed { + t.Fatalf("expected allow (changed=false), got body=%s", string(resp)) + } +} + +func TestBuildOpenclawPreToolUseResponse_BlocksDeniedExec(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{ + "command": {"deny": ["git push"], "useDefaults": false} +}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "exec", + "tool_input": {"command": "git push origin main"} + }` + resp, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if !changed { + t.Fatal("expected block") + } + var decoded openclawPreToolUseResponse + if err := json.Unmarshal(resp, &decoded); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if decoded.Decision != "deny" { + t.Fatalf("expected decision=deny, got %q", decoded.Decision) + } + if !strings.Contains(decoded.Reason, "exec") { + t.Errorf("expected reason to mention tool name, got %q", decoded.Reason) + } + if !strings.Contains(decoded.Reason, "git push") { + t.Errorf("expected reason to surface matched rule, got %q", decoded.Reason) + } +} + +func TestBuildOpenclawPreToolUseResponse_BashAliasesToExec(t *testing.T) { + // External agents (Claude harness, Codex) may emit "bash" before + // OpenClaw's normalizeToolName collapses it to "exec". Both names + // should produce the same evaluation. + settings := writeOpenclawFenceConfig(t, `{ + "command": {"deny": ["git push"], "useDefaults": false} +}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "bash", + "tool_input": {"command": "git push origin main"} + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if !changed { + t.Fatal("expected bash to be treated as exec and blocked") + } +} + +func TestBuildOpenclawPreToolUseResponse_BlocksDangerousWrite(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{ + "filesystem": {"allowWrite": ["/"]} +}`) + for _, tool := range []string{"write", "edit", "apply_patch"} { + t.Run(tool, func(t *testing.T) { + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "` + tool + `", + "tool_input": {"path": "/home/user/.zshrc"} + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if !changed { + t.Fatalf("expected dangerous write via %s to be blocked", tool) + } + }) + } +} + +func TestBuildOpenclawPreToolUseResponse_AllowsWriteUnderAllowList(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{ + "filesystem": {"allowWrite": ["/workspace"]} +}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "apply_patch", + "tool_input": {"path": "/workspace/proj/file.go"} + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if changed { + t.Fatal("expected allow") + } +} + +func TestBuildOpenclawPreToolUseResponse_BlocksWebFetchToBlockedHost(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{ + "network": {"allowedDomains": ["api.openai.com"]} +}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "web_fetch", + "tool_input": {"url": "https://blocked.test/page"} + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if !changed { + t.Fatal("expected URL outside allowedDomains to be blocked") + } +} + +func TestBuildOpenclawPreToolUseResponse_AllowsWebFetchToAllowedHost(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{ + "network": {"allowedDomains": ["*.openai.com"]} +}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "web_fetch", + "tool_input": {"url": "https://api.openai.com/v1/x"} + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if changed { + t.Fatal("expected allowed host to pass") + } +} + +func TestBuildOpenclawPreToolUseResponse_UnknownToolSkips(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "memory_search", + "tool_input": {"query": "anything"} + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if changed { + t.Fatal("expected unmapped tool to be a no-op") + } +} + +func TestBuildOpenclawPreToolUseResponse_NonBeforeToolCallEventNoOp(t *testing.T) { + // Defensive: if a future plugin version wires this binary to a + // different hook event, we should be a no-op rather than parse + // error. + settings := writeOpenclawFenceConfig(t, `{ + "command": {"deny": ["git push"], "useDefaults": false} +}`) + input := `{ + "hook_event_name": "after_tool_call", + "tool_name": "exec", + "tool_input": {"command": "git push origin main"} + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if changed { + t.Fatal("expected non-before_tool_call event to be a no-op") + } +} + +func TestBuildOpenclawPreToolUseResponse_RelativeWritePathUsesEnvelopeCWD(t *testing.T) { + settings := writeOpenclawFenceConfig(t, `{ + "filesystem": {"allowWrite": ["/workspace"]} +}`) + input := `{ + "hook_event_name": "before_tool_call", + "tool_name": "write", + "tool_input": {"path": "main.go"}, + "cwd": "/workspace/proj" + }` + _, changed, err := buildOpenclawPreToolUseResponse(strings.NewReader(input), []string{"--settings", settings}) + if err != nil { + t.Fatalf("buildOpenclawPreToolUseResponse: %v", err) + } + if changed { + t.Fatal("expected relative path resolved against envelope cwd to allow") + } +} + +func TestWriteOpenclawHooksGuidance_DefaultMessage(t *testing.T) { + var buf strings.Builder + if err := writeOpenclawHooksGuidance(&buf, hookFenceOptions{}); err != nil { + t.Fatalf("writeOpenclawHooksGuidance: %v", err) + } + out := buf.String() + if !strings.Contains(out, "openclaw plugins install @use-tusk/openclaw-fence") { + t.Errorf("expected the canonical install one-liner, got:\n%s", out) + } + if !strings.Contains(out, "template: openclaw") { + t.Errorf("expected the template recommendation, got:\n%s", out) + } +} + +func TestWriteOpenclawHooksGuidance_PolicyPin(t *testing.T) { + var buf strings.Builder + if err := writeOpenclawHooksGuidance(&buf, hookFenceOptions{TemplateName: "openclaw"}); err != nil { + t.Fatalf("writeOpenclawHooksGuidance: %v", err) + } + out := buf.String() + if !strings.Contains(out, "template: openclaw") { + t.Errorf("expected --template to surface in plugin-options hint, got:\n%s", out) + } + if !strings.Contains(out, "openclaw plugins install") { + t.Errorf("install one-liner missing, got:\n%s", out) + } +} diff --git a/cmd/fence/main.go b/cmd/fence/main.go index dec22a6..7646de7 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -108,6 +108,13 @@ func main() { } return } + if len(os.Args) >= 2 && os.Args[1] == openclawPreToolUseMode { + if err := runOpenclawPreToolUseMode(); err != nil { + fencelog.Printf("[fence:hooks] %v\n", err) + os.Exit(2) + } + return + } rootCmd := &cobra.Command{ Use: "fence [flags] -- [command...]", diff --git a/docs/agents.md b/docs/agents.md index a6ee36c..c269cf1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -213,6 +213,69 @@ traffic at the proxy layer; the two modes compose. > [Shell Hooks docs](https://docs.hermes-agent.com/docs/user-guide/features/hooks) > for the full consent model. +### OpenClaw + +OpenClaw uses an imperative plugin manager rather than a declarative +config-array, so the Fence integration ships as the +[`@use-tusk/openclaw-fence`](https://github.com/Use-Tusk/openclaw-fence) +npm package and is installed via OpenClaw's own command: + +```bash +openclaw plugins install @use-tusk/openclaw-fence +openclaw gateway restart +``` + +`fence hooks install --openclaw` is **not** supported — Fence editing the +config file would only be half the work. To see the install one-liner +plus optional plugin-options hints, run: + +```bash +fence hooks print --openclaw +fence hooks print --openclaw --template openclaw # surface the template hint +``` + +The plugin registers a `before_tool_call` handler and forwards a curated +set of OpenClaw tool calls through `fence --openclaw-pre-tool-use`: + +| OpenClaw tool | Fence policy domain | Reads | +|---|---|---| +| `exec` (alias `bash`) | `command.deny` / `command.allow` | `params.command` | +| `write`, `edit`, `apply_patch` | `filesystem.allowWrite` / `denyWrite` (+ dangerous-files protection) | `params.path` | +| `web_fetch` | `network.allowedDomains` / `deniedDomains` | `params.url` | + +Tools not in this table — channel sends, MCP, subagent spawning, image/media +generation, sessions, gateway, and so on — are passed through unmodified at +the hook layer. Wrap mode (`fence -t openclaw -- openclaw gateway run`) +covers their network traffic at the proxy layer. + +#### Recommended config + +The bundled `openclaw` template extends `code` with channel/provider +domains and writable `~/.openclaw/**`. Pin it via the plugin's options +in your OpenClaw config: + +```jsonc +{ + "plugins": { + "entries": { + "openclaw-fence": { + "enabled": true, + "config": { + "template": "openclaw" + } + } + } + } +} +``` + +> [!NOTE] +> **Hook mode is intent-only, not traffic-enforced.** Fence sees what +> the agent declared it wants to do (which command, path, or URL) and +> decides against your config; it doesn't sit in the syscall or HTTP +> path. Run `fence -t openclaw -- openclaw gateway run` for traffic-time +> enforcement; the two compose. + If your coding agent has a hook or plugin system you'd like Fence to support, feel free to open an issue or pull request. ## Protecting your environment diff --git a/internal/templates/openclaw.json b/internal/templates/openclaw.json new file mode 100644 index 0000000..04d078c --- /dev/null +++ b/internal/templates/openclaw.json @@ -0,0 +1,84 @@ +{ + // Fence config for OpenClaw (https://github.com/openclaw/openclaw). + // Extends the `code` template; refine as OpenClaw's tool surface evolves. + // + // network.allowedDomains covers two surfaces: + // 1. The OpenClaw gateway process itself in wrap mode + // (`fence -t openclaw -- openclaw gateway run`) — channel polling, + // provider APIs, ClawHub. Enforced by the proxy. + // 2. The agent's web_fetch tool in hook mode + // (`@use-tusk/openclaw-fence` plugin) — search APIs, docs, news. + // Hook mode does not gate OpenClaw's channel-send / MCP / subagent + // tools today, so the messaging-platform hosts below are wrap-mode + // only. They're harmless to keep in hook-mode users' configs. + "extends": "code", + "network": { + "allowedDomains": [ + // LLM providers not already in `code` + "api.deepseek.com", + "api.x.ai", + "api.fireworks.ai", + "api.groq.com", + "api.cerebras.ai", + "api.huggingface.co", + "api.together.ai", + "api.minimax.chat", + "api.moonshot.cn", + "api.lingyiwanwu.com", // Yi + "ark.cn-beijing.volces.com", // Volcengine + "qianfan.baidubce.com", // Qianfan + + // Messaging platforms (wrap-mode only, see header) + "api.telegram.org", + "discord.com", + "*.discord.com", + "discordapp.com", + "slack.com", + "*.slack.com", + "graph.facebook.com", + "graph.whatsapp.com", + "*.matrix.org", + "matrix.org", + "graph.microsoft.com", + "login.microsoftonline.com", + "open.feishu.cn", + "open.larksuite.com", + "openapi.zalo.me", + "*.signal.org", + "*.bsky.app", + "relay.damus.io", + + // OpenClaw services + "*.clawhub.dev", + "clawhub.dev", + "openclaw.ai", + "*.openclaw.ai", + + // Search / extraction backends (web_fetch / web_search providers) + "api.tavily.com", + "api.firecrawl.dev", + "api.serper.dev", + "api.exa.ai", + "*.duckduckgo.com", + + // Speech / voice / media providers + "api.elevenlabs.io", + "api.deepgram.com", + "*.cognitiveservices.azure.com", + "api.replicate.com", + "fal.run", + "*.fal.ai", + + // Memory / RAG providers (extensions/memory-*) + "api.voyageai.com" + ] + }, + + "filesystem": { + "allowWrite": [ + // OpenClaw home: sessions, plugin state, ClawHub install staging, + // credentials, gateway logs. + "~/.openclaw/**" + ] + } +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 28b4ac5..6f49a3c 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -35,6 +35,7 @@ var templateDescriptions = map[string]string{ "code-relaxed": "Like 'code' but allows direct network for apps that ignore HTTP_PROXY (cursor-agent, opencode)", "code-strict": "Like 'code' but denies reads by default; only allows reading the current project directory and essential system paths", "hermes": "Extends 'code' with messaging-platform domains and ~/.hermes writes for Hermes Agent (gateway + CLI)", + "openclaw": "Extends 'code' with messaging-platform domains and ~/.openclaw writes for OpenClaw (gateway + agents)", } // List returns all available template names sorted alphabetically.