Skip to content

Commit b893814

Browse files
committed
feat: security integration for native tools (read_file, write_file, search_files, patch, browser)
Add danger classification to all native tools so they follow the same security rules as the shell tool (allow/prompt/deny per risk class): - ClassifyPath() — maps filesystem paths to risk classes (local_write, system_write, destructive) matching shell tool semantics - ClassifyURL() — maps browser URLs to network_egress or system_write (for internal IPs) - CheckOperation() — unified approval gate with TTY prompt and session trust cache, matching the shell tool's interactive flow - promptForOperation() — reusable TTY prompt showing tool name, risk class, and target path/URL Each tool now checks before executing: - read_file → ClassifyPath(path) → deny/prompt on system paths - write_file → ClassifyPath(path) → prompt on /etc, /root, ~/.ssh, etc. - search_files → ClassifyPath(search_dir) → same as read_file - patch → ClassifyPath(path) → same as write_file - browser → ClassifyURL(url) → prompt on external URLs, deny/prompt on internal IPs The same config (dangerous.classes, dangerous.action, dangerous.non_interactive) controls all tools.
1 parent 04c6519 commit b893814

4 files changed

Lines changed: 193 additions & 14 deletions

File tree

cmd/kode/browser_tool.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"regexp"
1010
"strings"
1111
"sync"
12+
13+
"github.com/BackendStack21/kode/internal/danger"
1214
)
1315

