Skip to content

Commit defcc75

Browse files
committed
feat(tools): configurable LLM tool list via tools config, env, and --tool/--no-tool
Add a new config section with (whitelist) and (blacklist) plus CLI flags / and env vars / . This supports deployments where odek should only expose a subset of tools, e.g. a chatbot with web_search + voice but no shell or file writes. Key changes: - internal/config/loader.go: ToolConfig/ToolsConfig types, file/env/CLI merge logic, and project-level security restriction (project config can only disable, never enable). - internal/tool/registry.go: FilterTools helper with whitelist/blacklist and required-tool preservation. - odek.go: ToolFilterConfig on Config; memory tool is no longer appended unconditionally — it respects the filter. - cmd/odek/*.go: wire filtering into run, continue, repl, serve, telegram, subagent, schedule, and mcp surfaces. - Docs and help text updated with examples and env vars. Tests: - internal/config/tools_test.go - internal/tool/filter_test.go - cmd/odek/run_flags_tools_test.go - odek_test.go memory-filter regression tests All existing tests pass.
1 parent b179690 commit defcc75

15 files changed

Lines changed: 433 additions & 7 deletions

File tree

cmd/odek/main.go

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/BackendStack21/odek/internal/session"
2727
"github.com/BackendStack21/odek/internal/skills"
2828
"github.com/BackendStack21/odek/internal/telegram"
29+
"github.com/BackendStack21/odek/internal/tool"
2930
)
3031

3132
// version is set at build time via ldflags: -ldflags "-X main.version=v0.2.1"
@@ -253,8 +254,14 @@ type runFlags struct {
253254
NoAgents *bool // nil = not set
254255
PromptCaching *bool // nil = not set; true = enable prompt caching
255256
Session *bool // nil = not set; true = save session after run
256-
Learn *bool // nil = not set; true = enable skills learning mode
257-
Task string
257+
Learn *bool // nil = not set; true = enable skills learning mode
258+
Task string
259+
260+
// ToolsEnabled and ToolsDisabled control which tools are exposed to the LLM.
261+
// Repeated --tool/--no-tool flags accumulate. They are the highest priority
262+
// layer after file config and env vars.
263+
ToolsEnabled []string
264+
ToolsDisabled []string
258265
Ctx []string // --ctx files to attach
259266

260267
// Sandbox-specific CLI flags
@@ -333,6 +340,18 @@ func parseRunFlags(args []string) (runFlags, error) {
333340
case "--no-learn":
334341
f.Learn = boolPtr(false)
335342
i++
343+
case "--tool":
344+
if i+1 >= len(args) {
345+
return f, fmt.Errorf("--tool requires a value")
346+
}
347+
f.ToolsEnabled = append(f.ToolsEnabled, args[i+1])
348+
i += 2
349+
case "--no-tool":
350+
if i+1 >= len(args) {
351+
return f, fmt.Errorf("--no-tool requires a value")
352+
}
353+
f.ToolsDisabled = append(f.ToolsDisabled, args[i+1])
354+
i += 2
336355
case "--no-color":
337356
f.NoColor = boolPtr(true)
338357
i++
@@ -604,6 +623,8 @@ Run flags:
604623
--session Save conversation as a multi-turn session
605624
--learn Enable skill learning mode — on by default, no flag needed
606625
--no-learn Disable skill learning mode (overrides config/default)
626+
--tool <name> Enable a tool for the LLM (repeatable)
627+
--no-tool <name> Disable a tool for the LLM (repeatable)
607628
--system <prompt> System prompt override
608629
609630
Skill commands:
@@ -641,6 +662,8 @@ Environment variables:
641662
ODEK_NO_COLOR true/false — disable colors
642663
ODEK_NO_AGENTS true/false — skip AGENTS.md
643664
ODEK_SYSTEM System prompt override
665+
ODEK_TOOLS_ENABLED Comma-separated tool whitelist
666+
ODEK_TOOLS_DISABLED Comma-separated tool blacklist
644667
ODEK_SANDBOX_IMAGE Docker image for sandbox container
645668
ODEK_SANDBOX_NETWORK Network mode (none | bridge | host)
646669
ODEK_SANDBOX_READONLY true/false — mount read-only
@@ -669,6 +692,10 @@ const defaultConfigTemplate = `{
669692
"sandbox_user": "",
670693
"sandbox_env": {},
671694
"sandbox_volumes": [],
695+
"tools": {
696+
"enabled": [],
697+
"disabled": []
698+
},
672699
"dangerous": {
673700
"action": "prompt",
674701
"non_interactive": "deny",
@@ -835,6 +862,8 @@ func run(args []string) error {
835862
Learn: f.Learn,
836863
System: f.System,
837864
Task: f.Task,
865+
ToolsEnabled: f.ToolsEnabled,
866+
ToolsDisabled: f.ToolsDisabled,
838867

839868
SandboxImage: f.SandboxImage,
840869
SandboxNetwork: f.SandboxNetwork,
@@ -881,6 +910,9 @@ func run(args []string) error {
881910
var sandboxCleanup func() error
882911
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, nil)
883912

913+
// Apply tool filtering based on configuration.
914+
tools = filterBuiltinTools(tools, resolved.Tools)
915+
884916
// MCP server tools
885917
var mcpCleanup func()
886918
if len(resolved.MCPServers) > 0 {
@@ -951,6 +983,7 @@ func run(args []string) error {
951983
ThinkingBudget: f.ThinkingBudget,
952984
Temperature: 0, // deterministic by default; override with --temperature
953985
Tools: tools,
986+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
954987
SandboxCleanup: sandboxCleanup,
955988
Renderer: rend,
956989
Skills: skillsCfg,
@@ -1235,6 +1268,38 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d
12351268
return tools
12361269
}
12371270

1271+
// filterBuiltinTools applies the configured tools.enabled / tools.disabled
1272+
// lists to a slice of tools. Unknown names are ignored. Required tools are
1273+
// always preserved.
1274+
func filterBuiltinTools(tools []odek.Tool, cfg config.ToolConfig, required ...map[string]bool) []odek.Tool {
1275+
var req map[string]bool
1276+
if len(required) > 0 {
1277+
req = required[0]
1278+
}
1279+
adapted := make([]tool.Tool, len(tools))
1280+
for i, t := range tools {
1281+
adapted[i] = odekToolAdapter{t}
1282+
}
1283+
filtered := tool.FilterTools(adapted, cfg.Enabled, cfg.Disabled, req)
1284+
out := make([]odek.Tool, len(filtered))
1285+
for i, t := range filtered {
1286+
out[i] = t.(odekToolAdapter).tool
1287+
}
1288+
return out
1289+
}
1290+
1291+
// odekToolAdapter bridges odek.Tool to internal/tool.Tool.
1292+
type odekToolAdapter struct {
1293+
tool odek.Tool
1294+
}
1295+
1296+
func (a odekToolAdapter) Name() string { return a.tool.Name() }
1297+
func (a odekToolAdapter) Description() string { return a.tool.Description() }
1298+
func (a odekToolAdapter) Schema() any { return a.tool.Schema() }
1299+
func (a odekToolAdapter) Call(args string) (string, error) {
1300+
return a.tool.Call(args)
1301+
}
1302+
12381303
// loadMCPTools connects to configured MCP servers and appends their tools
12391304
// to the tool slice. Returns a cleanup function that closes all connections.
12401305
// The passed-in tool slice pointer is extended with ToolAdapters.
@@ -1792,6 +1857,10 @@ func continueCmd(args []string) error {
17921857
)
17931858
}
17941859
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, store)
1860+
1861+
// Apply tool filtering based on configuration.
1862+
tools = filterBuiltinTools(tools, resolved.Tools)
1863+
17951864
var sandboxCleanup func() error
17961865

17971866
// MCP server tools
@@ -1853,6 +1922,7 @@ func continueCmd(args []string) error {
18531922
Thinking: resolved.Thinking,
18541923
Temperature: 0, // deterministic by default; override with --temperature
18551924
Tools: tools,
1925+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
18561926
SandboxCleanup: sandboxCleanup,
18571927
Renderer: rend,
18581928
Skills: skillsCfg,

cmd/odek/mcp.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ Flags:
7676
// Build tools
7777
toolSet := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
7878

79+
// Apply tool filtering based on configuration.
80+
toolSet = filterBuiltinTools(toolSet, resolved.Tools)
81+
7982
// MCP server tools — connect and discover before sandbox
8083
var mcpCleanup func()
8184
if len(resolved.MCPServers) > 0 {

cmd/odek/repl.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ func replCmd(args []string) error {
7979
)
8080
}
8181
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
82+
83+
// Apply tool filtering based on configuration.
84+
tools = filterBuiltinTools(tools, resolved.Tools)
85+
8286
var sandboxCleanup func() error
8387

8488
// MCP server tools
@@ -139,6 +143,7 @@ func replCmd(args []string) error {
139143
Thinking: resolved.Thinking,
140144
ThinkingBudget: f.ThinkingBudget,
141145
Tools: tools,
146+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
142147
SandboxCleanup: sandboxCleanup,
143148
Renderer: rend,
144149
Skills: skillsCfg,

cmd/odek/schedule.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,10 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
671671
dangerCfg := buildHeadlessDangerConfig(resolved)
672672

673673
tools := builtinTools(dangerCfg, nil, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, nil)
674+
675+
// Apply tool filtering based on configuration.
676+
tools = filterBuiltinTools(tools, resolved.Tools)
677+
674678
tools = append(tools, mcpTools...)
675679

676680
// Capture cumulative token usage from the final iteration so the Runner
@@ -691,6 +695,7 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
691695
Thinking: resolved.Thinking,
692696
Temperature: 0,
693697
Tools: tools,
698+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
694699
Renderer: render.New(io.Discard, false), // silent: unattended
695700
InteractionMode: "off",
696701
PromptCaching: resolved.PromptCaching,

cmd/odek/serve.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ func serveCmd(args []string) error {
185185
var sandboxReadonly *bool
186186
var promptCaching *bool
187187
var sandboxImage, sandboxNetwork, sandboxMemory, sandboxCPUs, sandboxUser string
188+
var toolsEnabled, toolsDisabled []string
188189

189190
for i := 0; i < len(args); i++ {
190191
switch args[i] {
@@ -232,6 +233,16 @@ func serveCmd(args []string) error {
232233
}
233234
case "--prompt-caching":
234235
promptCaching = boolPtr(true)
236+
case "--tool":
237+
i++
238+
if i < len(args) {
239+
toolsEnabled = append(toolsEnabled, args[i])
240+
}
241+
case "--no-tool":
242+
i++
243+
if i < len(args) {
244+
toolsDisabled = append(toolsDisabled, args[i])
245+
}
235246
default:
236247
return fmt.Errorf("unknown flag %q for serve", args[i])
237248
}
@@ -246,6 +257,8 @@ func serveCmd(args []string) error {
246257
SandboxMemory: sandboxMemory,
247258
SandboxCPUs: sandboxCPUs,
248259
SandboxUser: sandboxUser,
260+
ToolsEnabled: toolsEnabled,
261+
ToolsDisabled: toolsDisabled,
249262
})
250263
// Serve mode default-on for sandbox: the Web UI surface is the
251264
// largest blast radius (browser-driven tool calls, untrusted-page
@@ -331,6 +344,8 @@ Flags:
331344
--sandbox-memory limit Container memory limit (e.g. 512m, 2g)
332345
--sandbox-cpus limit Container CPU limit (e.g. 0.5, 2, 4)
333346
--sandbox-user user Container user (e.g. 1000:1000)
347+
--tool name Enable a tool for the LLM (repeatable)
348+
--no-tool name Disable a tool for the LLM (repeatable)
334349
--help, -h Show this help`)
335350
}
336351

@@ -412,6 +427,9 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
412427

413428
tools := builtinTools(resolved.Dangerous, sm, approver, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
414429

430+
// Apply tool filtering based on configuration.
431+
tools = filterBuiltinTools(tools, resolved.Tools)
432+
415433
// Find the delegateTasksTool to wire up sub-agent log streaming
416434
var subagentTool *delegateTasksTool
417435
for _, t := range tools {
@@ -503,6 +521,7 @@ func newServeAgent(resolved config.ResolvedConfig, system string, sendFn func(v
503521
Thinking: resolved.Thinking,
504522
InteractionMode: resolved.InteractionMode,
505523
Tools: tools,
524+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
506525
// SandboxCleanup is intentionally NOT passed here. In serve mode,
507526
// cleanup is the caller's responsibility (handleWS defers it).
508527
// Passing it here would cause agent.Close() to call docker rm -f,

cmd/odek/subagent.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@ func subagentCmd(args []string) error {
352352
)
353353
}
354354
tools := builtinTools(resolved.Dangerous, sm, nil, resolved.MaxConcurrency, resolved.APIKey, toolConfig{WebSearch: resolved.WebSearch}, nil)
355+
356+
// Apply tool filtering based on configuration.
357+
tools = filterBuiltinTools(tools, resolved.Tools)
358+
355359
var sandboxCleanup func() error
356360

357361
// MCP server tools
@@ -416,6 +420,7 @@ func subagentCmd(args []string) error {
416420
NoProjectFile: resolved.NoAgents,
417421
Thinking: resolved.Thinking,
418422
Tools: tools,
423+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
419424
SandboxCleanup: sandboxCleanup,
420425
Renderer: rend,
421426
Skills: &resolved.Skills,

cmd/odek/telegram.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,11 @@ func handleChatMessage(
12141214
// Build the agent with Telegram approver.
12151215
tools := builtinTools(resolved.Dangerous, nil, approver, resolved.MaxConcurrency, resolved.APIKey, toolConfig{Transcription: resolved.Transcription, Vision: resolved.Vision, WebSearch: resolved.WebSearch}, sessionManager.Store)
12161216

1217+
// Apply tool filtering based on configuration, but preserve Telegram's
1218+
// required tools so the bot can always respond and ask clarifications.
1219+
requiredTelegramTools := map[string]bool{"send_message": true, "clarify": true}
1220+
tools = filterBuiltinTools(tools, resolved.Tools, requiredTelegramTools)
1221+
12171222
modelLabel := odek.ProfileLabel(resolved.Model)
12181223
if modelLabel == "" {
12191224
modelLabel = "deepseek-v4-flash"
@@ -1516,6 +1521,7 @@ func handleChatMessage(
15161521
Skills: skillsCfg,
15171522
Thinking: resolved.Thinking,
15181523
Tools: agentTools,
1524+
ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled},
15191525
Renderer: rend,
15201526
ToolEventHandler: func(event string, name string, data string) {
15211527
// Enhance mode: send new messages with narrated descriptions.

docs/CLI.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
| `--session` | bool | false | Save conversation as a multi-turn session |
5252
| `--learn` | bool | `true` | Enable skill learning mode (detects patterns, saves skills). On by default |
5353
| `--no-learn` | bool | `false` | Disable skill learning mode (overrides config/default) |
54+
| `--tool <name>` | string || Enable a specific tool for the LLM (repeatable). Highest-priority layer for the tool whitelist. |
55+
| `--no-tool <name>` | string || Disable a specific tool for the LLM (repeatable). Merges with lower-priority disabled lists. |
5456
| `--system <prompt>` | string | built-in | Override system prompt |
5557
| `--ctx <files>` / `-c` | string || Attach comma-separated files as context blocks |
5658

docs/CONFIG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,52 @@ Project-level `odek.json` cannot set `schedules.dangerous`; configure it via `~/
556556

557557
Full guide: [docs/SCHEDULES.md](SCHEDULES.md).
558558

559+
## Tool configuration
560+
561+
Control which tools are exposed to the LLM. Use this to deploy locked-down
562+
agents — for example, a chatbot with only `web_search`, `transcribe`, and
563+
`vision`, or a read-only research assistant with no write tools.
564+
565+
```json
566+
{
567+
"tools": {
568+
"enabled": ["web_search", "transcribe", "vision"],
569+
"disabled": ["shell", "write_file", "patch"]
570+
}
571+
}
572+
```
573+
574+
| Field | Env | Default | Description |
575+
|---|---|---|---|
576+
| `enabled` | `ODEK_TOOLS_ENABLED` | unset | Whitelist. When set, only these tools are registered. Comma-separated in env. |
577+
| `disabled` | `ODEK_TOOLS_DISABLED` | unset | Blacklist. These tools are removed from the default set. Comma-separated in env. |
578+
579+
CLI flags override file and env config:
580+
581+
```bash
582+
# Whitelist mode: only these tools
583+
odek run --tool web_search --tool vision "what's new in Go?"
584+
585+
# Blacklist mode: remove specific tools
586+
odek run --no-tool shell --no-tool write_file "review this code"
587+
588+
# Environment
589+
ODEK_TOOLS_ENABLED=web_search,vision odek run "search and summarize"
590+
```
591+
592+
Resolution rules:
593+
594+
- `enabled` is set by the highest-priority layer that provides it.
595+
- `disabled` is merged across layers.
596+
- If both are present: start from `enabled`, then subtract `disabled`.
597+
- Unknown tool names are silently ignored.
598+
- The `memory` tool is also subject to this filter, so a whitelist must
599+
include `"memory"` if you want persistent memory.
600+
601+
Project-level `./odek.json` **cannot enable tools** — it may only append to
602+
`disabled`. This prevents a malicious repository from widening the tool
603+
surface.
604+
559605
## Tool Progress
560606

561607
Controls how per-tool progress messages appear inside the Telegram bot during agent runs. Independent from `interaction_mode` — you can have engaging terminal output with minimal Telegram progress, or verbose terminal with rich progress bubbles.

0 commit comments

Comments
 (0)