Skip to content

Commit 2cf8d47

Browse files
committed
v0.7.0: dangerous operations approval system
Add configurable risk-classification and user approval for high-risk shell commands when running outside sandbox mode. NEW: internal/danger/ package - RiskClass type with 8 levels: safe → blocked - Token-level classifier (quotes, pipes, &&, ||, ;, redirects) - DangerousConfig with per-class actions, allowlist, denylist - 52 tests covering every class, edge cases, and config paths NEW: Shell tool approval prompt - Optional 'description' field in JSON schema for agent intent - /dev/tty prompt with Approve/Deny/Trust/Context responses - Session-level trust cache (in-memory, not persisted) - Non-interactive fallback when /dev/tty unavailable - 13 tests with mocked TTY CHANGED: - config/loader.go: DangerousConfig in FileConfig + ResolvedConfig - main.go: builtinTools() accepts DangerousConfig, all callers updated - kode init: default template includes dangerous section - docs/CLI.md: dangerous operations reference table - docs/SECURITY.md: full approval system documentation
1 parent f819d9d commit 2cf8d47

10 files changed

Lines changed: 1769 additions & 25 deletions

File tree

cmd/kode/main.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/BackendStack21/kode"
1717
"github.com/BackendStack21/kode/internal/config"
18+
"github.com/BackendStack21/kode/internal/danger"
1819
"github.com/BackendStack21/kode/internal/llm"
1920
"github.com/BackendStack21/kode/internal/render"
2021
"github.com/BackendStack21/kode/internal/session"
@@ -303,7 +304,20 @@ const defaultConfigTemplate = `{
303304
"sandbox_cpus": "",
304305
"sandbox_user": "",
305306
"sandbox_env": {},
306-
"sandbox_volumes": []
307+
"sandbox_volumes": [],
308+
"dangerous": {
309+
"action": "prompt",
310+
"non_interactive": "allow",
311+
"classes": {
312+
"destructive": "deny",
313+
"network_egress": "prompt",
314+
"code_execution": "prompt",
315+
"install": "prompt",
316+
"system_write": "prompt"
317+
},
318+
"allowlist": [],
319+
"denylist": []
320+
}
307321
}`
308322

