diff --git a/cmd/fence/hooks_claude.go b/cmd/fence/hooks_claude.go new file mode 100644 index 0000000..1525573 --- /dev/null +++ b/cmd/fence/hooks_claude.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +const claudePreToolUseMode = "--claude-pre-tool-use" + +type claudePreToolUseEvent struct { + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput map[string]any `json:"tool_input"` + CWD string `json:"cwd,omitempty"` +} + +type claudePreToolUseResponse struct { + HookSpecificOutput *claudePreToolUseHookSpecificOutput `json:"hookSpecificOutput,omitempty"` +} + +type claudePreToolUseHookSpecificOutput struct { + HookEventName string `json:"hookEventName"` + PermissionDecision string `json:"permissionDecision"` + UpdatedInput map[string]any `json:"updatedInput,omitempty"` +} + +func runClaudePreToolUseMode() error { + return runClaudePreToolUse(os.Stdin, os.Stdout, resolveFenceExecutable(), os.Args[2:]) +} + +func runClaudePreToolUse(stdin io.Reader, stdout io.Writer, fenceExePath string, extraFenceArgs []string) error { + response, changed, err := buildCompatiblePreToolUseResponse(stdin, fenceExePath, extraFenceArgs) + if err != nil { + return err + } + if !changed { + return nil + } + + _, err = fmt.Fprintln(stdout, string(response)) + return err +} + +func buildClaudePreToolUseResponse(stdin io.Reader, fenceExePath string, extraFenceArgs []string) ([]byte, bool, error) { + var event claudePreToolUseEvent + decoder := json.NewDecoder(stdin) + decoder.UseNumber() + if err := decoder.Decode(&event); err != nil { + return nil, false, fmt.Errorf("failed to decode Claude hook JSON: %w", err) + } + + if event.HookEventName != "PreToolUse" || event.ToolName != "Bash" { + return nil, false, nil + } + + command, ok := event.ToolInput["command"].(string) + if !ok { + return nil, false, fmt.Errorf("Bash tool_input.command missing or not a string") + } + result, changed, err := evaluateShellHookRequest(shellHookRequest{ + Command: command, + CWD: extractHookCommandCWD(event.ToolInput, event.CWD), + ToolInput: event.ToolInput, + }, fenceExePath, extraFenceArgs) + if err != nil { + return nil, false, err + } + if !changed { + return nil, false, nil + } + response := claudePreToolUseResponse{HookSpecificOutput: &claudePreToolUseHookSpecificOutput{ + HookEventName: "PreToolUse", + }} + switch result.Decision { + case hookShellDeny: + response.HookSpecificOutput.PermissionDecision = "deny" + case hookShellWrap: + response.HookSpecificOutput.PermissionDecision = "allow" + response.HookSpecificOutput.UpdatedInput = result.UpdatedInput + default: + return nil, false, nil + } + + data, err := json.Marshal(response) + if err != nil { + return nil, false, fmt.Errorf("failed to encode Claude hook response: %w", err) + } + return data, true, nil +} diff --git a/cmd/fence/hooks_claude_test.go b/cmd/fence/hooks_claude_test.go new file mode 100644 index 0000000..ef73ade --- /dev/null +++ b/cmd/fence/hooks_claude_test.go @@ -0,0 +1,307 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Use-Tusk/fence/internal/sandbox" +) + +func TestBuildClaudePreToolUseResponse_WrapsBashCommand(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "") + + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test", + "description": "Run tests", + "timeout": 120000, + "run_in_background": true + } + }` + + response, changed, err := buildClaudePreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if !changed { + t.Fatal("expected Bash command to be rewritten") + } + + var decoded claudePreToolUseResponse + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.HookSpecificOutput == nil { + t.Fatal("expected hookSpecificOutput in response") + } + if decoded.HookSpecificOutput.PermissionDecision != "allow" { + t.Fatalf("expected permissionDecision allow, got %q", decoded.HookSpecificOutput.PermissionDecision) + } + + wantCommand := sandbox.ShellQuote([]string{"/usr/local/bin/fence", "-c", "npm test"}) + if got := decoded.HookSpecificOutput.UpdatedInput["command"]; got != wantCommand { + t.Fatalf("expected wrapped command %q, got %#v", wantCommand, got) + } + if got := decoded.HookSpecificOutput.UpdatedInput["description"]; got != "Run tests" { + t.Fatalf("expected description to be preserved, got %#v", got) + } + if got := decoded.HookSpecificOutput.UpdatedInput["run_in_background"]; got != true { + t.Fatalf("expected run_in_background to be preserved, got %#v", got) + } + if got := decoded.HookSpecificOutput.UpdatedInput["timeout"]; got != float64(120000) { + t.Fatalf("expected timeout to be preserved, got %#v", got) + } +} + +func TestBuildClaudePreToolUseResponse_SkipsPureCD(t *testing.T) { + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "cd ../repo" + } + }` + + _, changed, err := buildClaudePreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected pure cd command to be skipped") + } +} + +func TestBuildClaudePreToolUseResponse_SkipsAlreadyFencedCommand(t *testing.T) { + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "/usr/local/bin/fence -c 'npm test'" + } + }` + + _, changed, err := buildClaudePreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected already-fenced command to be skipped") + } +} + +func TestBuildClaudePreToolUseResponse_IgnoresNonBashEvent(t *testing.T) { + input := `{ + "hook_event_name": "PostToolUse", + "tool_name": "Read", + "tool_input": { + "file_path": "/tmp/test.txt" + } + }` + + _, changed, err := buildClaudePreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected non-Bash event to be ignored") + } +} + +func TestBuildClaudePreToolUseResponse_InvalidJSON(t *testing.T) { + _, _, err := buildClaudePreToolUseResponse(strings.NewReader(`{`), "/usr/local/bin/fence", nil) + if err == nil { + t.Fatal("expected invalid JSON to return an error") + } +} + +func TestBuildClaudePreToolUseResponse_LeavesCommandUnchangedInsideFence(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "1") + + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test" + } + }` + + _, changed, err := buildClaudePreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected command to stay unchanged when already inside Fence") + } +} + +func TestBuildClaudePreToolUseResponse_UsesPinnedSettings(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "") + + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test" + } + }` + + response, changed, err := buildClaudePreToolUseResponse( + strings.NewReader(input), + "/usr/local/bin/fence", + []string{"--settings", "/tmp/fence policy.json"}, + ) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if !changed { + t.Fatal("expected Bash command to be rewritten") + } + + var decoded claudePreToolUseResponse + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + wantCommand := sandbox.ShellQuote([]string{"/usr/local/bin/fence", "--settings", "/tmp/fence policy.json", "-c", "npm test"}) + if got := decoded.HookSpecificOutput.UpdatedInput["command"]; got != wantCommand { + t.Fatalf("expected wrapped command %q, got %#v", wantCommand, got) + } +} + +func TestBuildClaudePreToolUseResponse_DeniesBlockedCommandInsideFence(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "1") + + settingsPath := filepath.Join(t.TempDir(), "fence.json") + content := `{ + "command": { + "deny": ["npm test"], + "useDefaults": false + } +}` + if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test" + } + }` + + response, changed, err := buildClaudePreToolUseResponse( + strings.NewReader(input), + "/usr/local/bin/fence", + []string{"--settings", settingsPath}, + ) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if !changed { + t.Fatal("expected blocked command to produce a deny response") + } + + var decoded claudePreToolUseResponse + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.HookSpecificOutput == nil { + t.Fatal("expected hookSpecificOutput in response") + } + if got := decoded.HookSpecificOutput.PermissionDecision; got != "deny" { + t.Fatalf("expected permissionDecision deny, got %q", got) + } + if decoded.HookSpecificOutput.UpdatedInput != nil { + t.Fatalf("expected deny response to omit updatedInput, got %#v", decoded.HookSpecificOutput.UpdatedInput) + } +} + +func TestBuildClaudePreToolUseResponse_UsesPayloadCWDForOuterDeny(t *testing.T) { + repoDir := t.TempDir() + settingsPath := filepath.Join(repoDir, "fence.json") + content := `{ + "command": { + "deny": ["ls"], + "useDefaults": false + } +}` + if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "pwd && ls", + "cwd": "` + repoDir + `" + }, + "cwd": "` + repoDir + `" + }` + + response, changed, err := buildClaudePreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildClaudePreToolUseResponse() error = %v", err) + } + if !changed { + t.Fatal("expected blocked command to produce a deny response") + } + + var decoded claudePreToolUseResponse + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.HookSpecificOutput == nil { + t.Fatal("expected hookSpecificOutput in response") + } + if got := decoded.HookSpecificOutput.PermissionDecision; got != "deny" { + t.Fatalf("expected permissionDecision deny, got %q", got) + } + if decoded.HookSpecificOutput.UpdatedInput != nil { + t.Fatalf("expected deny response to omit updatedInput, got %#v", decoded.HookSpecificOutput.UpdatedInput) + } +} + +func TestRunClaudePreToolUse_AcceptsCursorPayload(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "") + + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": { + "command": "npm test", + "timeout": 30000 + } + }` + + var stdout bytes.Buffer + if err := runClaudePreToolUse(strings.NewReader(input), &stdout, "/usr/local/bin/fence", nil); err != nil { + t.Fatalf("runClaudePreToolUse() error = %v", err) + } + + var decoded cursorPreToolUseResponse + if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := decoded.Permission; got != "allow" { + t.Fatalf("expected permission allow, got %q", got) + } + if !decoded.Continue { + t.Fatal("expected continue=true in response") + } + wantCommand := sandbox.ShellQuote([]string{"/usr/local/bin/fence", "-c", "npm test"}) + if got := decoded.UpdatedInput["command"]; got != wantCommand { + t.Fatalf("expected wrapped command %q, got %#v", wantCommand, got) + } +} diff --git a/cmd/fence/hooks_cmd.go b/cmd/fence/hooks_cmd.go new file mode 100644 index 0000000..26c3749 --- /dev/null +++ b/cmd/fence/hooks_cmd.go @@ -0,0 +1,227 @@ +package main + +import ( + "fmt" + + "github.com/Use-Tusk/fence/internal/importer" + "github.com/spf13/cobra" +) + +func newHooksCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hooks", + Short: "Print and manage editor/agent hook integrations", + } + + cmd.AddCommand(newHooksPrintCmd()) + cmd.AddCommand(newHooksInstallCmd()) + cmd.AddCommand(newHooksUninstallCmd()) + return cmd +} + +func newHooksPrintCmd() *cobra.Command { + var ( + claude bool + cursor bool + hookOptions hookFenceOptions + ) + + cmd := &cobra.Command{ + Use: "print", + Short: "Print hook config for supported integrations", + Long: `Print hook configuration snippets for supported integrations. + +Examples: + fence hooks print --claude + fence hooks print --claude --settings ./fence.json + fence hooks print --cursor --template code`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + resolvedHookOptions, err := hookOptions.normalized() + if err != nil { + return fmt.Errorf("failed to resolve hook policy options: %w", err) + } + + switch { + case claude: + return writeClaudeHooksConfigWithOptions(cmd.OutOrStdout(), resolvedHookOptions) + case cursor: + return writeCursorHooksConfigWithOptions(cmd.OutOrStdout(), resolvedHookOptions) + default: + return fmt.Errorf("no hook target specified. Use --claude or --cursor") + } + }, + } + + cmd.Flags().BoolVar(&claude, "claude", false, "Print Claude Code hook config") + cmd.Flags().BoolVar(&cursor, "cursor", false, "Print Cursor hook config") + addHookPolicyFlags(cmd, &hookOptions) + cmd.MarkFlagsMutuallyExclusive("claude", "cursor") + return cmd +} + +func newHooksInstallCmd() *cobra.Command { + var ( + claude bool + cursor bool + path string + hookOptions hookFenceOptions + ) + + cmd := &cobra.Command{ + Use: "install", + Short: "Install hook config into supported integrations", + Long: `Install hook configuration into supported integrations. + +Examples: + fence hooks install --claude + fence hooks install --claude --file ./.claude/settings.json + fence hooks install --claude --settings ./fence.json + fence hooks install --cursor --template code --file ./.cursor/hooks.json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + resolvedHookOptions, err := hookOptions.normalized() + if err != nil { + return fmt.Errorf("failed to resolve hook policy options: %w", err) + } + + switch { + case claude: + targetPath := path + if targetPath == "" { + targetPath = importer.DefaultClaudeSettingsPath() + } + if targetPath == "" { + return fmt.Errorf("could not determine Claude settings path") + } + changed, err := installClaudeHookWithOptions(targetPath, resolvedHookOptions) + if err != nil { + return err + } + if changed { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Installed Claude hook in %q\n", targetPath); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Claude hook already installed in %q\n", targetPath); err != nil { + return err + } + } + return nil + case cursor: + targetPath := path + if targetPath == "" { + targetPath = defaultCursorHooksPath() + } + if targetPath == "" { + return fmt.Errorf("could not determine Cursor hooks path") + } + changed, err := installCursorHookWithOptions(targetPath, resolvedHookOptions) + if err != nil { + return err + } + if changed { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Installed Cursor hook in %q\n", targetPath); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Cursor hook already installed in %q\n", targetPath); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("no hook target specified. Use --claude or --cursor") + } + }, + } + + cmd.Flags().BoolVar(&claude, "claude", false, "Install Claude Code hook config") + cmd.Flags().BoolVar(&cursor, "cursor", false, "Install Cursor hook config") + cmd.Flags().StringVarP(&path, "file", "f", "", "Path to the settings file to modify (default: ~/.claude/settings.json for --claude, ~/.cursor/hooks.json for --cursor)") + addHookPolicyFlags(cmd, &hookOptions) + cmd.MarkFlagsMutuallyExclusive("claude", "cursor") + return cmd +} + +func newHooksUninstallCmd() *cobra.Command { + var ( + claude bool + cursor bool + path string + ) + + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove hook config from supported integrations", + Long: `Remove hook configuration from supported integrations. + +Examples: + fence hooks uninstall --claude + fence hooks uninstall --claude --file ./.claude/settings.json + fence hooks uninstall --cursor --file ./.cursor/hooks.json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + switch { + case claude: + targetPath := path + if targetPath == "" { + targetPath = importer.DefaultClaudeSettingsPath() + } + if targetPath == "" { + return fmt.Errorf("could not determine Claude settings path") + } + changed, err := uninstallClaudeHook(targetPath) + if err != nil { + return err + } + if changed { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Removed Claude hook from %q\n", targetPath); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Claude hook not present in %q\n", targetPath); err != nil { + return err + } + } + return nil + case cursor: + targetPath := path + if targetPath == "" { + targetPath = defaultCursorHooksPath() + } + if targetPath == "" { + return fmt.Errorf("could not determine Cursor hooks path") + } + changed, err := uninstallCursorHook(targetPath) + if err != nil { + return err + } + if changed { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Removed Cursor hook from %q\n", targetPath); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Cursor hook not present in %q\n", targetPath); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("no hook target specified. Use --claude or --cursor") + } + }, + } + + cmd.Flags().BoolVar(&claude, "claude", false, "Remove Claude Code hook config") + cmd.Flags().BoolVar(&cursor, "cursor", false, "Remove Cursor hook config") + cmd.Flags().StringVarP(&path, "file", "f", "", "Path to the settings file to modify (default: ~/.claude/settings.json for --claude, ~/.cursor/hooks.json for --cursor)") + cmd.MarkFlagsMutuallyExclusive("claude", "cursor") + return cmd +} + +func addHookPolicyFlags(cmd *cobra.Command, hookOptions *hookFenceOptions) { + cmd.Flags().StringVar(&hookOptions.SettingsPath, "settings", "", "Pin wrapped shell commands to this Fence settings file") + cmd.Flags().StringVar(&hookOptions.TemplateName, "template", "", "Pin wrapped shell commands to this Fence template") + cmd.MarkFlagsMutuallyExclusive("settings", "template") +} diff --git a/cmd/fence/hooks_config.go b/cmd/fence/hooks_config.go new file mode 100644 index 0000000..5a38717 --- /dev/null +++ b/cmd/fence/hooks_config.go @@ -0,0 +1,301 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/Use-Tusk/fence/internal/sandbox" +) + +func writeClaudeHooksConfigWithOptions(w io.Writer, hookOptions hookFenceOptions) error { + config := buildClaudeHooksConfigWithOptions(hookOptions) + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal Claude hook config: %w", err) + } + + _, err = fmt.Fprintln(w, string(data)) + return err +} + +func writeCursorHooksConfigWithOptions(w io.Writer, hookOptions hookFenceOptions) error { + config := buildCursorHooksConfigWithOptions(hookOptions) + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal Cursor hook config: %w", err) + } + + _, err = fmt.Fprintln(w, string(data)) + return err +} + +func defaultCursorHooksPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".cursor", "hooks.json") +} + +func buildClaudeHooksConfigWithOptions(hookOptions hookFenceOptions) map[string]any { + return map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + buildClaudePreToolUseHookGroupWithOptions(hookOptions), + }, + }, + } +} + +func buildCursorHooksConfigWithOptions(hookOptions hookFenceOptions) map[string]any { + return map[string]any{ + "version": 1, + "hooks": map[string]any{ + "preToolUse": []any{ + buildCursorPreToolUseHookGroupWithOptions(hookOptions), + }, + }, + } +} + +func buildClaudePreToolUseHookGroupWithOptions(hookOptions hookFenceOptions) map[string]any { + return map[string]any{ + "matcher": "Bash", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": claudeHookCommandWithOptions(hookOptions), + }, + }, + } +} + +func buildCursorPreToolUseHookGroupWithOptions(hookOptions hookFenceOptions) map[string]any { + return map[string]any{ + "matcher": "Shell", + "command": cursorHookCommandWithOptions(hookOptions), + } +} + +func claudeHookCommand() string { + return claudeHookCommandWithOptions(hookFenceOptions{}) +} + +func claudeHookCommandWithOptions(hookOptions hookFenceOptions) string { + args := []string{"fence", claudePreToolUseMode} + args = append(args, hookOptions.fenceArgs()...) + return sandbox.ShellQuote(args) +} + +func cursorHookCommand() string { + return cursorHookCommandWithOptions(hookFenceOptions{}) +} + +func cursorHookCommandWithOptions(hookOptions hookFenceOptions) string { + args := []string{"fence", cursorPreToolUseMode} + args = append(args, hookOptions.fenceArgs()...) + return sandbox.ShellQuote(args) +} + +func installClaudeHook(path string) (bool, error) { + return installClaudeHookWithOptions(path, hookFenceOptions{}) +} + +func installClaudeHookWithOptions(path string, hookOptions hookFenceOptions) (bool, error) { + doc, err := loadHookConfigDocument(path, "Claude settings") + if err != nil { + return false, err + } + + hooks, err := ensureJSONObjectField(doc, "hooks", "Claude settings") + if err != nil { + return false, err + } + + preToolUse, err := getJSONArrayField(hooks, "PreToolUse", "Claude settings") + if err != nil { + return false, err + } + + desiredCommand := claudeHookCommandWithOptions(hookOptions) + summary := summarizeHookCommands(preToolUse, desiredCommand, isClaudeHookCommand) + if summary.Total == 1 && summary.Exact == 1 { + return false, nil + } + + filtered := preToolUse + if summary.Total > 0 { + var removed bool + filtered, removed = removeHookCommands(preToolUse, isClaudeHookCommand) + if !removed { + filtered = preToolUse + } + } + + hooks["PreToolUse"] = append(filtered, buildClaudePreToolUseHookGroupWithOptions(hookOptions)) + doc["hooks"] = hooks + + if err := writeHookConfigDocument(path, doc, "Claude settings"); err != nil { + return false, err + } + return true, nil +} + +func installCursorHook(path string) (bool, error) { + return installCursorHookWithOptions(path, hookFenceOptions{}) +} + +func installCursorHookWithOptions(path string, hookOptions hookFenceOptions) (bool, error) { + doc, err := loadHookConfigDocument(path, "Cursor hooks config") + if err != nil { + return false, err + } + + if _, ok := doc["version"]; !ok { + doc["version"] = 1 + } + + hooks, err := ensureJSONObjectField(doc, "hooks", "Cursor hooks config") + if err != nil { + return false, err + } + + preToolUse, err := getJSONArrayField(hooks, "preToolUse", "Cursor hooks config") + if err != nil { + return false, err + } + + desiredCommand := cursorHookCommandWithOptions(hookOptions) + summary := summarizeHookCommands(preToolUse, desiredCommand, isCursorHookCommand) + if summary.Total == 1 && summary.Exact == 1 { + return false, nil + } + + filtered := preToolUse + if summary.Total > 0 { + var removed bool + filtered, removed = removeHookCommands(preToolUse, isCursorHookCommand) + if !removed { + filtered = preToolUse + } + } + + hooks["preToolUse"] = append(filtered, buildCursorPreToolUseHookGroupWithOptions(hookOptions)) + doc["hooks"] = hooks + + if err := writeHookConfigDocument(path, doc, "Cursor hooks config"); err != nil { + return false, err + } + return true, nil +} + +func uninstallClaudeHook(path string) (bool, error) { + doc, err := loadHookConfigDocument(path, "Claude settings") + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + hooksValue, ok := doc["hooks"] + if !ok { + return false, nil + } + hooks, ok := hooksValue.(map[string]any) + if !ok { + return false, fmt.Errorf("invalid Claude settings: hooks must be an object") + } + + preToolUseValue, ok := hooks["PreToolUse"] + if !ok { + return false, nil + } + preToolUse, ok := preToolUseValue.([]any) + if !ok { + return false, fmt.Errorf("invalid Claude settings: hooks.PreToolUse must be an array") + } + + filtered, removed := removeHookCommands(preToolUse, isClaudeHookCommand) + if !removed { + return false, nil + } + + if len(filtered) == 0 { + delete(hooks, "PreToolUse") + } else { + hooks["PreToolUse"] = filtered + } + + if len(hooks) == 0 { + delete(doc, "hooks") + } else { + doc["hooks"] = hooks + } + + if err := writeHookConfigDocument(path, doc, "Claude settings"); err != nil { + return false, err + } + return true, nil +} + +func uninstallCursorHook(path string) (bool, error) { + doc, err := loadHookConfigDocument(path, "Cursor hooks config") + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + hooksValue, ok := doc["hooks"] + if !ok { + return false, nil + } + hooks, ok := hooksValue.(map[string]any) + if !ok { + return false, fmt.Errorf("invalid Cursor hooks config: hooks must be an object") + } + + preToolUseValue, ok := hooks["preToolUse"] + if !ok { + return false, nil + } + preToolUse, ok := preToolUseValue.([]any) + if !ok { + return false, fmt.Errorf("invalid Cursor hooks config: hooks.preToolUse must be an array") + } + + filtered, removed := removeHookCommands(preToolUse, isCursorHookCommand) + if !removed { + return false, nil + } + + if len(filtered) == 0 { + delete(hooks, "preToolUse") + } else { + hooks["preToolUse"] = filtered + } + + if len(hooks) == 0 { + delete(doc, "hooks") + } else { + doc["hooks"] = hooks + } + + if err := writeHookConfigDocument(path, doc, "Cursor hooks config"); err != nil { + return false, err + } + return true, nil +} + +func isClaudeHookCommand(command string) bool { + return containsHelperMode(command, claudePreToolUseMode) +} + +func isCursorHookCommand(command string) bool { + return containsHelperMode(command, cursorPreToolUseMode) +} diff --git a/cmd/fence/hooks_cursor.go b/cmd/fence/hooks_cursor.go new file mode 100644 index 0000000..9688df9 --- /dev/null +++ b/cmd/fence/hooks_cursor.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +const cursorPreToolUseMode = "--cursor-pre-tool-use" + +type cursorPreToolUseEvent struct { + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput map[string]any `json:"tool_input"` + CWD string `json:"cwd,omitempty"` +} + +type cursorPreToolUseResponse struct { + Permission string `json:"permission,omitempty"` + UpdatedInput map[string]any `json:"updated_input,omitempty"` + Continue bool `json:"continue,omitempty"` +} + +func runCursorPreToolUseMode() error { + return runCursorPreToolUse(os.Stdin, os.Stdout, resolveFenceExecutable(), os.Args[2:]) +} + +func runCursorPreToolUse(stdin io.Reader, stdout io.Writer, fenceExePath string, extraFenceArgs []string) error { + response, changed, err := buildCompatiblePreToolUseResponse(stdin, fenceExePath, extraFenceArgs) + if err != nil { + return err + } + if !changed { + return nil + } + + _, err = fmt.Fprintln(stdout, string(response)) + return err +} + +func buildCursorPreToolUseResponse(stdin io.Reader, fenceExePath string, extraFenceArgs []string) ([]byte, bool, error) { + var event cursorPreToolUseEvent + decoder := json.NewDecoder(stdin) + decoder.UseNumber() + if err := decoder.Decode(&event); err != nil { + return nil, false, fmt.Errorf("failed to decode Cursor hook JSON: %w", err) + } + + if event.ToolName != "Shell" { + return nil, false, nil + } + if event.HookEventName != "" && event.HookEventName != "preToolUse" { + return nil, false, nil + } + + command, ok := event.ToolInput["command"].(string) + if !ok { + return nil, false, fmt.Errorf("Shell tool_input.command missing or not a string") + } + result, changed, err := evaluateShellHookRequest(shellHookRequest{ + Command: command, + CWD: extractHookCommandCWD(event.ToolInput, event.CWD), + ToolInput: event.ToolInput, + }, fenceExePath, extraFenceArgs) + if err != nil { + return nil, false, err + } + if !changed { + return nil, false, nil + } + response := cursorPreToolUseResponse{Continue: true} + switch result.Decision { + case hookShellDeny: + response.Permission = "deny" + case hookShellWrap: + response.Permission = "allow" + response.UpdatedInput = result.UpdatedInput + default: + return nil, false, nil + } + + data, err := json.Marshal(response) + if err != nil { + return nil, false, fmt.Errorf("failed to encode Cursor hook response: %w", err) + } + return data, true, nil +} diff --git a/cmd/fence/hooks_cursor_test.go b/cmd/fence/hooks_cursor_test.go new file mode 100644 index 0000000..e55431a --- /dev/null +++ b/cmd/fence/hooks_cursor_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Use-Tusk/fence/internal/sandbox" +) + +func TestBuildCursorPreToolUseResponse_WrapsShellCommand(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "") + + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": { + "command": "npm test", + "description": "Run tests", + "timeout": 120000, + "run_in_background": true + } + }` + + response, changed, err := buildCursorPreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildCursorPreToolUseResponse() error = %v", err) + } + if !changed { + t.Fatal("expected Shell command to be rewritten") + } + + var decoded cursorPreToolUseResponse + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.Permission != "allow" { + t.Fatalf("expected permission allow, got %q", decoded.Permission) + } + if !decoded.Continue { + t.Fatal("expected continue=true in response") + } + + wantCommand := sandbox.ShellQuote([]string{"/usr/local/bin/fence", "-c", "npm test"}) + if got := decoded.UpdatedInput["command"]; got != wantCommand { + t.Fatalf("expected wrapped command %q, got %#v", wantCommand, got) + } + if got := decoded.UpdatedInput["description"]; got != "Run tests" { + t.Fatalf("expected description to be preserved, got %#v", got) + } + if got := decoded.UpdatedInput["run_in_background"]; got != true { + t.Fatalf("expected run_in_background to be preserved, got %#v", got) + } + if got := decoded.UpdatedInput["timeout"]; got != float64(120000) { + t.Fatalf("expected timeout to be preserved, got %#v", got) + } +} + +func TestBuildCursorPreToolUseResponse_SkipsPureCD(t *testing.T) { + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": { + "command": "cd ../repo" + } + }` + + _, changed, err := buildCursorPreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildCursorPreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected pure cd command to be skipped") + } +} + +func TestBuildCursorPreToolUseResponse_SkipsAlreadyFencedCommand(t *testing.T) { + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": { + "command": "/usr/local/bin/fence -c 'npm test'" + } + }` + + _, changed, err := buildCursorPreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildCursorPreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected already-fenced command to be skipped") + } +} + +func TestBuildCursorPreToolUseResponse_IgnoresNonShellEvent(t *testing.T) { + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Read", + "tool_input": { + "file_path": "/tmp/test.txt" + } + }` + + _, changed, err := buildCursorPreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildCursorPreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected non-Shell event to be ignored") + } +} + +func TestBuildCursorPreToolUseResponse_InvalidJSON(t *testing.T) { + _, _, err := buildCursorPreToolUseResponse(strings.NewReader(`{`), "/usr/local/bin/fence", nil) + if err == nil { + t.Fatal("expected invalid JSON to return an error") + } +} + +func TestBuildCursorPreToolUseResponse_LeavesCommandUnchangedInsideFence(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "1") + + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": { + "command": "npm test" + } + }` + + _, changed, err := buildCursorPreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildCursorPreToolUseResponse() error = %v", err) + } + if changed { + t.Fatal("expected command to stay unchanged when already inside Fence") + } +} + +func TestBuildCursorPreToolUseResponse_DeniesBlockedCommandInsideFence(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "1") + + settingsPath := filepath.Join(t.TempDir(), "fence.json") + content := `{ + "command": { + "deny": ["npm test"], + "useDefaults": false + } +}` + if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": { + "command": "npm test" + } + }` + + response, changed, err := buildCursorPreToolUseResponse( + strings.NewReader(input), + "/usr/local/bin/fence", + []string{"--settings", settingsPath}, + ) + if err != nil { + t.Fatalf("buildCursorPreToolUseResponse() error = %v", err) + } + if !changed { + t.Fatal("expected blocked command to produce a deny response") + } + + var decoded cursorPreToolUseResponse + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := decoded.Permission; got != "deny" { + t.Fatalf("expected permission deny, got %q", got) + } + if !decoded.Continue { + t.Fatal("expected continue=true in deny response") + } + if decoded.UpdatedInput != nil { + t.Fatalf("expected deny response to omit updated_input, got %#v", decoded.UpdatedInput) + } +} + +func TestBuildCursorPreToolUseResponse_UsesPayloadCWDForOuterDeny(t *testing.T) { + repoDir := t.TempDir() + settingsPath := filepath.Join(repoDir, "fence.json") + content := `{ + "command": { + "deny": ["ls"], + "useDefaults": false + } +}` + if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + input := `{ + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": { + "command": "pwd && ls", + "cwd": "` + repoDir + `" + }, + "cwd": "` + repoDir + `" + }` + + response, changed, err := buildCursorPreToolUseResponse(strings.NewReader(input), "/usr/local/bin/fence", nil) + if err != nil { + t.Fatalf("buildCursorPreToolUseResponse() error = %v", err) + } + if !changed { + t.Fatal("expected blocked command to produce a deny response") + } + + var decoded cursorPreToolUseResponse + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := decoded.Permission; got != "deny" { + t.Fatalf("expected permission deny, got %q", got) + } + if !decoded.Continue { + t.Fatal("expected continue=true in deny response") + } + if decoded.UpdatedInput != nil { + t.Fatalf("expected deny response to omit updated_input, got %#v", decoded.UpdatedInput) + } +} + +func TestRunCursorPreToolUse_AcceptsClaudePayload(t *testing.T) { + t.Setenv(fenceSandboxEnvVar, "") + + input := `{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "npm test", + "timeout": 30000 + } + }` + + var stdout bytes.Buffer + if err := runCursorPreToolUse(strings.NewReader(input), &stdout, "/usr/local/bin/fence", nil); err != nil { + t.Fatalf("runCursorPreToolUse() error = %v", err) + } + + var decoded claudePreToolUseResponse + if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if decoded.HookSpecificOutput == nil { + t.Fatal("expected hookSpecificOutput in response") + } + if got := decoded.HookSpecificOutput.PermissionDecision; got != "allow" { + t.Fatalf("expected permissionDecision allow, got %q", got) + } + wantCommand := sandbox.ShellQuote([]string{"/usr/local/bin/fence", "-c", "npm test"}) + if got := decoded.HookSpecificOutput.UpdatedInput["command"]; got != wantCommand { + t.Fatalf("expected wrapped command %q, got %#v", wantCommand, got) + } +} diff --git a/cmd/fence/hooks_doc.go b/cmd/fence/hooks_doc.go new file mode 100644 index 0000000..1bd1396 --- /dev/null +++ b/cmd/fence/hooks_doc.go @@ -0,0 +1,269 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/tidwall/jsonc" +) + +type hookCommandSummary struct { + Total int + Exact int +} + +func loadHookConfigDocument(path string, label string) (map[string]any, error) { + data, err := os.ReadFile(path) //nolint:gosec // user-provided path is intentional + if err != nil { + if os.IsNotExist(err) { + return map[string]any{}, nil + } + return nil, fmt.Errorf("failed to read %s: %w", label, err) + } + + if len(strings.TrimSpace(string(data))) == 0 { + return map[string]any{}, nil + } + + var doc map[string]any + if err := json.Unmarshal(jsonc.ToJSON(data), &doc); err != nil { + return nil, fmt.Errorf("invalid JSON in %s: %w", label, err) + } + if doc == nil { + return map[string]any{}, nil + } + return doc, nil +} + +func writeHookConfigDocument(path string, doc map[string]any, label string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("failed to create %s directory: %w", label, err) + } + + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal %s: %w", label, err) + } + + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", label, err) + } + return nil +} + +func ensureJSONObjectField(doc map[string]any, key string, label string) (map[string]any, error) { + if value, ok := doc[key]; ok { + object, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid %s: %s must be an object", label, key) + } + return object, nil + } + return map[string]any{}, nil +} + +func getJSONArrayField(doc map[string]any, key string, label string) ([]any, error) { + if value, ok := doc[key]; ok { + array, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("invalid %s: %s must be an array", label, key) + } + return array, nil + } + return []any{}, nil +} + +func summarizeHookCommands(hookGroups []any, desiredCommand string, matcher func(string) bool) hookCommandSummary { + var summary hookCommandSummary + for _, groupValue := range hookGroups { + group, ok := groupValue.(map[string]any) + if !ok { + continue + } + groupSummary := summarizeCommandsInHookGroup(group, desiredCommand, matcher) + summary.Total += groupSummary.Total + summary.Exact += groupSummary.Exact + } + return summary +} + +func removeHookCommands(hookGroups []any, matcher func(string) bool) ([]any, bool) { + filteredGroups := make([]any, 0, len(hookGroups)) + removed := false + + for _, groupValue := range hookGroups { + group, ok := groupValue.(map[string]any) + if !ok { + filteredGroups = append(filteredGroups, groupValue) + continue + } + filteredGroup, groupRemoved, keepGroup := removeCommandsFromHookGroup(group, matcher) + removed = removed || groupRemoved + if keepGroup { + filteredGroups = append(filteredGroups, filteredGroup) + } + } + + return filteredGroups, removed +} + +func summarizeCommandsInHookGroup(group map[string]any, desiredCommand string, matcher func(string) bool) hookCommandSummary { + var summary hookCommandSummary + + if command, ok := group["command"].(string); ok { + if matcher(command) { + summary.Total++ + if command == desiredCommand { + summary.Exact++ + } + } + return summary + } + + hooksValue, ok := group["hooks"].([]any) + if !ok { + return summary + } + for _, hookValue := range hooksValue { + hook, ok := hookValue.(map[string]any) + if !ok { + continue + } + command, ok := hook["command"].(string) + if hook["type"] == "command" && ok && matcher(command) { + summary.Total++ + if command == desiredCommand { + summary.Exact++ + } + } + } + return summary +} + +func removeCommandsFromHookGroup(group map[string]any, matcher func(string) bool) (map[string]any, bool, bool) { + if command, ok := group["command"].(string); ok { + if matcher(command) { + return nil, true, false + } + return group, false, true + } + + hooksValue, ok := group["hooks"].([]any) + if !ok { + return group, false, true + } + + filteredHooks := make([]any, 0, len(hooksValue)) + groupRemoved := false + for _, hookValue := range hooksValue { + hook, ok := hookValue.(map[string]any) + command, commandOK := hook["command"].(string) + if ok && hook["type"] == "command" && commandOK && matcher(command) { + groupRemoved = true + continue + } + filteredHooks = append(filteredHooks, hookValue) + } + + if !groupRemoved { + return group, false, true + } + if len(filteredHooks) == 0 { + return nil, true, false + } + + groupCopy := cloneJSONMap(group) + groupCopy["hooks"] = filteredHooks + return groupCopy, true, true +} + +func containsHelperMode(command, helperMode string) bool { + tokens := tokenizeHookCommand(command) + executableIndex := firstHookExecutableTokenIndex(tokens) + if executableIndex == -1 { + return false + } + if filepath.Base(tokens[executableIndex]) != "fence" { + return false + } + + for _, token := range tokens[executableIndex+1:] { + if token == helperMode { + return true + } + } + return false +} + +func tokenizeHookCommand(command string) []string { + var tokens []string + var current strings.Builder + var inSingleQuote bool + var inDoubleQuote bool + + for _, c := range command { + switch { + case c == '\'' && !inDoubleQuote: + inSingleQuote = !inSingleQuote + case c == '"' && !inSingleQuote: + inDoubleQuote = !inDoubleQuote + case (c == ' ' || c == '\t') && !inSingleQuote && !inDoubleQuote: + if current.Len() > 0 { + tokens = append(tokens, current.String()) + current.Reset() + } + default: + current.WriteRune(c) + } + } + + if current.Len() > 0 { + tokens = append(tokens, current.String()) + } + + return tokens +} + +func firstHookExecutableTokenIndex(tokens []string) int { + for i, token := range tokens { + if isShellAssignmentToken(token) { + continue + } + return i + } + return -1 +} + +func isShellAssignmentToken(token string) bool { + separator := strings.IndexByte(token, '=') + if separator <= 0 { + return false + } + + name := token[:separator] + for i := 0; i < len(name); i++ { + c := name[i] + if i == 0 { + if c != '_' && !isASCIILetter(c) { + return false + } + continue + } + if c != '_' && !isASCIILetter(c) && !isASCIIDigit(c) { + return false + } + } + + return true +} + +func isASCIILetter(c byte) bool { + return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') +} + +func isASCIIDigit(c byte) bool { + return '0' <= c && c <= '9' +} diff --git a/cmd/fence/hooks_doc_test.go b/cmd/fence/hooks_doc_test.go new file mode 100644 index 0000000..77585a8 --- /dev/null +++ b/cmd/fence/hooks_doc_test.go @@ -0,0 +1,57 @@ +package main + +import "testing" + +func TestContainsHelperMode(t *testing.T) { + testCases := []struct { + name string + command string + helperMode string + want bool + }{ + { + name: "generated Claude hook", + command: "fence --claude-pre-tool-use", + helperMode: claudePreToolUseMode, + want: true, + }, + { + name: "absolute fence path with settings", + command: `PATH=/tmp/bin /usr/local/bin/fence --claude-pre-tool-use --settings "/tmp/policy.json"`, + helperMode: claudePreToolUseMode, + want: true, + }, + { + name: "generated Cursor hook", + command: `"/Users/jy/bin/fence" "--cursor-pre-tool-use"`, + helperMode: cursorPreToolUseMode, + want: true, + }, + { + name: "unrelated command containing helper text", + command: "echo --claude-pre-tool-use", + helperMode: claudePreToolUseMode, + want: false, + }, + { + name: "similar but not exact flag", + command: "fence --claude-pre-tool-use-suffix", + helperMode: claudePreToolUseMode, + want: false, + }, + { + name: "different executable", + command: "other-fence --cursor-pre-tool-use", + helperMode: cursorPreToolUseMode, + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := containsHelperMode(tc.command, tc.helperMode); got != tc.want { + t.Fatalf("expected %v, got %v for %q", tc.want, got, tc.command) + } + }) + } +} diff --git a/cmd/fence/hooks_runtime.go b/cmd/fence/hooks_runtime.go new file mode 100644 index 0000000..01e313c --- /dev/null +++ b/cmd/fence/hooks_runtime.go @@ -0,0 +1,289 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Use-Tusk/fence/internal/sandbox" +) + +const fenceSandboxEnvVar = "FENCE_SANDBOX" + +type hookFenceOptions struct { + SettingsPath string + TemplateName string +} + +type shellHookRequest struct { + Command string + CWD string + ToolInput map[string]any +} + +type shellHookResult struct { + Decision hookShellDecision + UpdatedInput map[string]any +} + +type hookShellDecision int + +const ( + hookShellNoChange hookShellDecision = iota + hookShellDeny + hookShellWrap +) + +func (o hookFenceOptions) normalized() (hookFenceOptions, error) { + if o.SettingsPath == "" { + return o, nil + } + + resolvedPath, err := resolveCLIPath(o.SettingsPath, "") + if err != nil { + return hookFenceOptions{}, err + } + + o.SettingsPath = resolvedPath + return o, nil +} + +func (o hookFenceOptions) fenceArgs() []string { + args := make([]string, 0, 4) + if o.SettingsPath != "" { + args = append(args, "--settings", o.SettingsPath) + } + if o.TemplateName != "" { + args = append(args, "--template", o.TemplateName) + } + return args +} + +func resolveFenceExecutable() string { + fenceExePath, err := os.Executable() + if err != nil || fenceExePath == "" { + return "fence" + } + return filepath.Clean(fenceExePath) +} + +func wrapShellCommand(command, fenceExePath string, extraFenceArgs []string) string { + args := make([]string, 0, len(extraFenceArgs)+3) + args = append(args, fenceExePath) + args = append(args, extraFenceArgs...) + args = append(args, "-c", command) + return sandbox.ShellQuote(args) +} + +func evaluateShellHookRequest(request shellHookRequest, fenceExePath string, extraFenceArgs []string) (shellHookResult, bool, error) { + if shouldSkipShellWrap(request.Command, fenceExePath) { + return shellHookResult{}, false, nil + } + + blocked, err := isHookCommandBlocked(request.Command, request.CWD, extraFenceArgs) + if err != nil { + return shellHookResult{}, false, err + } + if blocked { + return shellHookResult{Decision: hookShellDeny}, true, nil + } + + if os.Getenv(fenceSandboxEnvVar) == "1" { + return shellHookResult{}, false, nil + } + + updatedInput := cloneJSONMap(request.ToolInput) + updatedInput["command"] = wrapShellCommand(request.Command, fenceExePath, extraFenceArgs) + return shellHookResult{ + Decision: hookShellWrap, + UpdatedInput: updatedInput, + }, true, nil +} + +func buildCompatiblePreToolUseResponse(stdin io.Reader, fenceExePath string, extraFenceArgs []string) ([]byte, bool, error) { + payload, err := io.ReadAll(stdin) + if err != nil { + return nil, false, fmt.Errorf("failed to read hook JSON: %w", err) + } + + response, changed, err := buildClaudePreToolUseResponse(bytes.NewReader(payload), fenceExePath, extraFenceArgs) + if err != nil { + return nil, false, err + } + if changed { + return response, true, nil + } + + return buildCursorPreToolUseResponse(bytes.NewReader(payload), fenceExePath, extraFenceArgs) +} + +func isHookCommandBlocked(command, commandCWD string, extraFenceArgs []string) (bool, error) { + hookOptions, err := parseHookFenceOptionsArgs(extraFenceArgs) + if err != nil { + return false, err + } + + activeConfig, err := loadActiveConfigAudit(commandCWD, hookOptions.SettingsPath, hookOptions.TemplateName) + if err != nil { + return false, err + } + + return sandbox.CheckCommand(command, activeConfig.Config) != nil, nil +} + +func extractHookCommandCWD(toolInput map[string]any, fallback string) string { + if cwd, ok := stringFromJSONMap(toolInput, "cwd"); ok { + return cwd + } + if cwd, ok := stringFromJSONMap(toolInput, "working_directory"); ok { + return cwd + } + if cwd, ok := stringFromJSONMap(toolInput, "workingDirectory"); ok { + return cwd + } + return fallback +} + +func stringFromJSONMap(input map[string]any, key string) (string, bool) { + if input == nil { + return "", false + } + value, ok := input[key] + if !ok { + return "", false + } + text, ok := value.(string) + return text, ok && text != "" +} + +func parseHookFenceOptionsArgs(args []string) (hookFenceOptions, error) { + var hookOptions hookFenceOptions + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--settings" || arg == "-s": + if i+1 >= len(args) { + return hookFenceOptions{}, fmt.Errorf("missing value for %s", arg) + } + hookOptions.SettingsPath = args[i+1] + i++ + case strings.HasPrefix(arg, "--settings="): + hookOptions.SettingsPath = strings.TrimPrefix(arg, "--settings=") + case arg == "--template" || arg == "-t": + if i+1 >= len(args) { + return hookFenceOptions{}, fmt.Errorf("missing value for %s", arg) + } + hookOptions.TemplateName = args[i+1] + i++ + case strings.HasPrefix(arg, "--template="): + hookOptions.TemplateName = strings.TrimPrefix(arg, "--template=") + default: + return hookFenceOptions{}, fmt.Errorf("unknown hook helper flag: %s", arg) + } + } + + if hookOptions.SettingsPath != "" && hookOptions.TemplateName != "" { + return hookFenceOptions{}, fmt.Errorf("cannot use --settings and --template together") + } + + return hookOptions.normalized() +} + +func shouldSkipShellWrap(command, fenceExePath string) bool { + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return true + } + if isPureCDCommand(trimmed) { + return true + } + return isAlreadyFencedCommand(trimmed, fenceExePath) +} + +func isPureCDCommand(command string) bool { + if command != "cd" && !(strings.HasPrefix(command, "cd ") || strings.HasPrefix(command, "cd\t")) { + return false + } + if containsCommandSubstitution(command) { + return false + } + + for _, separator := range []string{"&&", "||", ";", "|", ">", "<", "\n", "\r"} { + if strings.Contains(command, separator) { + return false + } + } + + return true +} + +func containsCommandSubstitution(command string) bool { + var inSingleQuote bool + var inDoubleQuote bool + var escaped bool + + runes := []rune(command) + for i := 0; i < len(runes); i++ { + c := runes[i] + + if escaped { + escaped = false + continue + } + if c == '\\' && !inSingleQuote { + escaped = true + continue + } + if c == '\'' && !inDoubleQuote { + inSingleQuote = !inSingleQuote + continue + } + if c == '"' && !inSingleQuote { + inDoubleQuote = !inDoubleQuote + continue + } + if inSingleQuote { + continue + } + if c == '`' { + return true + } + if c == '$' && i+1 < len(runes) && runes[i+1] == '(' { + if i+2 < len(runes) && runes[i+2] == '(' { + continue + } + return true + } + } + + return false +} + +func isAlreadyFencedCommand(command, fenceExePath string) bool { + quotedFenceExePath := sandbox.ShellQuote([]string{fenceExePath}) + prefixes := []string{ + "fence ", + fenceExePath + " ", + quotedFenceExePath + " ", + } + + for _, prefix := range prefixes { + if strings.HasPrefix(command, prefix) { + return true + } + } + + return command == "fence" || command == fenceExePath || command == quotedFenceExePath +} + +func cloneJSONMap(input map[string]any) map[string]any { + cloned := make(map[string]any, len(input)) + for key, value := range input { + cloned[key] = value + } + return cloned +} diff --git a/cmd/fence/hooks_runtime_test.go b/cmd/fence/hooks_runtime_test.go new file mode 100644 index 0000000..bbec6a8 --- /dev/null +++ b/cmd/fence/hooks_runtime_test.go @@ -0,0 +1,55 @@ +package main + +import "testing" + +func TestIsPureCDCommand(t *testing.T) { + testCases := []struct { + name string + command string + want bool + }{ + { + name: "plain cd", + command: "cd ../repo", + want: true, + }, + { + name: "environment expansion", + command: `cd "$HOME/tmp"`, + want: true, + }, + { + name: "single quoted literal substitution syntax", + command: `cd '$(pwd)'`, + want: true, + }, + { + name: "command substitution", + command: "cd $(pwd)", + want: false, + }, + { + name: "command substitution in double quotes", + command: `cd "$(pwd)"`, + want: false, + }, + { + name: "backtick substitution", + command: "cd `pwd`", + want: false, + }, + { + name: "arithmetic expansion", + command: "cd $((1 + 1))", + want: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if got := isPureCDCommand(tc.command); got != tc.want { + t.Fatalf("expected %v, got %v for %q", tc.want, got, tc.command) + } + }) + } +} diff --git a/cmd/fence/hooks_test.go b/cmd/fence/hooks_test.go new file mode 100644 index 0000000..c51238a --- /dev/null +++ b/cmd/fence/hooks_test.go @@ -0,0 +1,446 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestHooksPrintCmd_PrintsClaudeHookConfig(t *testing.T) { + cmd := newHooksPrintCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--claude"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + hooksValue, ok := output["hooks"].(map[string]any) + if !ok { + t.Fatalf("expected hooks object, got %#v", output["hooks"]) + } + preToolUse, ok := hooksValue["PreToolUse"].([]any) + if !ok || len(preToolUse) != 1 { + t.Fatalf("expected one PreToolUse hook group, got %#v", hooksValue["PreToolUse"]) + } + + group, ok := preToolUse[0].(map[string]any) + if !ok { + t.Fatalf("expected hook group object, got %#v", preToolUse[0]) + } + if got := group["matcher"]; got != "Bash" { + t.Fatalf("expected matcher Bash, got %#v", got) + } + + nestedHooks, ok := group["hooks"].([]any) + if !ok || len(nestedHooks) != 1 { + t.Fatalf("expected one nested hook, got %#v", group["hooks"]) + } + nested, ok := nestedHooks[0].(map[string]any) + if !ok { + t.Fatalf("expected nested hook object, got %#v", nestedHooks[0]) + } + if got := nested["type"]; got != "command" { + t.Fatalf("expected command hook type, got %#v", got) + } + if got := nested["command"]; got != claudeHookCommand() { + t.Fatalf("expected Claude helper command, got %#v", got) + } +} + +func TestHooksPrintCmd_PrintsClaudeHookConfigWithSettings(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), "policy with spaces.json") + + cmd := newHooksPrintCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--claude", "--settings", settingsPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + hooksValue := output["hooks"].(map[string]any) + preToolUse := hooksValue["PreToolUse"].([]any) + group := preToolUse[0].(map[string]any) + nested := group["hooks"].([]any)[0].(map[string]any) + + want := claudeHookCommandWithOptions(hookFenceOptions{SettingsPath: settingsPath}) + if got := nested["command"]; got != want { + t.Fatalf("expected pinned Claude helper command %q, got %#v", want, got) + } +} + +func TestHooksPrintCmd_PrintsCursorHookConfig(t *testing.T) { + cmd := newHooksPrintCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--cursor"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + + if got := output["version"]; got != float64(1) { + t.Fatalf("expected version 1, got %#v", got) + } + + hooksValue, ok := output["hooks"].(map[string]any) + if !ok { + t.Fatalf("expected hooks object, got %#v", output["hooks"]) + } + preToolUse, ok := hooksValue["preToolUse"].([]any) + if !ok || len(preToolUse) != 1 { + t.Fatalf("expected one preToolUse hook, got %#v", hooksValue["preToolUse"]) + } + + group, ok := preToolUse[0].(map[string]any) + if !ok { + t.Fatalf("expected hook object, got %#v", preToolUse[0]) + } + if got := group["matcher"]; got != "Shell" { + t.Fatalf("expected matcher Shell, got %#v", got) + } + if got := group["command"]; got != cursorHookCommand() { + t.Fatalf("expected Cursor helper command, got %#v", got) + } +} + +func TestHooksPrintCmd_RequiresTargetFlag(t *testing.T) { + cmd := newHooksPrintCmd() + cmd.SetArgs(nil) + + if err := cmd.Execute(); err == nil { + t.Fatal("expected command to require a hook target flag") + } +} + +func TestInstallClaudeHook_CreatesSettingsFile(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.json") + + changed, err := installClaudeHook(settingsPath) + if err != nil { + t.Fatalf("installClaudeHook() error = %v", err) + } + if !changed { + t.Fatal("expected install to create the Claude hook") + } + + doc := readHooksTestJSONFile(t, settingsPath) + hooks, ok := doc["hooks"].(map[string]any) + if !ok { + t.Fatalf("expected hooks object, got %#v", doc["hooks"]) + } + preToolUse, ok := hooks["PreToolUse"].([]any) + if !ok || len(preToolUse) != 1 { + t.Fatalf("expected one PreToolUse group, got %#v", hooks["PreToolUse"]) + } +} + +func TestInstallClaudeHook_IsIdempotent(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.json") + + changed, err := installClaudeHook(settingsPath) + if err != nil { + t.Fatalf("first installClaudeHook() error = %v", err) + } + if !changed { + t.Fatal("expected first install to change the file") + } + + changed, err = installClaudeHook(settingsPath) + if err != nil { + t.Fatalf("second installClaudeHook() error = %v", err) + } + if changed { + t.Fatal("expected second install to be a no-op") + } + + doc := readHooksTestJSONFile(t, settingsPath) + hooks := doc["hooks"].(map[string]any) + preToolUse := hooks["PreToolUse"].([]any) + if len(preToolUse) != 1 { + t.Fatalf("expected one PreToolUse group after repeated install, got %d", len(preToolUse)) + } +} + +func TestInstallClaudeHookWithOptions_ReplacesExistingFenceHook(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.json") + + changed, err := installClaudeHook(settingsPath) + if err != nil { + t.Fatalf("first installClaudeHook() error = %v", err) + } + if !changed { + t.Fatal("expected first install to change the file") + } + + hookOptions := hookFenceOptions{SettingsPath: filepath.Join(t.TempDir(), "policy.json")} + changed, err = installClaudeHookWithOptions(settingsPath, hookOptions) + if err != nil { + t.Fatalf("installClaudeHookWithOptions() error = %v", err) + } + if !changed { + t.Fatal("expected install with hook options to replace the existing Fence hook") + } + + doc := readHooksTestJSONFile(t, settingsPath) + hooks := doc["hooks"].(map[string]any) + preToolUse := hooks["PreToolUse"].([]any) + if len(preToolUse) != 1 { + t.Fatalf("expected one PreToolUse group after replacement, got %d", len(preToolUse)) + } + + group := preToolUse[0].(map[string]any) + nested := group["hooks"].([]any)[0].(map[string]any) + want := claudeHookCommandWithOptions(hookOptions) + if got := nested["command"]; got != want { + t.Fatalf("expected updated hook command %q, got %#v", want, got) + } +} + +func TestUninstallClaudeHook_RemovesOnlyFenceHook(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.json") + content := `{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "` + claudeHookCommandWithOptions(hookFenceOptions{SettingsPath: "/tmp/fence policy.json"}) + `" + }, + { + "type": "command", + "command": "echo custom" + } + ] + }, + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "echo keep" + } + ] + } + ] + }, + "theme": "dark" +}` + + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + changed, err := uninstallClaudeHook(settingsPath) + if err != nil { + t.Fatalf("uninstallClaudeHook() error = %v", err) + } + if !changed { + t.Fatal("expected uninstall to remove the Claude hook") + } + + doc := readHooksTestJSONFile(t, settingsPath) + if got := doc["theme"]; got != "dark" { + t.Fatalf("expected unrelated top-level settings to be preserved, got %#v", got) + } + + hooks := doc["hooks"].(map[string]any) + preToolUse := hooks["PreToolUse"].([]any) + if len(preToolUse) != 2 { + t.Fatalf("expected both PreToolUse groups to remain, got %d", len(preToolUse)) + } + + firstGroup := preToolUse[0].(map[string]any) + nestedHooks := firstGroup["hooks"].([]any) + if len(nestedHooks) != 1 { + t.Fatalf("expected only custom Bash hook to remain, got %#v", nestedHooks) + } + nested := nestedHooks[0].(map[string]any) + if got := nested["command"]; got != "echo custom" { + t.Fatalf("expected custom hook to be preserved, got %#v", got) + } +} + +func TestInstallCursorHook_CreatesHooksFile(t *testing.T) { + hooksPath := filepath.Join(t.TempDir(), ".cursor", "hooks.json") + + changed, err := installCursorHook(hooksPath) + if err != nil { + t.Fatalf("installCursorHook() error = %v", err) + } + if !changed { + t.Fatal("expected install to create the Cursor hook") + } + + doc := readHooksTestJSONFile(t, hooksPath) + if got := doc["version"]; got != float64(1) { + t.Fatalf("expected version 1, got %#v", got) + } + + hooks, ok := doc["hooks"].(map[string]any) + if !ok { + t.Fatalf("expected hooks object, got %#v", doc["hooks"]) + } + preToolUse, ok := hooks["preToolUse"].([]any) + if !ok || len(preToolUse) != 1 { + t.Fatalf("expected one preToolUse hook, got %#v", hooks["preToolUse"]) + } +} + +func TestUninstallCursorHook_RemovesOnlyFenceHook(t *testing.T) { + hooksPath := filepath.Join(t.TempDir(), ".cursor", "hooks.json") + content := `{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "matcher": "Shell", + "command": "` + cursorHookCommand() + `" + }, + { + "matcher": "Read", + "command": "echo keep" + } + ] + }, + "theme": "dark" +}` + + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + t.Fatalf("os.MkdirAll() error = %v", err) + } + if err := os.WriteFile(hooksPath, []byte(content), 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + changed, err := uninstallCursorHook(hooksPath) + if err != nil { + t.Fatalf("uninstallCursorHook() error = %v", err) + } + if !changed { + t.Fatal("expected uninstall to remove the Cursor hook") + } + + doc := readHooksTestJSONFile(t, hooksPath) + if got := doc["theme"]; got != "dark" { + t.Fatalf("expected unrelated top-level settings to be preserved, got %#v", got) + } + + hooks := doc["hooks"].(map[string]any) + preToolUse := hooks["preToolUse"].([]any) + if len(preToolUse) != 1 { + t.Fatalf("expected one remaining preToolUse hook, got %d", len(preToolUse)) + } + + group := preToolUse[0].(map[string]any) + if got := group["command"]; got != "echo keep" { + t.Fatalf("expected custom hook to be preserved, got %#v", got) + } +} + +func TestHooksInstallCmd_UsesExplicitFile(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + + cmd := newHooksInstallCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--claude", "--file", settingsPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte("Installed Claude hook")) { + t.Fatalf("expected install output, got %q", stdout.String()) + } + + doc := readHooksTestJSONFile(t, settingsPath) + hooks := doc["hooks"].(map[string]any) + if _, ok := hooks["PreToolUse"]; !ok { + t.Fatalf("expected PreToolUse hooks in %q", settingsPath) + } +} + +func TestHooksInstallCmd_UsesFenceTemplateForClaude(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.local.json") + + cmd := newHooksInstallCmd() + cmd.SetArgs([]string{"--claude", "--file", settingsPath, "--template", "code"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } + + doc := readHooksTestJSONFile(t, settingsPath) + hooks := doc["hooks"].(map[string]any) + preToolUse := hooks["PreToolUse"].([]any) + group := preToolUse[0].(map[string]any) + nested := group["hooks"].([]any)[0].(map[string]any) + want := claudeHookCommandWithOptions(hookFenceOptions{TemplateName: "code"}) + if got := nested["command"]; got != want { + t.Fatalf("expected template-pinned hook command %q, got %#v", want, got) + } +} + +func TestHooksInstallCmd_UsesExplicitFileForCursor(t *testing.T) { + hooksPath := filepath.Join(t.TempDir(), ".cursor", "hooks.local.json") + + cmd := newHooksInstallCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--cursor", "--file", hooksPath}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } + + if !bytes.Contains(stdout.Bytes(), []byte("Installed Cursor hook")) { + t.Fatalf("expected install output, got %q", stdout.String()) + } + + doc := readHooksTestJSONFile(t, hooksPath) + hooks := doc["hooks"].(map[string]any) + if _, ok := hooks["preToolUse"]; !ok { + t.Fatalf("expected preToolUse hooks in %q", hooksPath) + } +} + +func readHooksTestJSONFile(t *testing.T, path string) map[string]any { + t.Helper() + + data, err := os.ReadFile(path) //nolint:gosec // test helper intentionally reads a caller-provided temp file + if err != nil { + t.Fatalf("os.ReadFile() error = %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + return doc +} diff --git a/cmd/fence/main.go b/cmd/fence/main.go index f3d483a..e07ff52 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -4,6 +4,7 @@ package main import ( "bufio" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -68,6 +69,20 @@ func main() { } os.Exit(exitCode) } + if len(os.Args) >= 2 && os.Args[1] == claudePreToolUseMode { + if err := runClaudePreToolUseMode(); err != nil { + fmt.Fprintf(os.Stderr, "[fence:hooks] %v\n", err) + os.Exit(2) + } + return + } + if len(os.Args) >= 2 && os.Args[1] == cursorPreToolUseMode { + if err := runCursorPreToolUseMode(); err != nil { + fmt.Fprintf(os.Stderr, "[fence:hooks] %v\n", err) + os.Exit(2) + } + return + } rootCmd := &cobra.Command{ Use: "fence [flags] -- [command...]", @@ -132,6 +147,7 @@ Configuration file format: rootCmd.AddCommand(newImportCmd()) rootCmd.AddCommand(newConfigCmd()) + rootCmd.AddCommand(newHooksCmd()) rootCmd.AddCommand(newCompletionCmd(rootCmd)) if err := rootCmd.Execute(); err != nil { @@ -234,7 +250,7 @@ func runCommand(cmd *cobra.Command, args []string) error { sandboxedCommand, err := manager.WrapCommand(command) if err != nil { - return fmt.Errorf("failed to wrap command: %w", err) + return presentWrapCommandError(err) } if debug { @@ -347,6 +363,20 @@ func runCommand(cmd *cobra.Command, args []string) error { return nil } +func presentWrapCommandError(err error) error { + var commandBlockedErr *sandbox.CommandBlockedError + if errors.As(err, &commandBlockedErr) { + return err + } + + var sshBlockedErr *sandbox.SSHBlockedError + if errors.As(err, &sshBlockedErr) { + return err + } + + return fmt.Errorf("failed to wrap command: %w", err) +} + func applyCLIConfigOverrides(cmd *cobra.Command, cfg *config.Config, forceNewSessionValue bool) *config.Config { if cfg == nil { cfg = config.Default() diff --git a/cmd/fence/main_test.go b/cmd/fence/main_test.go index b183944..c862d32 100644 --- a/cmd/fence/main_test.go +++ b/cmd/fence/main_test.go @@ -1,12 +1,45 @@ package main import ( + "errors" "os/exec" "testing" + "github.com/Use-Tusk/fence/internal/sandbox" "github.com/spf13/cobra" ) +func TestPresentWrapCommandError_PreservesCommandBlockedError(t *testing.T) { + err := presentWrapCommandError(&sandbox.CommandBlockedError{ + Command: "ls", + BlockedPrefix: "ls", + }) + + if got, want := err.Error(), `command blocked by sandbox command policy: "ls" matches "ls"`; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestPresentWrapCommandError_PreservesSSHBlockedError(t *testing.T) { + err := presentWrapCommandError(&sandbox.SSHBlockedError{ + Host: "example.com", + RemoteCommand: "rm -rf /", + Reason: "host matches denied pattern \"example.com\"", + }) + + if got, want := err.Error(), `SSH command blocked: host matches denied pattern "example.com" (host: example.com, command: rm -rf /)`; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestPresentWrapCommandError_WrapsNonPolicyError(t *testing.T) { + err := presentWrapCommandError(errors.New("boom")) + + if got, want := err.Error(), "failed to wrap command: boom"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + func TestStartCommandWithSignalProxy_CleanupIsIdempotent(t *testing.T) { execCmd := exec.Command("sh", "-c", "exit 0") cleanup, err := startCommandWithSignalProxy(execCmd) diff --git a/docs/agents.md b/docs/agents.md index bc745ec..d631005 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -55,12 +55,65 @@ You can use it like `fence -t code -- claude`. | Droid | `code` | - | | Pi | `code` | - | | Crush | `code` | - | +| GitHub Copilot | `code` | - | | Cursor Agent | `code-relaxed` | Node.js/undici doesn't respect HTTP_PROXY | These configs can drift as agents evolve. If you encounter false positives on blocked requests or want a CLI agent listed, please open an issue or PR. Note: On Linux, if OpenCode or Gemini CLI is installed via Linuxbrew, Landlock can block the Linuxbrew node binary unless you widen filesystem access. Installing OpenCode/Gemini under your home directory (e.g., via nvm or npm prefix) avoids this without relaxing the template. +## Hooks + +Hook-based wrapping exists for environments where Fence cannot transparently enforce +child-process, argv-aware exec policy on every descendant command after the +agent is already running, especially outside Linux. Instead of trying to catch +child execs after the fact, Fence can use the agent/editor hook system to +rewrite shell tool invocations up front so they run through Fence. + +Prefer whole-agent wrapping when possible, since it is the stronger isolation +model. This hook-based approach is the fallback when you need the agent itself +to stay outside Fence but still want shell commands sandboxed. + +`print` emits the hook snippet, and `install`/`uninstall` manage the default +settings file for that integration. + +If you want hook-invoked shell commands to use a specific Fence policy instead +of resolving config at runtime, generate or install the hook with +`--settings /path/to/fence.json` or `--template code`. + +Commands that already violate Fence command policy are denied directly at hook +time instead of being rewritten to a nested `fence -c ...` invocation. + +If the agent is already running inside Fence, the helper avoids launching a +second nested sandbox and only applies Fence's command policy at hook time. + +Claude Code uses `PreToolUse` for `Bash` and calls +`fence --claude-pre-tool-use`: + +```bash +fence hooks print --claude +fence hooks install --claude +fence hooks uninstall --claude +``` + +Default file: `~/.claude/settings.json` + +Cursor uses `preToolUse` for `Shell` and calls +`fence --cursor-pre-tool-use`: + +```bash +fence hooks print --cursor +fence hooks install --cursor +fence hooks uninstall --cursor +``` + +Default file: `~/.cursor/hooks.json` + +Cursor may also run Claude Code hook commands if Claude settings are present. +Fence handles that too by accepting either Cursor or Claude hook payloads. + +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 Fence includes additional "dangerous file protection" (writes blocked regardless of config) to reduce persistence and environment-tampering vectors like: