Skip to content

Commit a6b4a90

Browse files
committed
v0.8.6: 8 security fixes + REPL sandbox flags + self-learning docs
Security (8 fixes, 12 tests): - Config file 0644→0600 (API key world-readable) - Session ID path traversal blocked - JSON injection via skill names fixed (json.Marshal) - RequireHTTPS enforcement on FetchFromURI - API key no longer leaks in error response bodies - Unbounded LLM response capped at 50MB - Sandbox volume mount forbidden paths rejected - SSRF redirect target validation (private IP check) Features: - REPL --sandbox + 6 sub-flags (--sandbox-image, --network, etc.) - Self-learning docs (LEARNING.md, 297 lines) - 2 self-improve bugs fixed (tool name, JSON parsing) Tests: +40 tests across main_test, session_test, importer_test
1 parent 9b873cc commit a6b4a90

13 files changed

Lines changed: 1049 additions & 45 deletions

File tree

cmd/kode/main.go

Lines changed: 130 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"crypto/sha256"
66
"encoding/hex"
7+
"encoding/json"
78
"fmt"
89
"os"
910
"os/exec"
@@ -71,6 +72,11 @@ Tool output handling:
7172
// Docker image from it. See buildFromDockerfile() and SANDBOXING.md.
7273
const dockerfileName = "Dockerfile.kode"
7374

75+
// forbiddenMountPrefixes lists host paths that sandbox volume mounts
76+
// may not target. Mounting /, /etc, /proc, /sys would give the sandbox
77+
// container access to the host filesystem, defeating isolation.
78+
var forbiddenMountPrefixes = []string{"/", "/etc", "/proc", "/sys", "/boot", "/dev"}
79+
7480
func boolPtr(b bool) *bool { return &b }
7581

7682
func main() {
@@ -225,13 +231,93 @@ done:
225231
return f, nil
226232
}
227233

