docs+examples: spontaneous open_panel docs and approve/secret example extensions (#19)
- docs/extensions.md: add open_panel spontaneous frame section with blocking tool pattern explanation, concurrent-panel note, and references to new examples; add approve/secret to See also list; add roadmap entry - examples/extensions/approve/: approve_action tool — opens a y/n panel from inside the tool handler, blocks until user responds - examples/extensions/secret/: fetch_with_password tool — masked password input panel, secret never leaves the extension process
- commit
- 5ffdafa5d84e3836a788ed7578bbc1bc67238eb2
- author
- Raymond Gasper · · 2026-06-08 16:18 UTC
- parent
- 2d46ef9
- signature
- unsigned
6 files · +493 -0
A examples/extensions/approve/README.md +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | # approve — example zot extension (Go, phase 4) | |
| 2 | + | ||
| 3 | + | Demonstrates the **spontaneous `open_panel`** pattern introduced in | |
| 4 | + | phase 4: an extension-registered tool opens a panel from inside its | |
| 5 | + | handler goroutine, blocks until the user responds, then returns the | |
| 6 | + | result to the model. | |
| 7 | + | ||
| 8 | + | ## What it does | |
| 9 | + | ||
| 10 | + | Registers one LLM-callable tool: | |
| 11 | + | ||
| 12 | + | ``` | |
| 13 | + | approve_action(action, reason?) | |
| 14 | + | ``` | |
| 15 | + | ||
| 16 | + | When the model calls it, a panel appears in the TUI: | |
| 17 | + | ||
| 18 | + | ``` | |
| 19 | + | ╭─ Approval required ─────────────────────╮ | |
| 20 | + | │ Action: delete /tmp/build │ | |
| 21 | + | │ Reason: cleaning up after the build │ | |
| 22 | + | │ │ | |
| 23 | + | │ y approve n / esc deny │ | |
| 24 | + | ╰──────────────────────────────────────────╯ | |
| 25 | + | y approve n deny esc cancel | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | The model's tool call is held open until the user presses a key. The | |
| 29 | + | model receives `"approved"` or `"denied: user rejected the action"` as | |
| 30 | + | the tool result and replies accordingly. | |
| 31 | + | ||
| 32 | + | ## Build | |
| 33 | + | ||
| 34 | + | ```bash | |
| 35 | + | cd examples/extensions/approve | |
| 36 | + | go build -o approve . | |
| 37 | + | ``` | |
| 38 | + | ||
| 39 | + | ## Install | |
| 40 | + | ||
| 41 | + | ```bash | |
| 42 | + | zot ext install . | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | ## Try it | |
| 46 | + | ||
| 47 | + | In zot, ask: | |
| 48 | + | ||
| 49 | + | > Request approval to delete the temp directory. | |
| 50 | + | ||
| 51 | + | The model calls `approve_action`; the panel opens. Press **y** to | |
| 52 | + | approve, **n** or **esc** to deny. | |
| 53 | + | ||
| 54 | + | > Ask me to approve before running any destructive command. | |
| 55 | + | ||
| 56 | + | The model will start calling `approve_action` before suggesting risky | |
| 57 | + | steps; each call pauses until you respond. | |
| 58 | + | ||
| 59 | + | ## Key points | |
| 60 | + | ||
| 61 | + | - The tool handler calls `e.OpenPanel(...)` directly — no slash command | |
| 62 | + | needed. | |
| 63 | + | - A `chan bool` bridges the panel key handler back to the blocked tool | |
| 64 | + | goroutine; nothing in the wire protocol changes. | |
| 65 | + | - Panel IDs are unique per call so multiple concurrent approvals don't | |
| 66 | + | collide (rare in practice, but handled correctly). | |
| 67 | + | - The `onClose` callback on `OnPanelKey` handles the case where the | |
| 68 | + | user dismisses the panel from the TUI border rather than pressing a | |
| 69 | + | key, treating it as a denial. | |
| 70 | + | ||
| 71 | + | ## See also | |
| 72 | + | ||
| 73 | + | - `examples/extensions/secret` — same pattern with masked text input | |
| 74 | + | (credential collection) | |
| 75 | + | - `examples/extensions/guard` — blocks dangerous tool calls via | |
| 76 | + | interception (no panel) | |
| 77 | + | - `docs/extensions.md` — full protocol reference | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | # approve — example zot extension (Go, phase 4) | |||
| 2 | + | ||||
| 3 | + | Demonstrates the **spontaneous `open_panel`** pattern introduced in | |||
| 4 | + | phase 4: an extension-registered tool opens a panel from inside its | |||
| 5 | + | handler goroutine, blocks until the user responds, then returns the | |||
| 6 | + | result to the model. | |||
| 7 | + | ||||
| 8 | + | ## What it does | |||
| 9 | + | ||||
| 10 | + | Registers one LLM-callable tool: | |||
| 11 | + | ||||
| 12 | + | ``` | |||
| 13 | + | approve_action(action, reason?) | |||
| 14 | + | ``` | |||
| 15 | + | ||||
| 16 | + | When the model calls it, a panel appears in the TUI: | |||
| 17 | + | ||||
| 18 | + | ``` | |||
| 19 | + | ╭─ Approval required ─────────────────────╮ | |||
| 20 | + | │ Action: delete /tmp/build │ | |||
| 21 | + | │ Reason: cleaning up after the build │ | |||
| 22 | + | │ │ | |||
| 23 | + | │ y approve n / esc deny │ | |||
| 24 | + | ╰──────────────────────────────────────────╯ | |||
| 25 | + | y approve n deny esc cancel | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 28 | + | The model's tool call is held open until the user presses a key. The | |||
| 29 | + | model receives `"approved"` or `"denied: user rejected the action"` as | |||
| 30 | + | the tool result and replies accordingly. | |||
| 31 | + | ||||
| 32 | + | ## Build | |||
| 33 | + | ||||
| 34 | + | ```bash | |||
| 35 | + | cd examples/extensions/approve | |||
| 36 | + | go build -o approve . | |||
| 37 | + | ``` | |||
| 38 | + | ||||
| 39 | + | ## Install | |||
| 40 | + | ||||
| 41 | + | ```bash | |||
| 42 | + | zot ext install . | |||
| 43 | + | ``` | |||
| 44 | + | ||||
| 45 | + | ## Try it | |||
| 46 | + | ||||
| 47 | + | In zot, ask: | |||
| 48 | + | ||||
| 49 | + | > Request approval to delete the temp directory. | |||
| 50 | + | ||||
| 51 | + | The model calls `approve_action`; the panel opens. Press **y** to | |||
| 52 | + | approve, **n** or **esc** to deny. | |||
| 53 | + | ||||
| 54 | + | > Ask me to approve before running any destructive command. | |||
| 55 | + | ||||
| 56 | + | The model will start calling `approve_action` before suggesting risky | |||
| 57 | + | steps; each call pauses until you respond. | |||
| 58 | + | ||||
| 59 | + | ## Key points | |||
| 60 | + | ||||
| 61 | + | - The tool handler calls `e.OpenPanel(...)` directly — no slash command | |||
| 62 | + | needed. | |||
| 63 | + | - A `chan bool` bridges the panel key handler back to the blocked tool | |||
| 64 | + | goroutine; nothing in the wire protocol changes. | |||
| 65 | + | - Panel IDs are unique per call so multiple concurrent approvals don't | |||
| 66 | + | collide (rare in practice, but handled correctly). | |||
| 67 | + | - The `onClose` callback on `OnPanelKey` handles the case where the | |||
| 68 | + | user dismisses the panel from the TUI border rather than pressing a | |||
| 69 | + | key, treating it as a denial. | |||
| 70 | + | ||||
| 71 | + | ## See also | |||
| 72 | + | ||||
| 73 | + | - `examples/extensions/secret` — same pattern with masked text input | |||
| 74 | + | (credential collection) | |||
| 75 | + | - `examples/extensions/guard` — blocks dangerous tool calls via | |||
| 76 | + | interception (no panel) | |||
| 77 | + | - `docs/extensions.md` — full protocol reference | |||
A examples/extensions/approve/extension.json +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "approve", | |
| 3 | + | "version": "1.0.0", | |
| 4 | + | "exec": "./approve", | |
| 5 | + | "language": "go", | |
| 6 | + | "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs", | |
| 7 | + | "enabled": true | |
| 8 | + | } | |
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "approve", | |||
| 3 | + | "version": "1.0.0", | |||
| 4 | + | "exec": "./approve", | |||
| 5 | + | "language": "go", | |||
| 6 | + | "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs", | |||
| 7 | + | "enabled": true | |||
| 8 | + | } | |||
A examples/extensions/approve/main.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | // approve — demonstrates the spontaneous open_panel pattern for | |
| 2 | + | // human-in-the-loop tool approval gates. | |
| 3 | + | // | |
| 4 | + | // Registers one LLM-callable tool: | |
| 5 | + | // | |
| 6 | + | // approve_action(action: string, reason: string) | |
| 7 | + | // | |
| 8 | + | // When the model calls it, a panel opens asking the user to approve | |
| 9 | + | // or deny. The tool goroutine blocks until the user responds; the | |
| 10 | + | // model only sees the result after the user has acted. | |
| 11 | + | // | |
| 12 | + | // Build: | |
| 13 | + | // | |
| 14 | + | // cd examples/extensions/approve | |
| 15 | + | // go build -o approve . | |
| 16 | + | // | |
| 17 | + | // Install: | |
| 18 | + | // | |
| 19 | + | // zot ext install . | |
| 20 | + | // | |
| 21 | + | // Try it — ask zot something like: | |
| 22 | + | // | |
| 23 | + | // "Request approval to delete the temp directory." | |
| 24 | + | // | |
| 25 | + | // The model will call approve_action; a panel appears in the TUI. Press | |
| 26 | + | // y to approve or n (or esc) to deny. The model receives "approved" or | |
| 27 | + | // "denied: user rejected the action" as the tool result and responds | |
| 28 | + | // accordingly. | |
| 29 | + | package main | |
| 30 | + | ||
| 31 | + | import ( | |
| 32 | + | "encoding/json" | |
| 33 | + | "fmt" | |
| 34 | + | "os" | |
| 35 | + | "strings" | |
| 36 | + | "sync" | |
| 37 | + | ||
| 38 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | const schema = `{ | |
| 42 | + | "type": "object", | |
| 43 | + | "properties": { | |
| 44 | + | "action": { | |
| 45 | + | "type": "string", | |
| 46 | + | "description": "Short description of what is about to happen, shown to the user." | |
| 47 | + | }, | |
| 48 | + | "reason": { | |
| 49 | + | "type": "string", | |
| 50 | + | "description": "Why the action is needed, shown to the user." | |
| 51 | + | } | |
| 52 | + | }, | |
| 53 | + | "required": ["action"] | |
| 54 | + | }` | |
| 55 | + | ||
| 56 | + | func main() { | |
| 57 | + | e := ext.New("approve", "1.0.0") | |
| 58 | + | ||
| 59 | + | // counter is used to generate unique panel IDs so that concurrent | |
| 60 | + | // tool calls (unlikely in practice but possible) don't collide. | |
| 61 | + | var mu sync.Mutex | |
| 62 | + | var counter int | |
| 63 | + | nextPID := func() string { | |
| 64 | + | mu.Lock() | |
| 65 | + | defer mu.Unlock() | |
| 66 | + | counter++ | |
| 67 | + | return fmt.Sprintf("approve-%d", counter) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema), | |
| 71 | + | func(args json.RawMessage) ext.ToolResult { | |
| 72 | + | var in struct { | |
| 73 | + | Action string `json:"action"` | |
| 74 | + | Reason string `json:"reason"` | |
| 75 | + | } | |
| 76 | + | if err := json.Unmarshal(args, &in); err != nil { | |
| 77 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |
| 78 | + | } | |
| 79 | + | if strings.TrimSpace(in.Action) == "" { | |
| 80 | + | return ext.TextErrorResult("action is required") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | pid := nextPID() | |
| 84 | + | decision := make(chan bool, 1) | |
| 85 | + | ||
| 86 | + | // Register key handler before opening the panel so there | |
| 87 | + | // is no window where a key could arrive unhandled. | |
| 88 | + | e.OnPanelKey(pid, func(key, text string) { | |
| 89 | + | switch { | |
| 90 | + | case key == "rune" && strings.ToLower(text) == "y": | |
| 91 | + | e.ClosePanel(pid) | |
| 92 | + | decision <- true | |
| 93 | + | case key == "rune" && strings.ToLower(text) == "n", | |
| 94 | + | key == "esc": | |
| 95 | + | e.ClosePanel(pid) | |
| 96 | + | decision <- false | |
| 97 | + | } | |
| 98 | + | }, func() { | |
| 99 | + | // Host closed the panel (e.g. user navigated away). | |
| 100 | + | select { | |
| 101 | + | case decision <- false: | |
| 102 | + | default: | |
| 103 | + | } | |
| 104 | + | }) | |
| 105 | + | ||
| 106 | + | lines := []string{ | |
| 107 | + | " Action: " + in.Action, | |
| 108 | + | } | |
| 109 | + | if strings.TrimSpace(in.Reason) != "" { | |
| 110 | + | lines = append(lines, " Reason: "+in.Reason) | |
| 111 | + | } | |
| 112 | + | lines = append(lines, "", " y approve n / esc deny") | |
| 113 | + | ||
| 114 | + | // Open the panel spontaneously from inside the tool handler. | |
| 115 | + | e.OpenPanel(pid, "Approval required", lines, "y approve n deny esc cancel") | |
| 116 | + | ||
| 117 | + | // Block until the user responds. | |
| 118 | + | if <-decision { | |
| 119 | + | return ext.TextResult("approved") | |
| 120 | + | } | |
| 121 | + | return ext.TextErrorResult("denied: user rejected the action") | |
| 122 | + | }) | |
| 123 | + | ||
| 124 | + | if err := e.Run(); err != nil { | |
| 125 | + | e.Logf("fatal: %v", err) | |
| 126 | + | os.Exit(1) | |
| 127 | + | } | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | // approve — demonstrates the spontaneous open_panel pattern for | |||
| 2 | + | // human-in-the-loop tool approval gates. | |||
| 3 | + | // | |||
| 4 | + | // Registers one LLM-callable tool: | |||
| 5 | + | // | |||
| 6 | + | // approve_action(action: string, reason: string) | |||
| 7 | + | // | |||
| 8 | + | // When the model calls it, a panel opens asking the user to approve | |||
| 9 | + | // or deny. The tool goroutine blocks until the user responds; the | |||
| 10 | + | // model only sees the result after the user has acted. | |||
| 11 | + | // | |||
| 12 | + | // Build: | |||
| 13 | + | // | |||
| 14 | + | // cd examples/extensions/approve | |||
| 15 | + | // go build -o approve . | |||
| 16 | + | // | |||
| 17 | + | // Install: | |||
| 18 | + | // | |||
| 19 | + | // zot ext install . | |||
| 20 | + | // | |||
| 21 | + | // Try it — ask zot something like: | |||
| 22 | + | // | |||
| 23 | + | // "Request approval to delete the temp directory." | |||
| 24 | + | // | |||
| 25 | + | // The model will call approve_action; a panel appears in the TUI. Press | |||
| 26 | + | // y to approve or n (or esc) to deny. The model receives "approved" or | |||
| 27 | + | // "denied: user rejected the action" as the tool result and responds | |||
| 28 | + | // accordingly. | |||
| 29 | + | package main | |||
| 30 | + | ||||
| 31 | + | import ( | |||
| 32 | + | "encoding/json" | |||
| 33 | + | "fmt" | |||
| 34 | + | "os" | |||
| 35 | + | "strings" | |||
| 36 | + | "sync" | |||
| 37 | + | ||||
| 38 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | const schema = `{ | |||
| 42 | + | "type": "object", | |||
| 43 | + | "properties": { | |||
| 44 | + | "action": { | |||
| 45 | + | "type": "string", | |||
| 46 | + | "description": "Short description of what is about to happen, shown to the user." | |||
| 47 | + | }, | |||
| 48 | + | "reason": { | |||
| 49 | + | "type": "string", | |||
| 50 | + | "description": "Why the action is needed, shown to the user." | |||
| 51 | + | } | |||
| 52 | + | }, | |||
| 53 | + | "required": ["action"] | |||
| 54 | + | }` | |||
| 55 | + | ||||
| 56 | + | func main() { | |||
| 57 | + | e := ext.New("approve", "1.0.0") | |||
| 58 | + | ||||
| 59 | + | // counter is used to generate unique panel IDs so that concurrent | |||
| 60 | + | // tool calls (unlikely in practice but possible) don't collide. | |||
| 61 | + | var mu sync.Mutex | |||
| 62 | + | var counter int | |||
| 63 | + | nextPID := func() string { | |||
| 64 | + | mu.Lock() | |||
| 65 | + | defer mu.Unlock() | |||
| 66 | + | counter++ | |||
| 67 | + | return fmt.Sprintf("approve-%d", counter) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema), | |||
| 71 | + | func(args json.RawMessage) ext.ToolResult { | |||
| 72 | + | var in struct { | |||
| 73 | + | Action string `json:"action"` | |||
| 74 | + | Reason string `json:"reason"` | |||
| 75 | + | } | |||
| 76 | + | if err := json.Unmarshal(args, &in); err != nil { | |||
| 77 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |||
| 78 | + | } | |||
| 79 | + | if strings.TrimSpace(in.Action) == "" { | |||
| 80 | + | return ext.TextErrorResult("action is required") | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | pid := nextPID() | |||
| 84 | + | decision := make(chan bool, 1) | |||
| 85 | + | ||||
| 86 | + | // Register key handler before opening the panel so there | |||
| 87 | + | // is no window where a key could arrive unhandled. | |||
| 88 | + | e.OnPanelKey(pid, func(key, text string) { | |||
| 89 | + | switch { | |||
| 90 | + | case key == "rune" && strings.ToLower(text) == "y": | |||
| 91 | + | e.ClosePanel(pid) | |||
| 92 | + | decision <- true | |||
| 93 | + | case key == "rune" && strings.ToLower(text) == "n", | |||
| 94 | + | key == "esc": | |||
| 95 | + | e.ClosePanel(pid) | |||
| 96 | + | decision <- false | |||
| 97 | + | } | |||
| 98 | + | }, func() { | |||
| 99 | + | // Host closed the panel (e.g. user navigated away). | |||
| 100 | + | select { | |||
| 101 | + | case decision <- false: | |||
| 102 | + | default: | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | ||||
| 106 | + | lines := []string{ | |||
| 107 | + | " Action: " + in.Action, | |||
| 108 | + | } | |||
| 109 | + | if strings.TrimSpace(in.Reason) != "" { | |||
| 110 | + | lines = append(lines, " Reason: "+in.Reason) | |||
| 111 | + | } | |||
| 112 | + | lines = append(lines, "", " y approve n / esc deny") | |||
| 113 | + | ||||
| 114 | + | // Open the panel spontaneously from inside the tool handler. | |||
| 115 | + | e.OpenPanel(pid, "Approval required", lines, "y approve n deny esc cancel") | |||
| 116 | + | ||||
| 117 | + | // Block until the user responds. | |||
| 118 | + | if <-decision { | |||
| 119 | + | return ext.TextResult("approved") | |||
| 120 | + | } | |||
| 121 | + | return ext.TextErrorResult("denied: user rejected the action") | |||
| 122 | + | }) | |||
| 123 | + | ||||
| 124 | + | if err := e.Run(); err != nil { | |||
| 125 | + | e.Logf("fatal: %v", err) | |||
| 126 | + | os.Exit(1) | |||
| 127 | + | } | |||
| 128 | + | } | |||
A examples/extensions/secret/README.md +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | # secret — example zot extension (Go, phase 4) | |
| 2 | + | ||
| 3 | + | Demonstrates **secret collection via a masked panel**: the model asks | |
| 4 | + | for a resource that needs a credential, the extension collects the | |
| 5 | + | credential directly from the user inside a panel, uses it to perform | |
| 6 | + | the operation, and returns only the outcome to the model. The secret | |
| 7 | + | is never written to any JSON frame or the transcript. | |
| 8 | + | ||
| 9 | + | ## What it does | |
| 10 | + | ||
| 11 | + | Registers one LLM-callable tool: | |
| 12 | + | ||
| 13 | + | ``` | |
| 14 | + | fetch_with_password(url: string) | |
| 15 | + | ``` | |
| 16 | + | ||
| 17 | + | When the model calls it, a masked password panel opens: | |
| 18 | + | ||
| 19 | + | ``` | |
| 20 | + | ╭─ Password required ─────────────────────╮ | |
| 21 | + | │ URL: https://internal.example.com │ | |
| 22 | + | │ │ | |
| 23 | + | │ Password: ●●●●●●●▌ │ | |
| 24 | + | ╰──────────────────────────────────────────╯ | |
| 25 | + | type password enter confirm esc cancel | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | The panel re-renders after every keystroke, showing bullets instead of | |
| 29 | + | characters. Pressing Enter unblocks the tool goroutine, which uses the | |
| 30 | + | password directly (in `doFetch`) and returns only the fetch outcome to | |
| 31 | + | the model. | |
| 32 | + | ||
| 33 | + | ## Security property | |
| 34 | + | ||
| 35 | + | The password lives only in the extension process's memory. It is never | |
| 36 | + | serialised into a JSON frame, never appears in the transcript, and | |
| 37 | + | never reaches the model. The model sees: | |
| 38 | + | ||
| 39 | + | ``` | |
| 40 | + | fetched https://internal.example.com successfully (password was 9 characters, not shown) | |
| 41 | + | ``` | |
| 42 | + | ||
| 43 | + | ## Build | |
| 44 | + | ||
| 45 | + | ```bash | |
| 46 | + | cd examples/extensions/secret | |
| 47 | + | go build -o secret . | |
| 48 | + | ``` | |
| 49 | + | ||
| 50 | + | ## Install | |
| 51 | + | ||
| 52 | + | ```bash | |
| 53 | + | zot ext install . | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | ## Try it | |
| 57 | + | ||
| 58 | + | In zot, ask: | |
| 59 | + | ||
| 60 | + | > Fetch https://internal.example.com/report — it needs a password. | |
| 61 | + | ||
| 62 | + | The model calls `fetch_with_password`; the masked panel opens. Type | |
| 63 | + | anything and press **Enter**. The model receives the result; the | |
| 64 | + | password is gone. | |
| 65 | + | ||
| 66 | + | ## Adapting to real use | |
| 67 | + | ||
| 68 | + | Replace `doFetch` in `main.go` with a real HTTP request: | |
| 69 | + | ||
| 70 | + | ```go | |
| 71 | + | func doFetch(url, password string) ext.ToolResult { | |
| 72 | + | req, _ := http.NewRequest("GET", url, nil) | |
| 73 | + | req.SetBasicAuth("user", password) | |
| 74 | + | resp, err := http.DefaultClient.Do(req) | |
| 75 | + | if err != nil { | |
| 76 | + | return ext.TextErrorResult("fetch failed: " + err.Error()) | |
| 77 | + | } | |
| 78 | + | defer resp.Body.Close() | |
| 79 | + | body, _ := io.ReadAll(resp.Body) | |
| 80 | + | return ext.TextResult(string(body)) | |
| 81 | + | } | |
| 82 | + | ``` | |
| 83 | + | ||
| 84 | + | The same pattern works for any credential type: API tokens, SSH | |
| 85 | + | passphrases, TOTP codes, or freeform override strings. | |
| 86 | + | ||
| 87 | + | ## See also | |
| 88 | + | ||
| 89 | + | - `examples/extensions/approve` — same pattern for approve/deny gates | |
| 90 | + | (no text input, just y/n) | |
| 91 | + | - `docs/extensions.md` — full protocol reference | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | # secret — example zot extension (Go, phase 4) | |||
| 2 | + | ||||
| 3 | + | Demonstrates **secret collection via a masked panel**: the model asks | |||
| 4 | + | for a resource that needs a credential, the extension collects the | |||
| 5 | + | credential directly from the user inside a panel, uses it to perform | |||
| 6 | + | the operation, and returns only the outcome to the model. The secret | |||
| 7 | + | is never written to any JSON frame or the transcript. | |||
| 8 | + | ||||
| 9 | + | ## What it does | |||
| 10 | + | ||||
| 11 | + | Registers one LLM-callable tool: | |||
| 12 | + | ||||
| 13 | + | ``` | |||
| 14 | + | fetch_with_password(url: string) | |||
| 15 | + | ``` | |||
| 16 | + | ||||
| 17 | + | When the model calls it, a masked password panel opens: | |||
| 18 | + | ||||
| 19 | + | ``` | |||
| 20 | + | ╭─ Password required ─────────────────────╮ | |||
| 21 | + | │ URL: https://internal.example.com │ | |||
| 22 | + | │ │ | |||
| 23 | + | │ Password: ●●●●●●●▌ │ | |||
| 24 | + | ╰──────────────────────────────────────────╯ | |||
| 25 | + | type password enter confirm esc cancel | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 28 | + | The panel re-renders after every keystroke, showing bullets instead of | |||
| 29 | + | characters. Pressing Enter unblocks the tool goroutine, which uses the | |||
| 30 | + | password directly (in `doFetch`) and returns only the fetch outcome to | |||
| 31 | + | the model. | |||
| 32 | + | ||||
| 33 | + | ## Security property | |||
| 34 | + | ||||
| 35 | + | The password lives only in the extension process's memory. It is never | |||
| 36 | + | serialised into a JSON frame, never appears in the transcript, and | |||
| 37 | + | never reaches the model. The model sees: | |||
| 38 | + | ||||
| 39 | + | ``` | |||
| 40 | + | fetched https://internal.example.com successfully (password was 9 characters, not shown) | |||
| 41 | + | ``` | |||
| 42 | + | ||||
| 43 | + | ## Build | |||
| 44 | + | ||||
| 45 | + | ```bash | |||
| 46 | + | cd examples/extensions/secret | |||
| 47 | + | go build -o secret . | |||
| 48 | + | ``` | |||
| 49 | + | ||||
| 50 | + | ## Install | |||
| 51 | + | ||||
| 52 | + | ```bash | |||
| 53 | + | zot ext install . | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | ## Try it | |||
| 57 | + | ||||
| 58 | + | In zot, ask: | |||
| 59 | + | ||||
| 60 | + | > Fetch https://internal.example.com/report — it needs a password. | |||
| 61 | + | ||||
| 62 | + | The model calls `fetch_with_password`; the masked panel opens. Type | |||
| 63 | + | anything and press **Enter**. The model receives the result; the | |||
| 64 | + | password is gone. | |||
| 65 | + | ||||
| 66 | + | ## Adapting to real use | |||
| 67 | + | ||||
| 68 | + | Replace `doFetch` in `main.go` with a real HTTP request: | |||
| 69 | + | ||||
| 70 | + | ```go | |||
| 71 | + | func doFetch(url, password string) ext.ToolResult { | |||
| 72 | + | req, _ := http.NewRequest("GET", url, nil) | |||
| 73 | + | req.SetBasicAuth("user", password) | |||
| 74 | + | resp, err := http.DefaultClient.Do(req) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return ext.TextErrorResult("fetch failed: " + err.Error()) | |||
| 77 | + | } | |||
| 78 | + | defer resp.Body.Close() | |||
| 79 | + | body, _ := io.ReadAll(resp.Body) | |||
| 80 | + | return ext.TextResult(string(body)) | |||
| 81 | + | } | |||
| 82 | + | ``` | |||
| 83 | + | ||||
| 84 | + | The same pattern works for any credential type: API tokens, SSH | |||
| 85 | + | passphrases, TOTP codes, or freeform override strings. | |||
| 86 | + | ||||
| 87 | + | ## See also | |||
| 88 | + | ||||
| 89 | + | - `examples/extensions/approve` — same pattern for approve/deny gates | |||
| 90 | + | (no text input, just y/n) | |||
| 91 | + | - `docs/extensions.md` — full protocol reference | |||
A examples/extensions/secret/extension.json +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "secret", | |
| 3 | + | "version": "1.0.0", | |
| 4 | + | "exec": "./secret", | |
| 5 | + | "language": "go", | |
| 6 | + | "description": "collects secrets from the user via a masked panel; the model never sees the value", | |
| 7 | + | "enabled": true | |
| 8 | + | } | |
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "secret", | |||
| 3 | + | "version": "1.0.0", | |||
| 4 | + | "exec": "./secret", | |||
| 5 | + | "language": "go", | |||
| 6 | + | "description": "collects secrets from the user via a masked panel; the model never sees the value", | |||
| 7 | + | "enabled": true | |||
| 8 | + | } | |||
A examples/extensions/secret/main.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | // secret — demonstrates collecting a secret from the user via a | |
| 2 | + | // masked panel. The model never sees the value; it only sees the | |
| 3 | + | // outcome (success or failure). | |
| 4 | + | // | |
| 5 | + | // Registers one LLM-callable tool: | |
| 6 | + | // | |
| 7 | + | // fetch_with_password(url: string) | |
| 8 | + | // | |
| 9 | + | // When the model calls it, a panel opens with a masked password | |
| 10 | + | // field. The user types the password and presses Enter; the tool | |
| 11 | + | // goroutine uses it directly to perform a (fake) authenticated fetch | |
| 12 | + | // and returns only "fetched successfully" or an error to the model. | |
| 13 | + | // The password exists only in the extension process's memory and is | |
| 14 | + | // never written to any JSON frame or the transcript. | |
| 15 | + | // | |
| 16 | + | // Build: | |
| 17 | + | // | |
| 18 | + | // cd examples/extensions/secret | |
| 19 | + | // go build -o secret . | |
| 20 | + | // | |
| 21 | + | // Install: | |
| 22 | + | // | |
| 23 | + | // zot ext install . | |
| 24 | + | // | |
| 25 | + | // Try it — ask zot something like: | |
| 26 | + | // | |
| 27 | + | // "Fetch https://internal.example.com/report — it needs a password." | |
| 28 | + | // | |
| 29 | + | // The model calls fetch_with_password; a masked password panel opens. | |
| 30 | + | // Type anything and press Enter. The model receives the result without | |
| 31 | + | // ever seeing what you typed. | |
| 32 | + | package main | |
| 33 | + | ||
| 34 | + | import ( | |
| 35 | + | "encoding/json" | |
| 36 | + | "fmt" | |
| 37 | + | "os" | |
| 38 | + | "strings" | |
| 39 | + | "sync" | |
| 40 | + | ||
| 41 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | const schema = `{ | |
| 45 | + | "type": "object", | |
| 46 | + | "properties": { | |
| 47 | + | "url": { | |
| 48 | + | "type": "string", | |
| 49 | + | "description": "The URL to fetch." | |
| 50 | + | } | |
| 51 | + | }, | |
| 52 | + | "required": ["url"] | |
| 53 | + | }` | |
| 54 | + | ||
| 55 | + | func main() { | |
| 56 | + | e := ext.New("secret", "1.0.0") | |
| 57 | + | ||
| 58 | + | var mu sync.Mutex | |
| 59 | + | var counter int | |
| 60 | + | nextPID := func() string { | |
| 61 | + | mu.Lock() | |
| 62 | + | defer mu.Unlock() | |
| 63 | + | counter++ | |
| 64 | + | return fmt.Sprintf("secret-%d", counter) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | e.Tool("fetch_with_password", | |
| 68 | + | "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.", | |
| 69 | + | json.RawMessage(schema), | |
| 70 | + | func(args json.RawMessage) ext.ToolResult { | |
| 71 | + | var in struct { | |
| 72 | + | URL string `json:"url"` | |
| 73 | + | } | |
| 74 | + | if err := json.Unmarshal(args, &in); err != nil { | |
| 75 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |
| 76 | + | } | |
| 77 | + | if strings.TrimSpace(in.URL) == "" { | |
| 78 | + | return ext.TextErrorResult("url is required") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | pid := nextPID() | |
| 82 | + | ||
| 83 | + | type result struct { | |
| 84 | + | password string | |
| 85 | + | ok bool | |
| 86 | + | } | |
| 87 | + | ch := make(chan result, 1) | |
| 88 | + | ||
| 89 | + | // input and inputMu are owned by the key handler goroutine. | |
| 90 | + | var inputMu sync.Mutex | |
| 91 | + | var input string | |
| 92 | + | ||
| 93 | + | render := func() { | |
| 94 | + | inputMu.Lock() | |
| 95 | + | masked := strings.Repeat("●", len([]rune(input))) | |
| 96 | + | inputMu.Unlock() | |
| 97 | + | e.RenderPanel(pid, "Password required", | |
| 98 | + | []string{ | |
| 99 | + | " URL: " + in.URL, | |
| 100 | + | "", | |
| 101 | + | " Password: " + masked + "▌", | |
| 102 | + | }, | |
| 103 | + | "type password enter confirm esc cancel") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | e.OnPanelKey(pid, func(key, text string) { | |
| 107 | + | inputMu.Lock() | |
| 108 | + | switch key { | |
| 109 | + | case "rune": | |
| 110 | + | input += text | |
| 111 | + | inputMu.Unlock() | |
| 112 | + | render() | |
| 113 | + | return | |
| 114 | + | case "backspace": | |
| 115 | + | if len(input) > 0 { | |
| 116 | + | r := []rune(input) | |
| 117 | + | input = string(r[:len(r)-1]) | |
| 118 | + | } | |
| 119 | + | inputMu.Unlock() | |
| 120 | + | render() | |
| 121 | + | return | |
| 122 | + | case "enter": | |
| 123 | + | password := input | |
| 124 | + | inputMu.Unlock() | |
| 125 | + | e.ClosePanel(pid) | |
| 126 | + | ch <- result{password: password, ok: true} | |
| 127 | + | return | |
| 128 | + | case "esc": | |
| 129 | + | inputMu.Unlock() | |
| 130 | + | e.ClosePanel(pid) | |
| 131 | + | ch <- result{} | |
| 132 | + | return | |
| 133 | + | default: | |
| 134 | + | inputMu.Unlock() | |
| 135 | + | } | |
| 136 | + | }, func() { | |
| 137 | + | // Panel closed by host (user navigated away). | |
| 138 | + | select { | |
| 139 | + | case ch <- result{}: | |
| 140 | + | default: | |
| 141 | + | } | |
| 142 | + | }) | |
| 143 | + | ||
| 144 | + | // Open the panel from inside the tool handler. | |
| 145 | + | e.OpenPanel(pid, "Password required", | |
| 146 | + | []string{ | |
| 147 | + | " URL: " + in.URL, | |
| 148 | + | "", | |
| 149 | + | " Password: ▌", | |
| 150 | + | }, | |
| 151 | + | "type password enter confirm esc cancel") | |
| 152 | + | ||
| 153 | + | // Block until the user submits or cancels. | |
| 154 | + | r := <-ch | |
| 155 | + | if !r.ok { | |
| 156 | + | return ext.TextErrorResult("cancelled: user did not provide a password") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Use the password directly here. It never leaves this | |
| 160 | + | // process and is never written to any frame or transcript. | |
| 161 | + | return doFetch(in.URL, r.password) | |
| 162 | + | }) | |
| 163 | + | ||
| 164 | + | if err := e.Run(); err != nil { | |
| 165 | + | e.Logf("fatal: %v", err) | |
| 166 | + | os.Exit(1) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // doFetch is a stub. Replace with a real http.Get + BasicAuth or | |
| 171 | + | // token header in production use. | |
| 172 | + | func doFetch(url, password string) ext.ToolResult { | |
| 173 | + | if password == "" { | |
| 174 | + | return ext.TextErrorResult("fetch failed: empty password") | |
| 175 | + | } | |
| 176 | + | // Demonstrate that we *have* the password without logging it. | |
| 177 | + | return ext.TextResult(fmt.Sprintf( | |
| 178 | + | "fetched %s successfully (password was %d characters, not shown)", | |
| 179 | + | url, len([]rune(password)), | |
| 180 | + | )) | |
| 181 | + | } | |
| @@ -0,0 +1,181 @@ | |||||
| 1 | + | // secret — demonstrates collecting a secret from the user via a | |||
| 2 | + | // masked panel. The model never sees the value; it only sees the | |||
| 3 | + | // outcome (success or failure). | |||
| 4 | + | // | |||
| 5 | + | // Registers one LLM-callable tool: | |||
| 6 | + | // | |||
| 7 | + | // fetch_with_password(url: string) | |||
| 8 | + | // | |||
| 9 | + | // When the model calls it, a panel opens with a masked password | |||
| 10 | + | // field. The user types the password and presses Enter; the tool | |||
| 11 | + | // goroutine uses it directly to perform a (fake) authenticated fetch | |||
| 12 | + | // and returns only "fetched successfully" or an error to the model. | |||
| 13 | + | // The password exists only in the extension process's memory and is | |||
| 14 | + | // never written to any JSON frame or the transcript. | |||
| 15 | + | // | |||
| 16 | + | // Build: | |||
| 17 | + | // | |||
| 18 | + | // cd examples/extensions/secret | |||
| 19 | + | // go build -o secret . | |||
| 20 | + | // | |||
| 21 | + | // Install: | |||
| 22 | + | // | |||
| 23 | + | // zot ext install . | |||
| 24 | + | // | |||
| 25 | + | // Try it — ask zot something like: | |||
| 26 | + | // | |||
| 27 | + | // "Fetch https://internal.example.com/report — it needs a password." | |||
| 28 | + | // | |||
| 29 | + | // The model calls fetch_with_password; a masked password panel opens. | |||
| 30 | + | // Type anything and press Enter. The model receives the result without | |||
| 31 | + | // ever seeing what you typed. | |||
| 32 | + | package main | |||
| 33 | + | ||||
| 34 | + | import ( | |||
| 35 | + | "encoding/json" | |||
| 36 | + | "fmt" | |||
| 37 | + | "os" | |||
| 38 | + | "strings" | |||
| 39 | + | "sync" | |||
| 40 | + | ||||
| 41 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | const schema = `{ | |||
| 45 | + | "type": "object", | |||
| 46 | + | "properties": { | |||
| 47 | + | "url": { | |||
| 48 | + | "type": "string", | |||
| 49 | + | "description": "The URL to fetch." | |||
| 50 | + | } | |||
| 51 | + | }, | |||
| 52 | + | "required": ["url"] | |||
| 53 | + | }` | |||
| 54 | + | ||||
| 55 | + | func main() { | |||
| 56 | + | e := ext.New("secret", "1.0.0") | |||
| 57 | + | ||||
| 58 | + | var mu sync.Mutex | |||
| 59 | + | var counter int | |||
| 60 | + | nextPID := func() string { | |||
| 61 | + | mu.Lock() | |||
| 62 | + | defer mu.Unlock() | |||
| 63 | + | counter++ | |||
| 64 | + | return fmt.Sprintf("secret-%d", counter) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | e.Tool("fetch_with_password", | |||
| 68 | + | "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.", | |||
| 69 | + | json.RawMessage(schema), | |||
| 70 | + | func(args json.RawMessage) ext.ToolResult { | |||
| 71 | + | var in struct { | |||
| 72 | + | URL string `json:"url"` | |||
| 73 | + | } | |||
| 74 | + | if err := json.Unmarshal(args, &in); err != nil { | |||
| 75 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |||
| 76 | + | } | |||
| 77 | + | if strings.TrimSpace(in.URL) == "" { | |||
| 78 | + | return ext.TextErrorResult("url is required") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | pid := nextPID() | |||
| 82 | + | ||||
| 83 | + | type result struct { | |||
| 84 | + | password string | |||
| 85 | + | ok bool | |||
| 86 | + | } | |||
| 87 | + | ch := make(chan result, 1) | |||
| 88 | + | ||||
| 89 | + | // input and inputMu are owned by the key handler goroutine. | |||
| 90 | + | var inputMu sync.Mutex | |||
| 91 | + | var input string | |||
| 92 | + | ||||
| 93 | + | render := func() { | |||
| 94 | + | inputMu.Lock() | |||
| 95 | + | masked := strings.Repeat("●", len([]rune(input))) | |||
| 96 | + | inputMu.Unlock() | |||
| 97 | + | e.RenderPanel(pid, "Password required", | |||
| 98 | + | []string{ | |||
| 99 | + | " URL: " + in.URL, | |||
| 100 | + | "", | |||
| 101 | + | " Password: " + masked + "▌", | |||
| 102 | + | }, | |||
| 103 | + | "type password enter confirm esc cancel") | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | e.OnPanelKey(pid, func(key, text string) { | |||
| 107 | + | inputMu.Lock() | |||
| 108 | + | switch key { | |||
| 109 | + | case "rune": | |||
| 110 | + | input += text | |||
| 111 | + | inputMu.Unlock() | |||
| 112 | + | render() | |||
| 113 | + | return | |||
| 114 | + | case "backspace": | |||
| 115 | + | if len(input) > 0 { | |||
| 116 | + | r := []rune(input) | |||
| 117 | + | input = string(r[:len(r)-1]) | |||
| 118 | + | } | |||
| 119 | + | inputMu.Unlock() | |||
| 120 | + | render() | |||
| 121 | + | return | |||
| 122 | + | case "enter": | |||
| 123 | + | password := input | |||
| 124 | + | inputMu.Unlock() | |||
| 125 | + | e.ClosePanel(pid) | |||
| 126 | + | ch <- result{password: password, ok: true} | |||
| 127 | + | return | |||
| 128 | + | case "esc": | |||
| 129 | + | inputMu.Unlock() | |||
| 130 | + | e.ClosePanel(pid) | |||
| 131 | + | ch <- result{} | |||
| 132 | + | return | |||
| 133 | + | default: | |||
| 134 | + | inputMu.Unlock() | |||
| 135 | + | } | |||
| 136 | + | }, func() { | |||
| 137 | + | // Panel closed by host (user navigated away). | |||
| 138 | + | select { | |||
| 139 | + | case ch <- result{}: | |||
| 140 | + | default: | |||
| 141 | + | } | |||
| 142 | + | }) | |||
| 143 | + | ||||
| 144 | + | // Open the panel from inside the tool handler. | |||
| 145 | + | e.OpenPanel(pid, "Password required", | |||
| 146 | + | []string{ | |||
| 147 | + | " URL: " + in.URL, | |||
| 148 | + | "", | |||
| 149 | + | " Password: ▌", | |||
| 150 | + | }, | |||
| 151 | + | "type password enter confirm esc cancel") | |||
| 152 | + | ||||
| 153 | + | // Block until the user submits or cancels. | |||
| 154 | + | r := <-ch | |||
| 155 | + | if !r.ok { | |||
| 156 | + | return ext.TextErrorResult("cancelled: user did not provide a password") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Use the password directly here. It never leaves this | |||
| 160 | + | // process and is never written to any frame or transcript. | |||
| 161 | + | return doFetch(in.URL, r.password) | |||
| 162 | + | }) | |||
| 163 | + | ||||
| 164 | + | if err := e.Run(); err != nil { | |||
| 165 | + | e.Logf("fatal: %v", err) | |||
| 166 | + | os.Exit(1) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // doFetch is a stub. Replace with a real http.Get + BasicAuth or | |||
| 171 | + | // token header in production use. | |||
| 172 | + | func doFetch(url, password string) ext.ToolResult { | |||
| 173 | + | if password == "" { | |||
| 174 | + | return ext.TextErrorResult("fetch failed: empty password") | |||
| 175 | + | } | |||
| 176 | + | // Demonstrate that we *have* the password without logging it. | |||
| 177 | + | return ext.TextResult(fmt.Sprintf( | |||
| 178 | + | "fetched %s successfully (password was %d characters, not shown)", | |||
| 179 | + | url, len([]rune(password)), | |||
| 180 | + | )) | |||
| 181 | + | } | |||