1416
// ── Regex helpers for HTML parsing (zero-dep) ─────────────────────────
@@ -55,14 +57,17 @@ type browserState struct {
5557
// ── Browser Tool ──────────────────────────────────────────────────────
5658

5759
type browserTool struct {
58-
state *browserState
59-
client *http.Client
60+
state *browserState
61+
client *http.Client
62+
dangerousConfig danger.DangerousConfig
63+
trustedClasses map[danger.RiskClass]bool
6064
}
6165

62-
func newBrowserTool() *browserTool {
66+
func newBrowserTool(dc danger.DangerousConfig) *browserTool {
6367
return &browserTool{
64-
state: &browserState{nextRef: 1},
65-
client: &http.Client{},
68+
state: &browserState{nextRef: 1},
69+
client: &http.Client{},
70+
dangerousConfig: dc,
6671
}
6772
}
6873

@@ -169,6 +174,14 @@ func (t *browserTool) doNavigate(rawURL string) (string, error) {
169174
req.Header.Set("User-Agent", "kode-browser/0.1")
170175
req.Header.Set("Accept", "text/html,application/xhtml+xml")
171176

177+
// Security: classify and check browser operation
178+
risk := danger.ClassifyURL(rawURL)
179+
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
180+
Name: "browser", Resource: rawURL, Risk: risk,
181+
}, t.trustedClasses); err != nil {
182+
return jsonError(err.Error())
183+
}
184+
172185
resp, err := t.client.Do(req)
173186
if err != nil {
174187
return jsonError(fmt.Sprintf("cannot fetch %q: %v", rawURL, err))

cmd/kode/file_tool.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import (
99
"regexp"
1010
"sort"
1111
"strings"
12+
13+
"github.com/BackendStack21/kode/internal/danger"
1214
)
1315

1416
// ── ReadFile Tool ──────────────────────────────────────────────────────
1517

1618
const maxLines = 2000
1719

18-
type readFileTool struct{}
20+
type readFileTool struct {
21+
dangerousConfig danger.DangerousConfig
22+
}
1923

2024
func (t *readFileTool) Name() string { return "read_file" }
2125

@@ -82,6 +86,14 @@ func (t *readFileTool) Call(argsJSON string) (string, error) {
8286
args.Limit = maxLines
8387
}
8488

89+
// Security: check if this path requires approval
90+
risk := danger.ClassifyPath(args.Path)
91+
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
92+
Name: "read_file", Resource: args.Path, Risk: risk,
93+
}, nil); err != nil {
94+
return jsonError(err.Error())
95+
}
96+
8597
// Check file exists and is not a directory
8698
info, err := os.Stat(args.Path)
8799
if err != nil {
@@ -127,7 +139,10 @@ func (t *readFileTool) Call(argsJSON string) (string, error) {
127139

128140
// ── WriteFile Tool ─────────────────────────────────────────────────────
129141

130-
type writeFileTool struct{}
142+
type writeFileTool struct {
143+
dangerousConfig danger.DangerousConfig
144+
trustedClasses map[danger.RiskClass]bool
145+
}
131146

132147
func (t *writeFileTool) Name() string { return "write_file" }
133148

@@ -174,6 +189,14 @@ func (t *writeFileTool) Call(argsJSON string) (string, error) {
174189
return jsonError("path is required")
175190
}
176191

192+
// Security: classify and check write operation
193+
risk := danger.ClassifyPath(args.Path)
194+
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
195+
Name: "write_file", Resource: args.Path, Risk: risk,
196+
}, t.trustedClasses); err != nil {
197+
return jsonError(err.Error())
198+
}
199+
177200
// Create parent directories
178201
dir := filepath.Dir(args.Path)
179202
if dir != "." && dir != "/" {
@@ -196,7 +219,9 @@ func (t *writeFileTool) Call(argsJSON string) (string, error) {
196219

197220
const maxMatches = 50
198221

199-
type searchFilesTool struct{}
222+
type searchFilesTool struct {
223+
dangerousConfig danger.DangerousConfig
224+
}
200225

201226
func (t *searchFilesTool) Name() string { return "search_files" }
202227

@@ -274,6 +299,14 @@ func (t *searchFilesTool) Call(argsJSON string) (string, error) {
274299
args.Limit = maxMatches
275300
}
276301

302+
// Security: check search path
303+
risk := danger.ClassifyPath(args.Path)
304+
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
305+
Name: "search_files", Resource: args.Path, Risk: risk,
306+
}, nil); err != nil {
307+
return jsonError(err.Error())
308+
}
309+
277310
switch args.Target {
278311
case "content":
279312
return t.searchContent(args)
@@ -423,7 +456,10 @@ func (t *searchFilesTool) searchFiles(args searchFilesArgs) (string, error) {
423456

424457
// ── Patch Tool ─────────────────────────────────────────────────────────
425458

426-
type patchTool struct{}
459+
type patchTool struct {
460+
dangerousConfig danger.DangerousConfig
461+
trustedClasses map[danger.RiskClass]bool
462+
}
427463

428464
func (t *patchTool) Name() string { return "patch" }
429465

@@ -483,6 +519,14 @@ func (t *patchTool) Call(argsJSON string) (string, error) {
483519
return jsonError("old_string is required")
484520
}
485521

522+
// Security: classify and check patch operation
523+
risk := danger.ClassifyPath(args.Path)
524+
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
525+
Name: "patch", Resource: args.Path, Risk: risk,
526+
}, t.trustedClasses); err != nil {
527+
return jsonError(err.Error())
528+
}
529+
486530
// Read the file
487531
data, err := os.ReadFile(args.Path)
488532
if err != nil {

cmd/kode/main.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -940,11 +940,11 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager) []kode.Too
940940
kodePath: os.Args[0],
941941
timeout: 120 * time.Second,
942942
},
943-
&readFileTool{},
944-
&writeFileTool{},
945-
&searchFilesTool{},
946-
&patchTool{},
947-
newBrowserTool(),
943+
&readFileTool{dangerousConfig: dc},
944+
&writeFileTool{dangerousConfig: dc},
945+
&searchFilesTool{dangerousConfig: dc},
946+
&patchTool{dangerousConfig: dc},
947+
newBrowserTool(dc),
948948
}
949949

