Skip to content

Commit a4bf52a

Browse files
committed
v0.11.0: Unified Approver — same security approvals everywhere
CLI (kode run/repl) and Web UI (kode serve) now share the exact same interactive approval experience for dangerous operations. - Approver interface (internal/danger/approver.go) with: - TTYApprover — /dev/tty prompt (CLI mode, backward compatible) - WSApprover — WebSocket-based prompt with browser modal (serve mode) - DangerousConfig.Approver field — tools auto-use it in CheckOperation - shellTool.promptUser delegates to Approver (removed /dev/tty hardcode) - removed dead promptForOperation() in classifier.go - Web UI: approval_request/approval_response WebSocket protocol - Frontend: modal dialog with Approve / Deny / Trust Session buttons - Per-risk-class color coding (system_write=amber, destructive=red, etc.) - 60s timeout prevents agent deadlock on unresponsive UI 13 packages, 859 tests, race detector clean.
1 parent 81eb4fe commit a4bf52a

17 files changed

Lines changed: 652 additions & 359 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ kode is not a framework. It's a **runtime** — the smallest possible surface ar
3939
Parallel OS-process sub-agents via `delegate_tasks`. True isolation — each sub-agent is a fresh `kode subagent` process with its own config, tools, and termination timeout. Up to 8 concurrent workers. [docs/SUBAGENTS.md](docs/SUBAGENTS.md)
4040