309323
// initConfig creates a new config file (local ./kode.json or global ~/kode/config.json).
@@ -524,7 +538,7 @@ func run(args []string) error {
524538

525539
// Sandbox setup
526540
var sandboxCleanup func() error
527-
tools := builtinTools()
541+
tools := builtinTools(resolved.Dangerous)
528542

529543
if resolved.Sandbox {
530544
cleanup, err := setupSandbox(tools, sbCfg)
@@ -706,9 +720,11 @@ func setupSandbox(tools []kode.Tool, cfg sandboxConfig) (func() error, error) {
706720
return cleanup, nil
707721
}
708722

709-
func builtinTools() []kode.Tool {
723+
func builtinTools(dc danger.DangerousConfig) []kode.Tool {
710724
return []kode.Tool{
711-
&shellTool{},
725+
&shellTool{
726+
dangerousConfig: dc,
727+
},
712728
}
713729
}
714730

@@ -780,10 +796,6 @@ func continueCmd(args []string) error {
780796
fmt.Fprintf(os.Stderr, "kode: continuing session %s (turn %d → %d)\n",
781797
sess.ID, sess.Turns, sess.Turns+1)
782798

783-
// Build tools
784-
tools := builtinTools()
785-
var sandboxCleanup func() error
786-
787799
// Resolve config (no CLI flags for continue — uses session's model)
788800
resolved := config.LoadConfig(config.CLIFlags{Model: sess.Model})
789801

@@ -793,6 +805,10 @@ func continueCmd(args []string) error {
793805
fmt.Fprintf(os.Stderr, "kode: session was sandboxed — enabling sandbox for this continuation\n")
794806
}
795807

808+
// Build tools
809+
tools := builtinTools(resolved.Dangerous)
810+
var sandboxCleanup func() error
811+
796812
systemMessage := resolved.System
797813
if systemMessage == "" {
798814
systemMessage = defaultSystem

cmd/kode/main_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"testing"
1212

1313
"github.com/BackendStack21/kode/internal/config"
14+
"github.com/BackendStack21/kode/internal/danger"
1415
)
1516

1617
func TestGetVersion_LdFlagsOverride(t *testing.T) {
@@ -193,7 +194,7 @@ func TestRun_NoAPIKey(t *testing.T) {
193194
}
194195

195196
func TestBuiltinTools(t *testing.T) {
196-
tools := builtinTools()
197+
tools := builtinTools(danger.DangerousConfig{})
197198
if len(tools) == 0 {
198199
t.Fatal("builtinTools() returned empty slice")
199200
}

cmd/kode/repl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func replCmd(args []string) error {
5454
}
5555

5656
// Build tools
57-
tools := builtinTools()
57+
tools := builtinTools(resolved.Dangerous)
5858
var sandboxCleanup func() error
5959

6060
if resolved.Sandbox {

cmd/kode/shell.go

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package main
22

33
import (
4+
"bufio"
45
"bytes"
56
"encoding/json"
67
"fmt"
8+
"os"
79
"os/exec"
810
"strings"
11+
12+
"github.com/BackendStack21/kode/internal/danger"
913
)
1014

1115
// shellTool is kode's built-in tool that lets the agent run shell commands.
@@ -33,11 +37,24 @@ import (
3337
// command string as JSON; the shell tool executes it as-is.
3438
// - Error output is merged into stdout (stderr follows stdout in output).
3539
// - Empty output returns "(no output)" so the LLM always gets a response.
40+
// - Commands are classified by risk (see internal/danger). High-risk
41+
// commands in non-sandboxed mode prompt the user for approval via /dev/tty.
3642
type shellTool struct {
3743
// containerName, when set, routes commands through "docker exec"
3844
// into this container. Set by setupSandbox() when --sandbox is active.
3945
// When empty, commands run directly on the host.
4046
containerName string
47+
48+
// dangerousConfig controls per-class actions and allow/denylists.
49+
dangerousConfig danger.DangerousConfig
50+
51+
// trustedClasses caches user-approved risk classes for this process.
52+
// Set when user presses T (trust this session) at the prompt.
53+
trustedClasses map[danger.RiskClass]bool
54+
55+
// ttyPath is the path to the terminal device for approval prompts.
56+
// Overridden in tests to mock user input.
57+
ttyPath string
4158
}
4259

4360
func (t *shellTool) Name() string { return "shell" }
@@ -46,7 +63,10 @@ func (t *shellTool) Description() string {
4663
return `Run a shell command and return its output.
4764
Use for: reading files, listing directories, running tests, building code, and git operations.
4865
In sandbox mode (--sandbox), commands run inside the Docker container with restricted permissions.
49-
In host mode (default), commands run with the same permissions as the kode process.`
66+
In host mode (default), commands run with the same permissions as the kode process.
67+
68+
Risk classes: safe, local_write, system_write, destructive, network_egress, code_execution, install, blocked
69+
High-risk operations may prompt for approval (configurable via dangerous section in kode.json).`
5070
}
5171

5272
func (t *shellTool) Schema() any {
@@ -57,6 +77,10 @@ func (t *shellTool) Schema() any {
5777
"type": "string",
5878
"description": "The shell command to execute. Supports pipes, redirects, and multi-line scripts.",
5979
},
80+
"description": map[string]any{
81+
"type": "string",
82+
"description": "Optional: explain what this command does and why. Shown in the approval prompt for high-risk operations.",
83+
},
6084
},
6185
"required": []string{"command"},
6286
}
@@ -67,7 +91,8 @@ func (t *shellTool) Schema() any {
6791
// Both stdout and stderr are captured and merged into the return string.
6892
func (t *shellTool) Call(args string) (string, error) {
6993
var input struct {
70-
Command string `json:"command"`
94+
Command string `json:"command"`
95+
Description string `json:"description,omitempty"`
7196
}
7297
if err := json.Unmarshal([]byte(args), &input); err != nil {
7398
return "", fmt.Errorf("shell: parse args: %w", err)
@@ -76,6 +101,11 @@ func (t *shellTool) Call(args string) (string, error) {
76101
return "", fmt.Errorf("shell: empty command")
77102
}
78103

104+
// Check approval before executing
105+
if err := t.checkApproval(input.Command, input.Description); err != nil {
106+
return "", err
107+
}
108+
79109
cmd := t.buildCmd(input.Command)
80110

81111
var outBuf, errBuf bytes.Buffer
@@ -99,6 +129,88 @@ func (t *shellTool) Call(args string) (string, error) {
99129
return output, nil
100130
}
101131

132+
// checkApproval classifies the command and prompts the user if needed.
133+
func (t *shellTool) checkApproval(cmd, description string) error {
134+
// Check allowlist/denylist + risk class via dangerous config
135+
action := t.dangerousConfig.ActionForCommand(cmd)
136+
137+
switch action {
138+
case danger.Allow:
139+
return nil
140+
case danger.Deny:
141+
return fmt.Errorf("operation denied by configuration: %s", cmd)
142+
case danger.Prompt:
143+
return t.promptUser(cmd, description)
144+
default:
145+
return nil
146+
}
147+
}
148+
149+
// promptUser opens /dev/tty and asks the user to approve the command.
150+
func (t *shellTool) promptUser(cmd, description string) error {
151+
cls := danger.Classify(cmd)
152+
153+
// Check session trust cache
154+
if t.trustedClasses != nil && t.trustedClasses[cls] {
155+
return nil
156+
}
157+
158+
// Open /dev/tty for interactive approval
159+
ttyPath := t.ttyPath
160+
if ttyPath == "" {
161+
ttyPath = "/dev/tty"
162+
}
163+
tty, err := os.OpenFile(ttyPath, os.O_RDWR, 0)
164+
if err != nil {
165+
// Non-interactive: use configured fallback
166+
action := t.dangerousConfig.NonInteractiveAction()
167+
if action == danger.Deny {
168+
return fmt.Errorf("operation denied (non-interactive mode): %s", cmd)
169+
}
170+
return nil
171+
}
172+
defer tty.Close()
173+
174+
// Build the prompt
175+
fmt.Fprintf(os.Stderr, "\n⚠️ \033[1mRisk:\033[0m %s\n", cls)
176+
fmt.Fprintf(os.Stderr, " \033[1mRun:\033[0m %s\n", cmd)
177+
if description != "" {
178+
fmt.Fprintf(os.Stderr, " \033[1mWhy:\033[0m %s\n", description)
179+
}
180+
fmt.Fprint(os.Stderr, "\n [A]pprove [D]eny [?] Context [T]rust session: ")
181+
182+
// Read a single line of input from the TTY
183+
reader := bufio.NewReader(tty)
184+
line, err := reader.ReadString('\n')
185+
if err != nil {
186+
return fmt.Errorf("approval prompt error: %w", err)
187+
}
188+
line = strings.TrimSpace(strings.ToLower(line))
189+
190+
switch line {
191+
case "a", "approve":
192+
return nil
193+
case "t", "trust":
194+
// Cache this risk class for the session
195+
if t.trustedClasses == nil {
196+
t.trustedClasses = make(map[danger.RiskClass]bool)
197+
}
198+
t.trustedClasses[cls] = true
199+
return nil
200+
case "?", "context":
201+
fmt.Fprintf(tty, "\n Command: %s\n", cmd)
202+
fmt.Fprintf(tty, " Risk class: %s\n", cls)
203+
if description != "" {
204+
fmt.Fprintf(tty, " Description: %s\n", description)
205+
}
206+
fmt.Fprintf(tty, " Trust this class: %v\n", t.trustedClasses[cls])
207+
// Re-prompt
208+
return t.promptUser(cmd, description)
209+
default:
210+
return fmt.Errorf("operation denied by user: %s", cmd)
211+
}
212+
}
213+
102214
// buildCmd constructs the exec.Cmd for the given shell command.
103215
//
104216
// When sandbox mode is active (containerName is non-empty), the command

0 commit comments

Comments
 (0)