950950
if sm != nil {

internal/danger/classifier.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
package danger
99

1010
import (
11+
"bufio"
12+
"fmt"
13+
"os"
14+
"path/filepath"
1115
"strings"
1216
)
1317

@@ -36,6 +40,65 @@ const (
3640
Deny Action = "deny"
3741
)
3842

43+
// ── Tool Operation ─────────────────────────────────────────────────────
44+
45+
// ToolOperation describes a native tool call for approval checking.
46+
type ToolOperation struct {
47+
Name string
48+
Resource string
49+
Risk RiskClass
50+
}
51+
52+
// ── Path-based classification ──────────────────────────────────────────
53+
54+
// ClassifyPath returns a RiskClass for a filesystem path.
55+
// /tmp/*, working directory → local_write; /etc/*, /root/* → system_write;
56+
// /boot/*, /dev/*, /sys/* → destructive; home sensitive dirs → system_write.
57+
func ClassifyPath(path string) RiskClass {
58+
abs, err := filepath.Abs(path)
59+
if err != nil {
60+
return SystemWrite
61+
}
62+
abs = filepath.Clean(abs)
63+
64+
for _, prefix := range []string{"/boot", "/dev", "/proc", "/sys", "/mnt", "/media"} {
65+
if strings.HasPrefix(abs, prefix) {
66+
return Destructive
67+
}
68+
}
69+
for _, prefix := range []string{"/etc", "/root", "/var", "/run", "/lib", "/usr"} {
70+
if strings.HasPrefix(abs, prefix) {
71+
return SystemWrite
72+
}
73+
}
74+
home, _ := os.UserHomeDir()
75+
if home != "" {
76+
for _, sub := range []string{"/.ssh", "/.config", "/.gnupg", "/.aws", "/.kube",
77+
"/.docker", "/.gitconfig", "/.env"} {
78+
if strings.HasPrefix(abs, home+sub) {
79+
return SystemWrite
80+
}
81+
}
82+
}
83+
return LocalWrite
84+
}
85+
86+
// ClassifyURL returns a RiskClass for a browser URL.
87+
// Internal IPs → system_write; external → network_egress.
88+
func ClassifyURL(rawURL string) RiskClass {
89+
for _, prefix := range []string{
90+
"http://127.0.0.1", "http://localhost", "http://10.",
91+
"http://172.", "http://192.168.", "http://[::1]",
92+
"https://127.0.0.1", "https://localhost",
93+
"https://10.", "https://172.", "https://192.168.",
94+
} {
95+
if strings.HasPrefix(rawURL, prefix) {
96+
return SystemWrite
97+
}
98+
}
99+
return NetworkEgress
100+
}
101+
39102
// ── Config ─────────────────────────────────────────────────────────────
40103

41104
// DangerousConfig defines how dangerous operations are handled.
@@ -136,6 +199,24 @@ func (c *DangerousConfig) NonInteractiveAction() Action {
136199
return Allow
137200
}
138201

202+
// CheckOperation checks whether a tool operation is allowed, denied,
203+
// or needs approval. Returns nil on allow, error on deny, and prompts
204+
// the user on prompt.
205+
func (c *DangerousConfig) CheckOperation(op ToolOperation, trustedClasses map[RiskClass]bool) error {
206+
action := c.ActionFor(op.Risk)
207+
switch action {
208+
case Allow:
209+
return nil
210+
case Deny:
211+
return fmt.Errorf("operation denied by configuration: %s %s (risk: %s)",
212+
op.Name, op.Resource, op.Risk)
213+
case Prompt:
214+
return promptForOperation(op, trustedClasses, c.NonInteractiveAction())
215+
default:
216+
return nil
217+
}
218+
}
219+
139220
func parseAction(s string) Action {
140221
switch strings.ToLower(strings.TrimSpace(s)) {
141222
case "allow":
@@ -147,6 +228,47 @@ func parseAction(s string) Action {
147228
}
148229
}
149230

231+
// promptForOperation reads user approval from /dev/tty for native tool ops.
232+
// Returns nil on approve/trust, error on deny or TTY failure.
233+
func promptForOperation(op ToolOperation, trustedClasses map[RiskClass]bool, nonInteractive Action) error {
234+
if trustedClasses != nil && trustedClasses[op.Risk] {
235+
return nil
236+
}
237+
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
238+
if err != nil {
239+
if nonInteractive == Deny {
240+
return fmt.Errorf("operation denied (non-interactive mode): %s %s (risk: %s)",
241+
op.Name, op.Resource, op.Risk)
242+
}
243+
return nil
244+
}
245+
defer tty.Close()
246+
247+
fmt.Fprintf(os.Stderr, "\n⚠️ \033[1mTool:\033[0m %s\n", op.Name)
248+
fmt.Fprintf(os.Stderr, " \033[1mRisk:\033[0m %s\n", op.Risk)
249+
fmt.Fprintf(os.Stderr, " \033[1mTarget:\033[0m %s\n", op.Resource)
250+
fmt.Fprint(os.Stderr, "\n [A]pprove [D]eny [T]rust session: ")
251+
252+
reader := bufio.NewReader(tty)
253+
line, err := reader.ReadString('\n')
254+
if err != nil {
255+
return fmt.Errorf("approval prompt error: %w", err)
256+
}
257+
line = strings.TrimSpace(strings.ToLower(line))
258+
259+
switch line {
260+
case "a", "approve":
261+
return nil
262+
case "t", "trust":
263+
if trustedClasses != nil {
264+
trustedClasses[op.Risk] = true
265+
}
266+
return nil
267+
default:
268+
return fmt.Errorf("operation denied by user: %s %s", op.Name, op.Resource)
269+
}
270+
}
271+
150272
// ── Tokenizer ──────────────────────────────────────────────────────────
151273

152274
// tokenize splits a shell command into tokens, respecting:

0 commit comments

Comments
 (0)