4141
### 🧠 Skill System (on by default)
42-
Trigger-matched `SKILL.md` files load on-demand. Auto-learns from patterns every session — detects multi-step procedures, error recoveries, repeated actions, and user corrections. **LLM-enhanced**: detected patterns are enriched with better names, descriptions, and structured content. Use `--no-learn` to disable. Import skills from any URI with automatic LLM risk assessment. [docs/CLI.md#skills](docs/CLI.md#skills)
42+
|Trigger-matched `SKILL.md` files load on-demand. Auto-learns from patterns every session — detects multi-step procedures, error recoveries, repeated actions, and user corrections. **LLM-enhanced**: each detected pattern is enriched with an LLM-generated name, description, trigger keywords, and structured body with overview, steps, pitfalls, and verification sections. Use `--no-learn` to disable. Import skills from any URI with automatic LLM risk assessment. [docs/CLI.md#skills](docs/CLI.md#skills)
4343

4444
### 💾 Persistent Memory
4545
Three tiers: **facts** (agent-managed durable entries), **session buffer** (auto-appended turn summaries), **episodes** (LLM-extracted knowledge from past sessions). Merge-on-write via go-vector RandomProjections — cosine >0.7 auto-merges, <0.3 auto-adds. Saves ~80% LLM calls. [docs/MEMORY.md](docs/MEMORY.md)

cmd/kode/main.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,6 @@ func printUsage() {
344344
345345
Commands:
346346
run Execute a task with the agent loop
347-
run --learn Execute with skill learning (detects patterns, suggests skills)
348347
run --session Execute and save conversation as a session
349348
continue Continue the most recent session (or by --id)
350349
repl Interactive REPL mode (multi-turn session)
@@ -378,7 +377,8 @@ Run flags:
378377
--no-color Disable colored terminal output
379378
--no-agents Skip loading AGENTS.md from working directory
380379
--session Save conversation as a multi-turn session
381-
--learn Enable skill learning mode (detects patterns, saves skills)
380+
--learn Enable skill learning mode — on by default, no flag needed
381+
--no-learn Disable skill learning mode (overrides config/default)
382382
--system <prompt> System prompt override
383383
384384
Skill commands:
@@ -458,7 +458,9 @@ const defaultConfigTemplate = `{
458458
"skills": {
459459
"max_auto_load": 3,
460460
"max_lazy_slots": 5,
461-
"learn": false,
461+
"learn": true,
462+
"llm_learn": true,
463+
"llm_curate": true,
462464
"dirs": [],
463465
"import": {
464466
"max_size_bytes": 1048576,
@@ -705,7 +707,7 @@ func run(args []string) error {
705707

706708
// Sandbox setup
707709
var sandboxCleanup func() error
708-
tools := builtinTools(resolved.Dangerous, sm)
710+
tools := builtinTools(resolved.Dangerous, sm, nil)
709711

710712
if resolved.Sandbox {
711713
cleanup, err := setupSandbox(tools, sbCfg)
@@ -969,10 +971,11 @@ func buildSandboxArgs(cfg sandboxConfig, containerName, workdir, image string) [
969971
return args
970972
}
971973

972-
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager) []kode.Tool {
974+
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver) []kode.Tool {
973975
tools := []kode.Tool{
974976
&shellTool{
975977
dangerousConfig: dc,
978+
approver: approver,
976979
},
977980
&delegateTasksTool{
978981
maxConcurrency: 3,
@@ -1327,7 +1330,7 @@ func continueCmd(args []string) error {
13271330
"./.kode/skills",
13281331
)
13291332
}
1330-
tools := builtinTools(resolved.Dangerous, sm)
1333+
tools := builtinTools(resolved.Dangerous, sm, nil)
13311334
var sandboxCleanup func() error
13321335

13331336
systemMessage := resolved.System

cmd/kode/main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func TestRun_NoAPIKey(t *testing.T) {
196196
}
197197

198198
func TestBuiltinTools(t *testing.T) {
199-
tools := builtinTools(danger.DangerousConfig{}, nil)
199+
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
200200
if len(tools) == 0 {
201201
t.Fatal("builtinTools() returned empty slice")
202202
}

cmd/kode/repl.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func replCmd(args []string) error {
7575
"./.kode/skills",
7676
)
7777
}
78-
tools := builtinTools(resolved.Dangerous, sm)
78+
tools := builtinTools(resolved.Dangerous, sm, nil)
7979
var sandboxCleanup func() error
8080

8181
if resolved.Sandbox {

cmd/kode/serve.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Flags:
9898

9999
// ── Agent Builder ──────────────────────────────────────────────────────
100100

101-
func newServeAgent(resolved config.ResolvedConfig, system string) (*kode.Agent, func() error, error) {
101+
func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v any) error) (*kode.Agent, func() error, *wsApprover, error) {
102102
var sm *skills.SkillManager
103103
if resolved.Skills.Learn {
104104
sm = skills.NewSkillManager(
@@ -107,7 +107,11 @@ func newServeAgent(resolved config.ResolvedConfig, system string) (*kode.Agent,
107107
)
108108
}
109109

110-
tools := builtinTools(resolved.Dangerous, sm)
110+
// Create WebSocket approver for dangerous operations approval
111+
approver := newWSApprover(sendFn)
112+
resolved.Dangerous.Approver = approver
113+
114+
tools := builtinTools(resolved.Dangerous, sm, approver)
111115
var sandboxCleanup func() error
112116

113117
if resolved.Sandbox {
@@ -123,7 +127,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string) (*kode.Agent,
123127
}
124128
cleanup, err := setupSandbox(tools, cfg)
125129
if err != nil {
126-
return nil, nil, fmt.Errorf("sandbox: %w", err)
130+
return nil, nil, nil, fmt.Errorf("sandbox: %w", err)
127131
}
128132
sandboxCleanup = cleanup
129133
}
@@ -144,10 +148,10 @@ func newServeAgent(resolved config.ResolvedConfig, system string) (*kode.Agent,
144148
MemoryConfig: resolved.Memory,
145149
})
146150
if err != nil {
147-
return nil, nil, err
151+
return nil, nil, nil, err
148152
}
149153

150-
return agent, sandboxCleanup, nil
154+
return agent, sandboxCleanup, approver, nil
151155
}
152156

153157
// ── WebSocket Types ────────────────────────────────────────────────────
@@ -171,7 +175,10 @@ func handleWebSocket(store *session.Store, resources *resource.Registry, resolve
171175

172176
// Create ONE agent per WebSocket connection — provides buffer
173177
// continuity across turns within the same session.
174-
agent, sandboxCleanup, err := newServeAgent(resolved, system)
178+
agent, sandboxCleanup, approver, err := newServeAgent(resolved, system, func(v any) error {
179+
writeWSJSON(conn, v)
180+
return nil
181+
})
175182
if err != nil {
176183
writeWSError(conn, fmt.Sprintf("agent: %v", err))
177184
return
@@ -196,13 +203,35 @@ func handleWebSocket(store *session.Store, resources *resource.Registry, resolve
196203
continue
197204
}
198205

206+
// Peek at the message type without full unmarshal
207+
var msgType struct {
208+
Type string `json:"type"`
209+
}
210+
if err := json.Unmarshal(data, &msgType); err != nil {
211+
continue
212+
}
213+
214+
// Handle approval responses separately (non-blocking, from the browser)
215+
if msgType.Type == "approval_response" {
216+
var resp approvalResponse
217+
if err := json.Unmarshal(data, &resp); err == nil {
218+
approver.HandleResponse(resp.ID, resp.Action)
219+
}
220+
continue
221+
}
222+
223+
// Only process prompt messages
224+
if msgType.Type != "prompt" {
225+
continue
226+
}
227+
199228
var msg wsClientMsg
200229
if err := json.Unmarshal(data, &msg); err != nil {
201230
writeWSError(conn, "invalid JSON")
202231
continue
203232
}
204233

205-
if msg.Type != "prompt" || msg.Content == "" {
234+
if msg.Content == "" {
206235
continue
207236
}
208237

cmd/kode/shell.go

Lines changed: 26 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
package main
22

33
import (
4-
"bufio"
54
"bytes"
65
"encoding/json"
76
"fmt"
8-
"os"
97
"os/exec"
108
"strings"
119

@@ -38,7 +36,9 @@ import (
3836
// - Error output is merged into stdout (stderr follows stdout in output).
3937
// - Empty output returns "(no output)" so the LLM always gets a response.
4038
// - Commands are classified by risk (see internal/danger). High-risk
41-
// commands in non-sandboxed mode prompt the user for approval via /dev/tty.
39+
// commands in non-sandboxed mode prompt the user for approval.
40+
// The approval mechanism uses the configured Approver — TTY in CLI mode,
41+
// WebSocket in serve mode — ensuring the same experience everywhere.
4242
type shellTool struct {
4343
// containerName, when set, routes commands through "docker exec"
4444
// into this container. Set by setupSandbox() when --sandbox is active.
@@ -48,12 +48,16 @@ type shellTool struct {
4848
// dangerousConfig controls per-class actions and allow/denylists.
4949
dangerousConfig danger.DangerousConfig
5050

51+
// approver handles interactive approval prompts. When nil, falls back
52+
// to TTYApprover (CLI-compatible default).
53+
approver danger.Approver
54+
5155
// trustedClasses caches user-approved risk classes for this process.
5256
// Set when user presses T (trust this session) at the prompt.
5357
trustedClasses map[danger.RiskClass]bool
5458

5559
// ttyPath is the path to the terminal device for approval prompts.
56-
// Overridden in tests to mock user input.
60+
// Overridden in tests to mock user input. Only used when approver is nil.
5761
ttyPath string
5862
}
5963

@@ -146,69 +150,32 @@ func (t *shellTool) checkApproval(cmd, description string) error {
146150
}
147151
}
148152

149-
// promptUser opens /dev/tty and asks the user to approve the command.
153+
// promptUser classifies the command and asks the user to approve it.
154+
// Delegates to the configured Approver, or falls back to TTYApprover.
150155
func (t *shellTool) promptUser(cmd, description string) error {
151156
cls := danger.Classify(cmd)
152157

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)
158+
// Get or create the approver
159+
approver := t.approver
160+
if approver == nil {
161+
ttyApprover := danger.NewTTYApprover(&t.dangerousConfig)
162+
if t.trustedClasses != nil {
163+
ttyApprover.TrustedClasses = t.trustedClasses
169164
}
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)
165+
if t.ttyPath != "" {
166+
ttyApprover.TTYPath = t.ttyPath
167+
}
168+
approver = ttyApprover
187169
}
188-
line = strings.TrimSpace(strings.ToLower(line))
189170

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)
171+
err := approver.PromptCommand(cls, cmd, description)
172+
if err == nil {
173+
// Sync trusted classes back if using TTYApprover
174+
if tty, ok := approver.(*danger.TTYApprover); ok {
175+
t.trustedClasses = tty.TrustedClasses
197176
}
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)
211177
}
178+
return err
212179
}
213180

214181
// buildCmd constructs the exec.Cmd for the given shell command.

cmd/kode/subagent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ func subagentCmd(args []string) error {
285285
"./.kode/skills",
286286
)
287287
}
288-
tools := builtinTools(resolved.Dangerous, sm)
288+
tools := builtinTools(resolved.Dangerous, sm, nil)
289289
var sandboxCleanup func() error
290290

291291
if resolved.Sandbox {

cmd/kode/subagent_contract_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ func TestSubagent_ExitCodeThree(t *testing.T) {
304304
// ── 4. delegate_tasks Tool Schema ───────────────────────────────────
305305

306306
func TestDelegateTasksTool_Exists(t *testing.T) {
307-
tools := builtinTools(danger.DangerousConfig{}, nil)
307+
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
308308

309309
found := false
310310
for _, tool := range tools {
@@ -319,7 +319,7 @@ func TestDelegateTasksTool_Exists(t *testing.T) {
319319
}
320320

321321
func TestDelegateTasksTool_HasSchema(t *testing.T) {
322-
tools := builtinTools(danger.DangerousConfig{}, nil)
322+
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
323323

324324
var tool kode.Tool
325325
for _, t2 := range tools {
@@ -408,7 +408,7 @@ func TestDelegateTasksTool_HasSchema(t *testing.T) {
408408
}
409409

410410
func TestDelegateTasksTool_Description(t *testing.T) {
411-
tools := builtinTools(danger.DangerousConfig{}, nil)
411+
tools := builtinTools(danger.DangerousConfig{}, nil, nil)
412412

413413
var tool kode.Tool
414414
for _, t2 := range tools {

0 commit comments

Comments
 (0)