Skip to content

Commit ba5fd7e

Browse files
feat: Add safety approval before executing any command (#6)
Implements the safety approval system requested in issue #5. ## Summary Adds a comprehensive safety approval system that prompts users before executing any command with three dropdown options: - Yes: Execute this command - Yes, and don't ask again: Execute this and all future commands - No: Cancel command execution ## Changes - Add `RequireApproval` configuration setting in `tools.safety` - Implement interactive approval prompt with session-based tracking - Integrate approval system into `ToolEngine.ExecuteBash` method - Add new CLI commands for safety management - Update documentation with new configuration options - Safety approval enabled by default for security ## Testing - All safety commands tested and working - Build, format, and lint checks pass - Configuration management verified Fixes #5 Generated with [Claude Code](https://claude.ai/code) --------- Signed-off-by: Eden Reich <[email protected]> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Eden Reich <[email protected]>
1 parent b7cbcb5 commit ba5fd7e

File tree

7 files changed

+324
-38
lines changed

7 files changed

+324
-38
lines changed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ tools:
140140
- "^git log --oneline -n [0-9]+$"
141141
- "^docker ps$"
142142
- "^kubectl get pods$"
143+
safety:
144+
require_approval: true # Prompt user before executing any command
143145
compact:
144146
output_dir: ".infer" # Directory for compact command exports (default: project root/.infer)
145147
```
@@ -154,6 +156,7 @@ compact:
154156
- `chat`: Interactive chat with model selection and tool support
155157
- `/compact`: Export conversation to markdown file
156158
- `tools`: Tool management and execution
159+
- `safety enable/disable/status`: Manage safety approval settings
157160
- `version`: Version information
158161

159162
## Dependencies

README.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ Validate if a command is whitelisted without executing it.
199199
#### `infer tools exec <command>`
200200
Execute a whitelisted command directly.
201201

202+
#### `infer tools safety`
203+
Manage safety approval settings for command execution.
204+
205+
#### `infer tools safety enable`
206+
Enable safety approval prompts before executing commands.
207+
208+
#### `infer tools safety disable`
209+
Disable safety approval prompts (commands execute immediately).
210+
211+
#### `infer tools safety status`
212+
Show current safety approval status.
213+
202214
**Options:**
203215
- `-f, --format`: Output format (text, json)
204216

@@ -216,6 +228,11 @@ infer tools validate "ls -la"
216228
# Execute a command
217229
infer tools exec "pwd"
218230

231+
# Manage safety settings
232+
infer tools safety enable
233+
infer tools safety status
234+
infer tools safety disable
235+
219236
# Get tool definitions for LLMs
220237
infer tools llm list --format=json
221238
```
@@ -271,6 +288,8 @@ tools:
271288
- "^git log --oneline -n [0-9]+$"
272289
- "^docker ps$"
273290
- "^kubectl get pods$"
291+
safety:
292+
require_approval: true # Prompt user before executing any command
274293
compact:
275294
output_dir: ".infer" # Directory for compact command exports
276295
```
@@ -290,6 +309,7 @@ compact:
290309
- **tools.enabled**: Enable/disable tool execution for LLMs (default: false)
291310
- **tools.whitelist.commands**: List of allowed commands (supports arguments)
292311
- **tools.whitelist.patterns**: Regex patterns for complex command validation
312+
- **tools.safety.require_approval**: Prompt user before executing any command (default: true)
293313
294314
**Compact Settings:**
295315
- **compact.output_dir**: Directory for compact command exports (default: ".infer")
@@ -308,6 +328,7 @@ The Inference Gateway CLI includes a secure tool execution system that allows LL
308328

309329
- **Whitelist-Only Execution**: Only explicitly allowed commands can be executed
310330
- **Command Validation**: Support for exact matches and regex patterns
331+
- **Safety Approval System**: Interactive prompts before command execution (enabled by default)
311332
- **Timeout Protection**: Commands timeout after 30 seconds
312333
- **Secure Environment**: Tools run with CLI user permissions
313334
- **Disabled by Default**: Tools must be explicitly enabled
@@ -331,11 +352,23 @@ The Inference Gateway CLI includes a secure tool execution system that allows LL
331352
infer chat
332353
```
333354

334-
3. **Example interaction**:
355+
3. **Example interaction with safety approval**:
335356
```
336357
You: Can you list the files in this directory?
337358
338-
Model: 🔧 Calling tool: Bash with arguments: {"command":"ls"}
359+
Model: I'll list the files in this directory for you.
360+
361+
⚠️ Command execution approval required:
362+
Command: ls
363+
364+
Please select an option:
365+
▶ Yes - Execute this command
366+
Yes, and don't ask again - Execute this and all future commands
367+
No - Cancel command execution
368+
369+
[User selects "Yes - Execute this command"]
370+
371+
🔧 Calling tool: Bash with arguments: {"command":"ls"}
339372
✅ Tool result:
340373
Command: ls
341374
Exit Code: 0
@@ -362,6 +395,8 @@ tools:
362395
patterns:
363396
- "^git status$"
364397
- "^your-pattern.*$"
398+
safety:
399+
require_approval: true # Set to false to disable safety prompts
365400
```
366401

367402
### Troubleshooting Tools
@@ -375,6 +410,11 @@ tools:
375410
- Enable tools: `infer tools enable`
376411
- Verify config: `tools.enabled: true` in `~/.infer.yaml`
377412

413+
**Safety approval prompts:**
414+
- Disable safety prompts: `infer tools safety disable`
415+
- Enable safety prompts: `infer tools safety enable`
416+
- Check current status: `infer tools safety status`
417+
378418
## Examples
379419

380420
### Complete Workflow with Tools
@@ -410,6 +450,11 @@ infer tools exec "pwd"
410450
411451
# Disable tool execution
412452
infer tools disable
453+
454+
# Manage safety settings
455+
infer tools safety enable # Enable safety approval prompts
456+
infer tools safety status # Check current safety status
457+
infer tools safety disable # Disable safety approval prompts
413458
```
414459

415460
### Configuration Management

cmd/chat.go

Lines changed: 74 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -131,47 +131,80 @@ func startChatSession() error {
131131

132132
fmt.Printf("\n%s: ", selectedModel)
133133

134-
var wg sync.WaitGroup
135-
var spinnerActive = true
136-
var mu sync.Mutex
134+
var totalMetrics *ChatMetrics
135+
maxIterations := 10
136+
for iteration := 0; iteration < maxIterations; iteration++ {
137+
var wg sync.WaitGroup
138+
var spinnerActive = true
139+
var mu sync.Mutex
137140

138-
wg.Add(1)
139-
go func() {
140-
defer wg.Done()
141-
showSpinner(&spinnerActive, &mu)
142-
}()
141+
wg.Add(1)
142+
go func() {
143+
defer wg.Done()
144+
showSpinner(&spinnerActive, &mu)
145+
}()
143146

144-
assistantMessage, assistantToolCalls, metrics, err := sendStreamingChatCompletion(cfg, selectedModel, conversation, &spinnerActive, &mu)
147+
assistantMessage, assistantToolCalls, metrics, err := sendStreamingChatCompletion(cfg, selectedModel, conversation, &spinnerActive, &mu)
145148

146-
wg.Wait()
149+
wg.Wait()
147150

148-
if err != nil {
149-
fmt.Printf("❌ Error: %v\n", err)
150-
conversation = conversation[:len(conversation)-1]
151-
continue
152-
}
151+
if err != nil {
152+
fmt.Printf("❌ Error: %v\n", err)
153+
conversation = conversation[:len(conversation)-1]
154+
break
155+
}
153156

154-
assistantMsg := sdk.Message{
155-
Role: sdk.Assistant,
156-
Content: assistantMessage,
157-
}
158-
if len(assistantToolCalls) > 0 {
159-
assistantMsg.ToolCalls = &assistantToolCalls
160-
}
161-
conversation = append(conversation, assistantMsg)
157+
if totalMetrics == nil {
158+
totalMetrics = metrics
159+
} else if metrics != nil {
160+
totalMetrics.Duration += metrics.Duration
161+
if totalMetrics.Usage != nil && metrics.Usage != nil {
162+
totalMetrics.Usage.PromptTokens += metrics.Usage.PromptTokens
163+
totalMetrics.Usage.CompletionTokens += metrics.Usage.CompletionTokens
164+
totalMetrics.Usage.TotalTokens += metrics.Usage.TotalTokens
165+
}
166+
}
162167

163-
for _, toolCall := range assistantToolCalls {
164-
toolResult, err := executeToolCall(cfg, toolCall.Function.Name, toolCall.Function.Arguments)
165-
if err == nil {
166-
conversation = append(conversation, sdk.Message{
167-
Role: sdk.Tool,
168-
Content: toolResult,
169-
ToolCallId: &toolCall.Id,
170-
})
168+
assistantMsg := sdk.Message{
169+
Role: sdk.Assistant,
170+
Content: assistantMessage,
171+
}
172+
if len(assistantToolCalls) > 0 {
173+
assistantMsg.ToolCalls = &assistantToolCalls
174+
}
175+
conversation = append(conversation, assistantMsg)
176+
177+
if len(assistantToolCalls) == 0 {
178+
break
179+
}
180+
181+
toolExecutionFailed := false
182+
for _, toolCall := range assistantToolCalls {
183+
toolResult, err := executeToolCall(cfg, toolCall.Function.Name, toolCall.Function.Arguments)
184+
if err != nil {
185+
fmt.Printf("❌ Tool execution failed: %v\n", err)
186+
toolExecutionFailed = true
187+
break
188+
} else {
189+
fmt.Printf("✅ Tool result:\n%s\n", toolResult)
190+
conversation = append(conversation, sdk.Message{
191+
Role: sdk.Tool,
192+
Content: toolResult,
193+
ToolCallId: &toolCall.Id,
194+
})
195+
}
196+
}
197+
198+
if toolExecutionFailed {
199+
conversation = conversation[:len(conversation)-1]
200+
fmt.Printf("\n❌ Tool execution was cancelled. Please try a different request.\n")
201+
break
171202
}
203+
204+
fmt.Printf("\n%s: ", selectedModel)
172205
}
173206

174-
displayChatMetrics(metrics)
207+
displayChatMetrics(totalMetrics)
175208
fmt.Print("\n\n")
176209
}
177210

@@ -396,7 +429,7 @@ func sendStreamingChatCompletion(cfg *config.Config, model string, messages []Ch
396429
return "", nil, nil, err
397430
}
398431

399-
finalToolCalls := executeRemainingToolCalls(cfg, result.activeToolCalls, result.toolCalls)
432+
finalToolCalls := result.toolCalls
400433

401434
duration := time.Since(startTime)
402435
metrics := &ChatMetrics{
@@ -533,7 +566,14 @@ func handleToolCallDelta(deltaToolCall sdk.ChatCompletionMessageToolCallChunk, r
533566

534567
func handleStreamEnd(result *streamingResult) {
535568
stopSpinner(result)
536-
result.toolCalls = executeToolCalls(result.cfg, result.activeToolCalls)
569+
var toolCalls []sdk.ChatCompletionMessageToolCall
570+
for _, toolCall := range result.activeToolCalls {
571+
if toolCall.Function.Name != "" {
572+
fmt.Printf(" with arguments: %s\n", toolCall.Function.Arguments)
573+
toolCalls = append(toolCalls, *toolCall)
574+
}
575+
}
576+
result.toolCalls = toolCalls
537577
}
538578

539579
func handleStreamError(event sdk.SSEvent, result *streamingResult) error {

cmd/tools.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,74 @@ var toolsLLMInvokeCmd = &cobra.Command{
224224
},
225225
}
226226

227+
var toolsSafetyCmd = &cobra.Command{
228+
Use: "safety",
229+
Short: "Manage safety approval settings",
230+
Long: "Configure safety approval requirements for command execution.",
231+
}
232+
233+
var toolsSafetyEnableCmd = &cobra.Command{
234+
Use: "enable",
235+
Short: "Enable safety approval for command execution",
236+
RunE: func(cmd *cobra.Command, args []string) error {
237+
configPath, _ := cmd.Flags().GetString("config")
238+
cfg, err := config.LoadConfig(configPath)
239+
if err != nil {
240+
return fmt.Errorf("failed to load config: %w", err)
241+
}
242+
243+
cfg.Tools.Safety.RequireApproval = true
244+
245+
if err := cfg.SaveConfig(configPath); err != nil {
246+
return fmt.Errorf("failed to save config: %w", err)
247+
}
248+
249+
fmt.Println("Safety approval enabled successfully")
250+
return nil
251+
},
252+
}
253+
254+
var toolsSafetyDisableCmd = &cobra.Command{
255+
Use: "disable",
256+
Short: "Disable safety approval for command execution",
257+
RunE: func(cmd *cobra.Command, args []string) error {
258+
configPath, _ := cmd.Flags().GetString("config")
259+
cfg, err := config.LoadConfig(configPath)
260+
if err != nil {
261+
return fmt.Errorf("failed to load config: %w", err)
262+
}
263+
264+
cfg.Tools.Safety.RequireApproval = false
265+
266+
if err := cfg.SaveConfig(configPath); err != nil {
267+
return fmt.Errorf("failed to save config: %w", err)
268+
}
269+
270+
fmt.Println("Safety approval disabled successfully")
271+
return nil
272+
},
273+
}
274+
275+
var toolsSafetyStatusCmd = &cobra.Command{
276+
Use: "status",
277+
Short: "Show safety approval status",
278+
RunE: func(cmd *cobra.Command, args []string) error {
279+
configPath, _ := cmd.Flags().GetString("config")
280+
cfg, err := config.LoadConfig(configPath)
281+
if err != nil {
282+
return fmt.Errorf("failed to load config: %w", err)
283+
}
284+
285+
status := "disabled"
286+
if cfg.Tools.Safety.RequireApproval {
287+
status = "enabled"
288+
}
289+
290+
fmt.Printf("Safety approval: %s\n", status)
291+
return nil
292+
},
293+
}
294+
227295
func init() {
228296
rootCmd.AddCommand(toolsCmd)
229297

@@ -233,10 +301,15 @@ func init() {
233301
toolsCmd.AddCommand(toolsExecCmd)
234302
toolsCmd.AddCommand(toolsValidateCmd)
235303
toolsCmd.AddCommand(toolsLLMCmd)
304+
toolsCmd.AddCommand(toolsSafetyCmd)
236305

237306
toolsLLMCmd.AddCommand(toolsLLMListCmd)
238307
toolsLLMCmd.AddCommand(toolsLLMInvokeCmd)
239308

309+
toolsSafetyCmd.AddCommand(toolsSafetyEnableCmd)
310+
toolsSafetyCmd.AddCommand(toolsSafetyDisableCmd)
311+
toolsSafetyCmd.AddCommand(toolsSafetyStatusCmd)
312+
240313
toolsExecCmd.Flags().StringP("format", "f", "text", "Output format (text, json)")
241314
toolsLLMListCmd.Flags().StringP("format", "f", "text", "Output format (text, json)")
242315
}

config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type OutputConfig struct {
3333
type ToolsConfig struct {
3434
Enabled bool `yaml:"enabled"`
3535
Whitelist ToolWhitelistConfig `yaml:"whitelist"`
36+
Safety SafetyConfig `yaml:"safety"`
3637
}
3738

3839
// ToolWhitelistConfig contains whitelisted commands and patterns
@@ -41,6 +42,11 @@ type ToolWhitelistConfig struct {
4142
Patterns []string `yaml:"patterns"`
4243
}
4344

45+
// SafetyConfig contains safety approval settings
46+
type SafetyConfig struct {
47+
RequireApproval bool `yaml:"require_approval"`
48+
}
49+
4450
// CompactConfig contains settings for compact command
4551
type CompactConfig struct {
4652
OutputDir string `yaml:"output_dir"`
@@ -72,6 +78,9 @@ func DefaultConfig() *Config {
7278
"^kubectl get pods$",
7379
},
7480
},
81+
Safety: SafetyConfig{
82+
RequireApproval: true,
83+
},
7584
},
7685
Compact: CompactConfig{
7786
OutputDir: ".infer",

0 commit comments

Comments
 (0)