234+
// ── REPL Flag Parsing ──────────────────────────────────────────────────
235+
236+
// replFlags holds the parsed CLI flags for `kode repl`.
237+
// Same resolution model as runFlags: zero/nil = not set,
238+
// config loader merges file → env → CLI.
239+
type replFlags struct {
240+
ID string // session ID to resume
241+
Model string
242+
Thinking string
243+
Sandbox *bool // nil = not set
244+
245+
// Sandbox-specific CLI flags
246+
SandboxImage string
247+
SandboxNetwork string
248+
SandboxReadonly *bool
249+
SandboxMemory string
250+
SandboxCPUs string
251+
SandboxUser string
252+
}
253+
254+
// parseReplFlags parses `kode repl` arguments and returns the parsed flags.
255+
// Exported for testing. Unlike parseRunFlags, there is no required task argument;
256+
// unrecognized flags or trailing args are silently ignored.
257+
func parseReplFlags(args []string) (replFlags, error) {
258+
var f replFlags
259+
if len(args) == 0 {
260+
return f, nil
261+
}
262+
263+
i := 0
264+
for i < len(args) {
265+
if i == len(args)-1 {
266+
// Last arg — can only be a boolean flag (no value pair needed)
267+
switch args[i] {
268+
case "--sandbox":
269+
f.Sandbox = boolPtr(true)
270+
case "--sandbox-readonly":
271+
f.SandboxReadonly = boolPtr(true)
272+
}
273+
break
274+
}
275+
switch args[i] {
276+
case "--id":
277+
f.ID = args[i+1]
278+
i += 2
279+
case "--model":
280+
f.Model = args[i+1]
281+
i += 2
282+
case "--thinking":
283+
f.Thinking = args[i+1]
284+
i += 2
285+
case "--sandbox":
286+
f.Sandbox = boolPtr(true)
287+
i++
288+
case "--sandbox-image":
289+
f.SandboxImage = args[i+1]
290+
i += 2
291+
case "--sandbox-network":
292+
f.SandboxNetwork = args[i+1]
293+
i += 2
294+
case "--sandbox-readonly":
295+
f.SandboxReadonly = boolPtr(true)
296+
i++
297+
case "--sandbox-memory":
298+
f.SandboxMemory = args[i+1]
299+
i += 2
300+
case "--sandbox-cpus":
301+
f.SandboxCPUs = args[i+1]
302+
i += 2
303+
case "--sandbox-user":
304+
f.SandboxUser = args[i+1]
305+
i += 2
306+
default:
307+
// Unrecognized flag or positional — skip it
308+
i++
309+
}
310+
}
311+
return f, nil
312+
}
313+
228314
func printUsage() {
229315
fmt.Println(`Usage:
230316
kode run [flags] <task>
231317
kode run --session [flags] <task>
232318
kode continue [--id <id>] <task>
233319
kode session <list|show [id]|trim <id> <n>|delete <id>|cleanup <days>>
234-
kode repl [--id <id>]
320+
kode repl [flags]
235321
kode init [--global | -g] [--force | -f]
236322
kode version
237323
@@ -241,6 +327,8 @@ Commands:
241327
run --session Execute and save conversation as a session
242328
continue Continue the most recent session (or by --id)
243329
repl Interactive REPL mode (multi-turn session)
330+
Accepts --model, --thinking, --sandbox, and
331+
--sandbox-* flags just like kode run.
244332
session Manage sessions: list, show, delete, trim, cleanup
245333
skill Manage skills: list, view, save, delete, import, curate
246334
init Create a config file (default: ./kode.json)
@@ -406,7 +494,7 @@ func initConfig(args []string) error {
406494
return fmt.Errorf("create directory %s: %w", dir, err)
407495
}
408496

409-
if err := os.WriteFile(configPath, []byte(defaultConfigTemplate+"\n"), 0644); err != nil {
497+
if err := os.WriteFile(configPath, []byte(defaultConfigTemplate+"\n"), 0600); err != nil {
410498
return fmt.Errorf("write config: %w", err)
411499
}
412500

@@ -789,7 +877,22 @@ func buildSandboxArgs(cfg sandboxConfig, containerName, workdir, image string) [
789877

790878
// Extra volume mounts
791879
for _, vol := range cfg.Volumes {
792-
args = append(args, "-v", vol)
880+
// Validate: reject mounts to sensitive host paths
881+
reject := false
882+
parts := strings.SplitN(vol, ":", 2)
883+
if len(parts) > 0 {
884+
hostPath := filepath.Clean(parts[0])
885+
for _, forbidden := range forbiddenMountPrefixes {
886+
if hostPath == forbidden || strings.HasPrefix(hostPath, forbidden+"/") {
887+
fmt.Fprintf(os.Stderr, "kode: WARNING: rejecting forbidden volume mount %q (host path %s)\n", vol, hostPath)
888+
reject = true
889+
break
890+
}
891+
}
892+
}
893+
if !reject {
894+
args = append(args, "-v", vol)
895+
}
793896
}
794897

795898
// Image and command
@@ -951,7 +1054,7 @@ func skillCmd(args []string) error {
9511054
sm := skills.NewSkillManager(userDir, "./.kode/skills")
9521055
tool := &skills.SkillLoadTool{}
9531056
tool.Manager = sm
954-
result, err := tool.Call(`{"name": "` + subArgs[0] + `"}`)
1057+
result, err := tool.Call(jsonMarshalName(subArgs[0]))
9551058
if err != nil {
9561059
return err
9571060
}
@@ -965,7 +1068,7 @@ func skillCmd(args []string) error {
9651068
sm := skills.NewSkillManager(userDir, "./.kode/skills")
9661069
tool := &skills.SkillDeleteTool{}
9671070
tool.Manager = sm
968-
result, err := tool.Call(`{"name": "` + subArgs[0] + `"}`)
1071+
result, err := tool.Call(jsonMarshalName(subArgs[0]))
9691072
if err != nil {
9701073
return err
9711074
}
@@ -988,12 +1091,13 @@ func skillCmd(args []string) error {
9881091
}
9891092
}
9901093

1094+
// Load config once for both RequireHTTPS and LLM assessment
1095+
cfg := config.LoadConfig(config.CLIFlags{})
1096+
9911097
llmCall := func(prompt string) (string, error) {
9921098
if basicOnly {
9931099
return "", fmt.Errorf("basic mode — no LLM call")
9941100
}
995-
// Load config and create LLM client for assessment
996-
cfg := config.LoadConfig(config.CLIFlags{})
9971101
client := llm.New(cfg.BaseURL, cfg.APIKey, cfg.Model, "", 30)
9981102
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
9991103
defer cancel()
@@ -1004,12 +1108,13 @@ func skillCmd(args []string) error {
10041108
}
10051109

10061110
result, err := skills.ImportSkill(skills.ImportOptions{
1007-
URI: uri,
1008-
MaxBytes: 1_048_576,
1009-
Timeout: 5,
1010-
BasicOnly: basicOnly,
1011-
AutoYes: autoYes,
1012-
UserDir: userDir,
1111+
URI: uri,
1112+
MaxBytes: 1_048_576,
1113+
Timeout: 5,
1114+
BasicOnly: basicOnly,
1115+
AutoYes: autoYes,
1116+
RequireHTTPS: cfg.Skills.Import.RequireHTTPS,
1117+
UserDir: userDir,
10131118
}, func(assessment *skills.ImportAssessment) bool {
10141119
if autoYes {
10151120
return true
@@ -1424,3 +1529,15 @@ func shorten(s string, n int) string {
14241529
}
14251530
return s[:n] + "…"
14261531
}
1532+
1533+
// ── JSON Injection Prevention ─────────────────────────────────────────
1534+
1535+
// jsonMarshalName safely marshals a skill name into a JSON object
1536+
// {"name":"<escaped>"}. Uses json.Marshal to prevent JSON injection
1537+
// from names containing quotes, backslashes, or control characters.
1538+
func jsonMarshalName(name string) string {
1539+
b, _ := json.Marshal(struct {
1540+
Name string `json:"name"`
1541+
}{Name: name})
1542+
return string(b)
1543+
}

0 commit comments

Comments
 (0)