From 679471a7daa24c50830df77cebc8fdbef4ca4b7d Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Thu, 3 Sep 2026 03:01:15 +0200 Subject: [PATCH] feat: add config_view and list_tools introspection tools --- AGENTS.md | 2 +- cmd/odek/introspect.go | 399 ++++++++++++++++++++++++++++++++++ cmd/odek/introspect_test.go | 413 ++++++++++++++++++++++++++++++++++++ cmd/odek/main.go | 30 +++ cmd/odek/serve.go | 25 +-- cmd/odek/serve_api.go | 110 ++-------- docs/CHEATSHEET.md | 2 + docs/MCP.md | 1 + docs/TOOL_SELECTION.md | 5 + docs/WEBUI.md | 7 +- 10 files changed, 883 insertions(+), 111 deletions(-) create mode 100644 cmd/odek/introspect.go create mode 100644 cmd/odek/introspect_test.go diff --git a/AGENTS.md b/AGENTS.md index 79c6b90..51ee85e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ ReAct cycle: observe → think → act → repeat. - **Execution budgets** — `limits` config section + `--max-runtime/--max-tool-calls/--max-input-tokens/--max-output-tokens/--max-cost-usd` on `run`; typed `budget.Error` → CLI exit code 4; session persisted before return. Per-model prices via `limits.model_prices` with flat-pair fallback; cost enforcement only when cap + prices configured. `odek init --global` scaffolds the section (zeros = off). `GET /api/limits` on serve exposes limits + effective prices for cost rendering. ### Tools -All built-in tools with zero subprocess forks: batch_read, batch_patch, parallel_shell, http_batch, math_eval, diff, count_lines, multi_grep, json_query, tree, checksum, sort, head_tail, base64, tr, word_count, transcribe, browser, read_file, write_file, search_files, patch, shell, delegate_tasks, session_search. +All built-in tools with zero subprocess forks: batch_read, batch_patch, parallel_shell, http_batch, math_eval, diff, count_lines, multi_grep, json_query, tree, checksum, sort, head_tail, base64, tr, word_count, transcribe, browser, read_file, write_file, search_files, patch, shell, delegate_tasks, session_search, config_view, list_tools. ### Terminal Rendering (`internal/render/`) Vertical space compression is baked into the render paths; blank lines removed from Iteration/FinalAnswer/Summary. Raw-mode cursor uses `\r\n` for cross-platform compatibility. diff --git a/cmd/odek/introspect.go b/cmd/odek/introspect.go new file mode 100644 index 0000000..b1bcc27 --- /dev/null +++ b/cmd/odek/introspect.go @@ -0,0 +1,399 @@ +package main + +// Introspection surfaces: the config_view and list_tools built-in tools plus +// the shared view builders they use. +// +// Design contract: ONE sanitizer, TWO consumers. The build*View functions +// below produce the sanitized rendering of operator configuration, and both +// the REST management API (serve_api.go / serve.go) and the agent-facing +// tools render exactly that output. Sanitization is STRUCTURAL — secrets +// (api_key, base_url, env maps, search backends) never enter the view map at +// build time; there is no render-time filter that a future edit could +// forget. A config view that leaked the LLM endpoint credentials would turn +// a read-only tool into key exfiltration for anything driving the model. +// +// Security posture of the tools: strictly read-only over in-memory operator +// state (same class as list_subagent_profiles — no approver, no +// DangerousConfig, no filesystem or network access). The tool structs never +// hold the raw config.ResolvedConfig — they receive only the pre-built view +// via toolConfig.Introspection, so the blast radius of a mistake in Call is +// the sanitized map, nothing else. + +import ( + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/BackendStack21/odek/internal/budget" + "github.com/BackendStack21/odek/internal/config" +) + +// ── shared view builders (REST + tools) ────────────────────────────────── + +// buildConfigView reports the operator-relevant resolved configuration as +// scalars and flags ONLY. Secrets (api_key, base_url, env maps, search +// backends) are deliberately excluded — see the file comment. Consumed by +// GET /api/config and the config_view tool; the two faces are pinned equal +// by TestRESTConfigViewMatchesToolAll. +func buildConfigView(resolved config.ResolvedConfig) map[string]any { + boolPtr := func(p *bool) any { + if p == nil { + return nil + } + return *p + } + return map[string]any{ + "model": resolved.Model, + "stream": resolved.Stream, + "compaction": resolved.Compaction, + "prompt_caching": resolved.PromptCaching, + "thinking": resolved.Thinking != "", + "max_iterations": resolved.MaxIter, + "max_tool_parallel": resolved.MaxToolParallel, + "max_concurrency": resolved.MaxConcurrency, + "interaction_mode": resolved.InteractionMode, + "no_agents_md": resolved.NoAgents, + "sandbox": map[string]any{ + "enabled": resolved.Sandbox, + "image": resolved.SandboxImage, + "network": resolved.SandboxNetwork, + "readonly": resolved.SandboxReadonly, + "memory": resolved.SandboxMemory, + "cpus": resolved.SandboxCPUs, + "user": resolved.SandboxUser, + }, + "memory": map[string]any{ + "enabled": boolPtr(resolved.Memory.Enabled), + "facts_limit_user": resolved.Memory.FactsLimitUser, + "facts_limit_env": resolved.Memory.FactsLimitEnv, + "extract_on_end": boolPtr(resolved.Memory.ExtractOnEnd), + "consolidate_on_end": boolPtr(resolved.Memory.ConsolidateOnEnd), + "min_turns_for_extraction": resolved.Memory.MinTurnsForExtraction, + }, + "skills": map[string]any{ + "max_auto_load": resolved.Skills.MaxAutoLoad, + "max_lazy_slots": resolved.Skills.MaxLazySlots, + }, + "tools": map[string]any{ + "enabled": resolved.Tools.Enabled, + "disabled": resolved.Tools.Disabled, + }, + "maintenance": map[string]any{ + "enabled": resolved.Maintenance.Enabled, + "interval_minutes": resolved.Maintenance.IntervalMinutes, + "sessions_max_age_days": resolved.Maintenance.SessionsMaxAgeDays, + "audit_max_age_days": resolved.Maintenance.AuditMaxAgeDays, + "plans_max_age_days": resolved.Maintenance.PlansMaxAgeDays, + }, + "dangerous_default_action": resolved.Dangerous.DefaultAction, + "guard_scan": guardScanView(resolved.Guard.Scan), + "subagent": map[string]any{ + // Raw resolved values; 0 = inherit the documented fallback + // (global max_concurrency, 1800s, 100 iterations, depth 2). + "max_concurrency": resolved.Subagent.MaxConcurrency, + "timeout_seconds": resolved.Subagent.TimeoutSeconds, + "max_iterations": resolved.Subagent.MaxIterations, + "max_depth": resolved.Subagent.MaxDepth, + "announce_budget": resolved.Subagent.AnnounceBudget, + "budget_inherit": resolved.Subagent.BudgetInherit, + "default_profile": resolved.Subagent.DefaultProfile, + }, + "background": map[string]any{ + "enabled": resolved.Background.Enabled, + "max_jobs": resolved.Background.MaxJobs, + "max_output_bytes": resolved.Background.MaxOutputBytes, + "max_timeout_seconds": resolved.Background.MaxTimeoutSeconds, + "notify": resolved.Background.Notify, + "on_session_end": resolved.Background.OnSessionEnd, + "wake_on_complete": resolved.Background.WakeOnComplete, + "wake_coalesce_ms": resolved.Background.WakeCoalesceMS, + "max_wakes_per_hour": resolved.Background.MaxWakesPerHour, + }, + "limits": buildLimitsView(resolved.Model, resolved.Limits), + } +} + +// buildLimitsView reports the execution-budget configuration plus the +// effective per-million token prices for the configured model +// (Limits.ResolvePrices). Zero/absent prices mean "costs unavailable", never +// $0. Consumed by GET /api/limits and the config_view limits section; the +// two faces are pinned equal by TestRESTLimitsMatchesSharedBuilder. +func buildLimitsView(configuredModel string, limits budget.Limits) map[string]any { + inPrice, outPrice := limits.ResolvePrices(configuredModel) + return map[string]any{ + "model": configuredModel, + "limits": limits, + "effective_prices": map[string]float64{ + "input_cost_per_million_usd": inPrice, + "output_cost_per_million_usd": outPrice, + }, + } +} + +// buildMCPServersView lists configured MCP servers with their extension +// limits. Command/args are operator config (the interactive approval UI +// already displays them verbatim); env values are withheld — they may carry +// credentials. Consumed by GET /api/mcp and the list_tools tool. +func buildMCPServersView(resolved config.ResolvedConfig) []mcpEntry { + project := map[string]bool{} + for _, n := range resolved.ProjectMCPServerNames { + project[n] = true + } + out := make([]mcpEntry, 0, len(resolved.MCPServers)) + for name, cfg := range resolved.MCPServers { + out = append(out, mcpEntry{ + Name: name, + Command: cfg.Command, + Args: cfg.Args, + Project: project[name], + AutoApprove: cfg.AutoApprove, + TimeoutSeconds: cfg.TimeoutSeconds, + MaxResponseBytes: cfg.MaxResponseBytes, + MaxResultChars: cfg.MaxResultChars, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// toolEnabled applies the resolved tools.enabled / tools.disabled filter — +// the same rule internal/tool.FilterTools uses. Shared by the REST tools +// view and the list_tools tool so the answer cannot drift. +func toolEnabled(name string, enabledSet, disabledSet map[string]bool, whitelistActive bool) bool { + return !disabledSet[name] && (!whitelistActive || enabledSet[name]) +} + +// credentialArgFlag matches CLI flags that introduce a credential value: +// --api-key, -token, --secret-file, --db-password, --credential, … +var credentialArgFlag = regexp.MustCompile(`(?i)^-{1,2}[a-z0-9_-]*(key|token|secret|password|passwd|credential)[a-z0-9_-]*$`) + +const redactedArgPlaceholder = "[redacted]" + +// redactMCPServersView is the defense-in-depth pass for the TOOL face of +// the MCP view: argv can carry credentials (--api-key sk-…), and the REST +// face's operator-only gate (CSRF token + loopback) does not exist for a +// model tool call. Command and non-credential args stay verbatim; values +// following a credential-ish flag (and --flag=value forms) are replaced. +// Env values are already withheld at build time. The input slice is never +// mutated — the REST face keeps verbatim argv, pinned by +// TestMCPServerArgsRedactedOnToolFace. +func redactMCPServersView(in []mcpEntry) []mcpEntry { + out := make([]mcpEntry, len(in)) + for i, e := range in { + e.Args = redactCredentialArgs(e.Args) + out[i] = e + } + return out +} + +func redactCredentialArgs(args []string) []string { + if len(args) == 0 { + return nil + } + out := make([]string, len(args)) + redactNext := false + for i, a := range args { + if redactNext { + out[i] = redactedArgPlaceholder + redactNext = false + continue + } + if eq := strings.IndexByte(a, '='); eq > 0 && credentialArgFlag.MatchString(a[:eq]) { + out[i] = a[:eq+1] + redactedArgPlaceholder + continue + } + if credentialArgFlag.MatchString(a) { + redactNext = true + } + out[i] = a + } + return out +} + +// ── config_view tool ───────────────────────────────────────────────────── + +// configViewSections names the view's section groups. Keys must exist in +// buildConfigView output — TestConfigViewToolSections fails loudly on drift. +var configViewSections = map[string][]string{ + "all": nil, // whole view + "core": {"model", "stream", "compaction", "prompt_caching", "thinking", "max_iterations", "max_tool_parallel", "max_concurrency", "interaction_mode", "no_agents_md"}, + "security": {"sandbox", "dangerous_default_action", "guard_scan", "tools"}, + "subagent": {"max_concurrency", "subagent"}, + "limits": {"limits"}, + "memory": {"memory"}, + "skills": {"skills"}, + "background": {"background"}, + "maintenance": {"maintenance"}, +} + +// configViewTool renders the sanitized resolved configuration for model +// consumption. +type configViewTool struct { + // view is the pre-built sanitized view (buildConfigView output), + // carried in via toolConfig.Introspection. Nil-safe: reserved-name + // probing constructs builtinTools with a zero toolConfig. + view map[string]any +} + +func (t *configViewTool) Name() string { return "config_view" } + +func (t *configViewTool) Description() string { + return "Read the sanitized, resolved configuration this odek run operates under — the " + + "operator's effective settings after the five-layer merge (secrets.env → global → " + + "project → env → flags). Sections: all (default), core (model/stream/iteration " + + "limits), security (sandbox, dangerous_default_action, guard_scan, tool filter), " + + "subagent (delegate_tasks budgets, default profile), limits (execution budgets + " + + "effective token prices), memory, skills, background, maintenance. Secrets (API " + + "keys, base URLs, env values) are structurally excluded. Read-only; renders the " + + "same view as the operator's GET /api/config. Use it to understand the security " + + "posture and budgets you and your sub-agents are running under." +} + +func (t *configViewTool) Schema() any { + sections := make([]string, 0, len(configViewSections)) + for name := range configViewSections { + sections = append(sections, name) + } + sort.Strings(sections) + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "section": map[string]any{ + "type": "string", + "enum": sections, + "description": "Config section to view. Omit or \"all\" for the full sanitized view.", + }, + }, + } +} + +func (t *configViewTool) Call(section string) (string, error) { + view := t.view + if view == nil { + view = map[string]any{} + } + if section == "" || section == "all" { + buf, err := json.Marshal(view) + if err != nil { + return "", fmt.Errorf("config_view: %w", err) + } + return string(buf), nil + } + + keys, ok := configViewSections[section] + if !ok { + names := make([]string, 0, len(configViewSections)) + for name := range configViewSections { + names = append(names, name) + } + sort.Strings(names) + return "", fmt.Errorf("config_view: unknown section %q (valid: %v)", section, names) + } + out := make(map[string]any, len(keys)) + for _, key := range keys { + if v, ok := view[key]; ok { + out[key] = v + } + } + buf, err := json.Marshal(out) + if err != nil { + return "", fmt.Errorf("config_view: %w", err) + } + return string(buf), nil +} + +// ── list_tools tool ────────────────────────────────────────────────────── + +// listToolsTool reports the tool registry actually constructed for THIS run +// plus the operator's filter state and the MCP server posture. The model +// otherwise only sees its own injected schemas — it cannot tell whether a +// missing tool was disabled by config, which MCP server owns a tool, or +// what limits apply to those servers. +type listToolsTool struct { + // registered is the live registry captured at the end of builtinTools + // (after conditional registrations), so the list is exactly what this + // process built — including config_view and list_tools themselves. + registered []string + + // toolsEnabled / toolsDisabled are the resolved filter lists. A nil + // toolsEnabled means no whitelist is active. + toolsEnabled []string + toolsDisabled []string + + // mcpServers is the pre-built sanitized MCP view (buildMCPServersView). + mcpServers []mcpEntry +} + +func (t *listToolsTool) Name() string { return "list_tools" } + +func (t *listToolsTool) Description() string { + return "List the tools actually registered for this run with their enabled/disabled " + + "state after the operator's tools.enabled/tools.disabled filter, plus the " + + "configured MCP servers (command, approval mode, per-server limits; env values " + + "withheld). Use it to reason about your own capabilities and what delegate_tasks " + + "sub-agents can access. Note: MCP tools are additionally withheld from untrusted " + + "sub-agents. Read-only; complements config_view (settings) and " + + "list_subagent_profiles (capability profiles)." +} + +func (t *listToolsTool) Schema() any { + // No parameters — the listing is static per-run state. + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +func (t *listToolsTool) Call(_ string) (string, error) { + enabledSet := map[string]bool{} + for _, n := range t.toolsEnabled { + enabledSet[n] = true + } + disabledSet := map[string]bool{} + for _, n := range t.toolsDisabled { + disabledSet[n] = true + } + whitelistActive := t.toolsEnabled != nil + + names := append([]string(nil), t.registered...) + sort.Strings(names) + out := make([]toolSummary, 0, len(names)) + for _, n := range names { + out = append(out, toolSummary{Name: n, Enabled: toolEnabled(n, enabledSet, disabledSet, whitelistActive)}) + } + + buf, err := json.Marshal(map[string]any{ + "tools": out, + "mcp_servers": t.mcpServers, + "note": "MCP tools are withheld from untrusted sub-agents (trust_level=untrusted); profile tool filters apply on top of this list.", + }) + if err != nil { + return "", fmt.Errorf("list_tools: %w", err) + } + return string(buf), nil +} + +// ── registration plumbing ──────────────────────────────────────────────── + +// IntrospectionState carries the pre-built sanitized views for the +// config_view / list_tools tools. Built ONCE by toolConfigFromResolved so +// the tool structs never hold the raw config.ResolvedConfig. +type IntrospectionState struct { + ConfigView map[string]any + ToolsEnabled []string + ToolsDisabled []string + MCPServers []mcpEntry +} + +func buildIntrospectionState(resolved config.ResolvedConfig) IntrospectionState { + return IntrospectionState{ + ConfigView: buildConfigView(resolved), + ToolsEnabled: resolved.Tools.Enabled, + ToolsDisabled: resolved.Tools.Disabled, + // Tool face: credential-ish argv values redacted (defense in + // depth — the REST /api/mcp face keeps verbatim argv). + MCPServers: redactMCPServersView(buildMCPServersView(resolved)), + } +} diff --git a/cmd/odek/introspect_test.go b/cmd/odek/introspect_test.go new file mode 100644 index 0000000..e4b386a --- /dev/null +++ b/cmd/odek/introspect_test.go @@ -0,0 +1,413 @@ +package main + +// Tests for the config_view / list_tools introspection tools and the shared +// view builders they use (introspect.go). The builders are shared with the +// REST management API handlers (handleConfigView, handleLimits, +// handleMCPServers, handleTools) so the tool face and the REST face of the +// same sanitized state can never drift apart — pinned by the parity tests. + +import ( + "encoding/json" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/BackendStack21/odek/internal/budget" + "github.com/BackendStack21/odek/internal/config" + "github.com/BackendStack21/odek/internal/danger" + "github.com/BackendStack21/odek/internal/mcpclient" +) + +// introspectionFixture returns a resolved config carrying the fields the +// introspection surfaces must render — plus secret fields that must never +// appear in any rendered view. +func introspectionFixture() config.ResolvedConfig { + return config.ResolvedConfig{ + Model: "test-model", + Stream: true, + MaxIter: 42, + MaxConcurrency: 3, + // Secrets: structural sanitization must exclude these from the + // view at build time — no render-time filtering to forget. + BaseURL: "https://llm.internal.example/v1", + APIKey: "sk-fixture-secret-123", + MCPServers: map[string]mcpclient.ServerConfig{ + "fs": { + Command: "npx", + Args: []string{"-y", "@model/filesystem", "--api-key", "sk-argv-secret-789"}, + Env: map[string]string{"FS_TOKEN": "env-secret-456"}, + AutoApprove: true, + TimeoutSeconds: 60, + MaxResponseBytes: 1 << 20, + MaxResultChars: 1000, + }, + }, + Subagent: config.SubagentResolved{ + MaxConcurrency: 2, + TimeoutSeconds: 1800, + MaxIterations: 100, + MaxDepth: 3, + AnnounceBudget: true, + BudgetInherit: "operator", + DefaultProfile: "default", + }, + Background: config.DefaultBackgroundConfig(), + Limits: budget.Limits{ + MaxToolCalls: 50, + ModelPrices: map[string]budget.ModelPrice{ + "test-model": {InputCostPerMillionUSD: 1.5, OutputCostPerMillionUSD: 6}, + }, + }, + } +} + +// ── config_view ────────────────────────────────────────────────────────── + +func TestConfigViewToolSections(t *testing.T) { + view := buildConfigView(introspectionFixture()) + tool := &configViewTool{view: view} + + t.Run("empty section defaults to all", func(t *testing.T) { + out, err := tool.Call("") + if err != nil { + t.Fatalf("Call(\"\") error: %v", err) + } + var m map[string]any + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"model", "sandbox", "memory", "skills", "tools", + "maintenance", "dangerous_default_action", "guard_scan", + "subagent", "background", "limits"} { + if _, ok := m[key]; !ok { + t.Errorf("all-section output missing key %q", key) + } + } + }) + + t.Run("security section", func(t *testing.T) { + out, err := tool.Call("security") + if err != nil { + t.Fatalf("Call(security) error: %v", err) + } + var m map[string]any + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"sandbox", "dangerous_default_action", "guard_scan", "tools"} { + if _, ok := m[key]; !ok { + t.Errorf("security section missing %q", key) + } + } + for _, key := range []string{"model", "memory", "limits", "background"} { + if _, ok := m[key]; ok { + t.Errorf("security section must not carry %q", key) + } + } + }) + + t.Run("subagent section", func(t *testing.T) { + out, err := tool.Call("subagent") + if err != nil { + t.Fatalf("Call(subagent) error: %v", err) + } + var m map[string]any + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("decode: %v", err) + } + sub, ok := m["subagent"].(map[string]any) + if !ok { + t.Fatalf("subagent section missing subagent object: %s", out) + } + if sub["timeout_seconds"].(float64) != 1800 { + t.Errorf("subagent.timeout_seconds = %v, want 1800", sub["timeout_seconds"]) + } + if sub["max_iterations"].(float64) != 100 { + t.Errorf("subagent.max_iterations = %v, want 100", sub["max_iterations"]) + } + if sub["default_profile"] != "default" { + t.Errorf("subagent.default_profile = %v, want default", sub["default_profile"]) + } + if sub["announce_budget"] != true { + t.Errorf("subagent.announce_budget = %v, want true", sub["announce_budget"]) + } + }) + + t.Run("background section", func(t *testing.T) { + out, err := tool.Call("background") + if err != nil { + t.Fatalf("Call(background) error: %v", err) + } + var m map[string]any + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("decode: %v", err) + } + bg, ok := m["background"].(map[string]any) + if !ok { + t.Fatalf("background section missing background object: %s", out) + } + if bg["enabled"] != true || bg["wake_on_complete"] != true { + t.Errorf("background defaults not rendered: %s", out) + } + }) + + t.Run("unknown section errors with valid names", func(t *testing.T) { + _, err := tool.Call("bogus") + if err == nil { + t.Fatal("unknown section must error") + } + if !strings.Contains(err.Error(), "security") { + t.Errorf("error should list valid sections, got: %v", err) + } + }) +} + +func TestConfigViewToolDoesNotLeakSecrets(t *testing.T) { + view := buildConfigView(introspectionFixture()) + tool := &configViewTool{view: view} + + out, err := tool.Call("") + if err != nil { + t.Fatalf("Call error: %v", err) + } + for _, secret := range []string{ + "sk-fixture-secret-123", + "llm.internal.example", + "env-secret-456", + "FS_TOKEN", + "api_key", + "base_url", + } { + if strings.Contains(out, secret) { + t.Errorf("sanitized config view leaks %q", secret) + } + } +} + +// ── list_tools ─────────────────────────────────────────────────────────── + +func TestListToolsToolReportsFilterState(t *testing.T) { + resolved := introspectionFixture() + tool := &listToolsTool{ + registered: []string{"shell", "read_file", "math_eval"}, + toolsEnabled: []string{"read_file"}, // whitelist active + toolsDisabled: []string{"shell"}, + mcpServers: buildMCPServersView(resolved), + } + out, err := tool.Call("") + if err != nil { + t.Fatalf("Call error: %v", err) + } + var m struct { + Tools []struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + } `json:"tools"` + MCPServers []map[string]any `json:"mcp_servers"` + } + if err := json.Unmarshal([]byte(out), &m); err != nil { + t.Fatalf("decode: %v", err) + } + enabled := map[string]bool{} + for _, tl := range m.Tools { + enabled[tl.Name] = tl.Enabled + } + if len(m.Tools) != 3 { + t.Fatalf("tools = %d entries, want 3", len(m.Tools)) + } + // Whitelist active + shell disabled ⇒ shell off; read_file on; math_eval + // not whitelisted ⇒ off. + if enabled["shell"] { + t.Error("shell should be disabled (disabled list)") + } + if !enabled["read_file"] { + t.Error("read_file should be enabled (whitelisted)") + } + if enabled["math_eval"] { + t.Error("math_eval should be disabled (not on active whitelist)") + } + if len(m.MCPServers) != 1 || m.MCPServers[0]["name"] != "fs" { + t.Fatalf("mcp_servers not rendered: %s", out) + } + if _, ok := m.MCPServers[0]["env"]; ok { + t.Error("mcp server entry must not carry env values") + } +} + +// ── zero-drift parity: REST face == tool face ──────────────────────────── + +func TestRESTConfigViewMatchesToolAll(t *testing.T) { + resolved := introspectionFixture() + + w := httptest.NewRecorder() + handleConfigView(resolved)(w, httptest.NewRequest("GET", "/api/config", nil)) + var fromREST map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &fromREST); err != nil { + t.Fatalf("decode REST body: %v", err) + } + + tool := &configViewTool{view: buildConfigView(resolved)} + out, err := tool.Call("") + if err != nil { + t.Fatalf("tool Call: %v", err) + } + var fromTool map[string]any + if err := json.Unmarshal([]byte(out), &fromTool); err != nil { + t.Fatalf("decode tool body: %v", err) + } + + if !reflect.DeepEqual(fromREST, fromTool) { + t.Error("GET /api/config and config_view(all) diverge — shared builder violated") + } +} + +func TestRESTLimitsMatchesSharedBuilder(t *testing.T) { + limits := introspectionFixture().Limits + + w := httptest.NewRecorder() + handleLimits("test-model", limits)(w, httptest.NewRequest("GET", "/api/limits", nil)) + var fromREST map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &fromREST); err != nil { + t.Fatalf("decode REST body: %v", err) + } + + builderView := buildLimitsView("test-model", limits) + // Compare like-for-like: decode the builder output through JSON so the + // struct-typed Limits is seen exactly as both faces marshal it. + builderJSON, err := json.Marshal(builderView) + if err != nil { + t.Fatalf("marshal builder view: %v", err) + } + var fromBuilder map[string]any + if err := json.Unmarshal(builderJSON, &fromBuilder); err != nil { + t.Fatalf("decode builder view: %v", err) + } + if !reflect.DeepEqual(fromREST, fromBuilder) { + t.Errorf("GET /api/limits and buildLimitsView diverge:\nrest: %v\ntool: %v", fromREST, fromBuilder) + } +} + +// ── registration wiring ────────────────────────────────────────────────── + +func TestIntrospectionToolsAreReserved(t *testing.T) { + reserved := reservedBuiltinToolNames() + for _, name := range []string{"config_view", "list_tools"} { + if !reserved[name] { + t.Errorf("reservedBuiltinToolNames missing %q — an MCP server could shadow it", name) + } + } +} + +func TestToolConfigFromResolvedBuildsIntrospection(t *testing.T) { + tc := toolConfigFromResolved(introspectionFixture()) + if tc.Introspection.ConfigView == nil { + t.Fatal("toolConfigFromResolved did not build Introspection.ConfigView") + } + if _, ok := tc.Introspection.ConfigView["subagent"]; !ok { + t.Error("ConfigView missing subagent section") + } + func() { + // Tool face must redact credential-ish argv (goes through the REAL + // wiring path: toolConfigFromResolved → buildIntrospectionState). + tc := toolConfigFromResolved(introspectionFixture()) + mcpJSON, err := json.Marshal(tc.Introspection.MCPServers) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(mcpJSON), "sk-argv-secret-789") { + t.Error("Introspection.MCPServers leaks credential argv — redaction not wired into the tool face") + } + if !strings.Contains(string(mcpJSON), "@model/filesystem") { + t.Error("tool face over-redacted benign argv") + } + }() +} + +func TestConfigViewToolToleratesNilView(t *testing.T) { + // reservedBuiltinToolNames constructs builtinTools with a zero + // toolConfig — the tools must survive a nil/empty view. + tool := &configViewTool{view: nil} + if _, err := tool.Call(""); err != nil { + t.Fatalf("nil view must not error: %v", err) + } +} + +// ── wiring: builtinTools must populate the live registry ──────────────── + +func TestBuiltinToolsWiresListToolsRegistry(t *testing.T) { + // P1 regression: the live-name capture was silently missing, so + // production list_tools always reported an empty registry while the + // hand-built test struct stayed green. + tc := toolConfigFromResolved(introspectionFixture()) + tools := builtinTools(danger.DangerousConfig{}, nil, nil, 1, "", tc, nil) + + var listTools *listToolsTool + seen := map[string]bool{} + for _, tl := range tools { + seen[tl.Name()] = true + if lt, ok := tl.(*listToolsTool); ok { + listTools = lt + } + } + if listTools == nil { + t.Fatal("list_tools not registered by builtinTools") + } + for _, want := range []string{"config_view", "list_tools", "read_file", "shell", "list_subagent_profiles"} { + if !seen[want] { + t.Errorf("builtinTools registry missing %q", want) + } + if !listTools.registeredWant(want) { + t.Errorf("list_tools.registered does not report %q — live capture missing", want) + } + } +} + +// registeredWant reports whether name is in the captured registry. +func (t *listToolsTool) registeredWant(name string) bool { + for _, n := range t.registered { + if n == name { + return true + } + } + return false +} + +// ── MCP argv redaction on the tool face ───────────────────────────────── + +func TestMCPServerArgsRedactedOnToolFace(t *testing.T) { + // P2: MCP argv can carry credentials (--api-key sk-…). The REST face + // keeps verbatim argv (CSRF-gated, operator-only); the tool face must + // redact credential-ish values before they enter model context. + resolved := introspectionFixture() + resolved.MCPServers["secretary"] = mcpclient.ServerConfig{ + Command: "uvx", + Args: []string{"mcp-harbor", "--api-key", "sk-argv-secret-789", "--verbose", "--token=BearerXYZ", "--port", "8080"}, + Env: map[string]string{"HARBOR_TOKEN": "env-secret-456"}, + } + + restFace := buildMCPServersView(resolved) + toolFace := redactMCPServersView(buildMCPServersView(resolved)) + + restJSON, _ := json.Marshal(restFace) + toolJSON, _ := json.Marshal(toolFace) + for _, secret := range []string{"sk-argv-secret-789", "BearerXYZ"} { + if !strings.Contains(string(restJSON), secret) { + t.Errorf("REST face must keep operator argv verbatim; lost %q", secret) + } + if strings.Contains(string(toolJSON), secret) { + t.Errorf("tool face leaks argv credential %q", secret) + } + } + var entries []mcpEntry + if err := json.Unmarshal(toolJSON, &entries); err != nil { + t.Fatalf("decode tool face: %v", err) + } + // Non-credential args and commands survive unredacted. + joined := toolJSON + for _, want := range []string{"mcp-harbor", "--verbose", "--port", "8080", "uvx"} { + if !strings.Contains(string(joined), want) { + t.Errorf("tool face over-redacted benign content: lost %q", want) + } + } +} diff --git a/cmd/odek/main.go b/cmd/odek/main.go index cd698ae..1302da8 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -2276,6 +2276,11 @@ type toolConfig struct { // The store it carries is shared with the loop engine via odek.New's // discovery of *loop.PlanTool in the returned tools slice. Planning *config.PlanningConfig + // Introspection carries the pre-built sanitized config view plus the + // tool-filter/MCP state for the config_view and list_tools tools. + // Built once by toolConfigFromResolved — the tool structs never hold + // the raw ResolvedConfig, so the sanitized boundary is structural. + Introspection IntrospectionState } // toolConfigFromResolved builds the toolConfig for builtinTools from a @@ -2293,6 +2298,8 @@ func toolConfigFromResolved(resolved config.ResolvedConfig) toolConfig { Planning: &resolved.Planning, Subagent: resolved.Subagent, Profiles: resolved.Profiles, + + Introspection: buildIntrospectionState(resolved), } } @@ -2329,6 +2336,13 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d dangerousConfig: dc, approver: approver, } + // listTools is held by reference so the live registry can be captured + // into it right before return (after all conditional registrations). + listTools := &listToolsTool{ + toolsEnabled: tcfg.Introspection.ToolsEnabled, + toolsDisabled: tcfg.Introspection.ToolsDisabled, + mcpServers: tcfg.Introspection.MCPServers, + } tools := []odek.Tool{ shell, &delegateTasksTool{ @@ -2348,6 +2362,13 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d profiles: tcfg.Profiles, defaultProfile: tcfg.Subagent.DefaultProfile, }, + // Introspection: sanitized resolved config + live registry state. + // Registered unconditionally (like list_subagent_profiles) so every + // surface — including sub-agents, which run builtinTools again with + // their own resolved config — can see the posture it operates under. + // Read-only over pre-built views; no approver, no DangerousConfig. + &configViewTool{view: tcfg.Introspection.ConfigView}, + listTools, &readFileTool{dangerousConfig: dc}, &writeFileTool{dangerousConfig: dc, restrictToCWD: true}, &searchFilesTool{dangerousConfig: dc}, @@ -2420,6 +2441,15 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d tools = appendBackgroundTools(tools, bg[0]) } + // Capture the live registry into list_tools AFTER all conditional + // registrations, so the tool reports exactly what this run constructed + // — including config_view and list_tools themselves. + names := make([]string, 0, len(tools)) + for _, t := range tools { + names = append(names, t.Name()) + } + listTools.registered = names + return tools } diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 192ec65..950fed7 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -2775,33 +2775,16 @@ func handleModelList(configuredModel string) http.HandlerFunc { // (Limits.ResolvePrices). Clients rendering session costs use // effective_prices directly; limits.model_prices lets them price other // models. When no prices are configured, effective_prices is 0/0 — clients -// should treat that as "costs unavailable". +// should treat that as "costs unavailable". The payload is built by +// buildLimitsView (introspect.go); the config_view tool renders the same +// map, pinned equal by TestRESTLimitsMatchesSharedBuilder. func handleLimits(configuredModel string, limits budget.Limits) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - type effectivePrices struct { - InputCostPerMillionUSD float64 `json:"input_cost_per_million_usd"` - OutputCostPerMillionUSD float64 `json:"output_cost_per_million_usd"` - } - type limitsResponse struct { - Model string `json:"model"` - Limits budget.Limits `json:"limits"` - EffectivePrices effectivePrices `json:"effective_prices"` - } - inPrice, outPrice := limits.ResolvePrices(configuredModel) - resp := limitsResponse{ - Model: configuredModel, - Limits: limits, - EffectivePrices: effectivePrices{ - InputCostPerMillionUSD: inPrice, - OutputCostPerMillionUSD: outPrice, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + writeAPIJSON(w, http.StatusOK, buildLimitsView(configuredModel, limits)) } } diff --git a/cmd/odek/serve_api.go b/cmd/odek/serve_api.go index d0034cd..b8b778b 100644 --- a/cmd/odek/serve_api.go +++ b/cmd/odek/serve_api.go @@ -636,8 +636,9 @@ func handleTools(resolved config.ResolvedConfig) http.HandlerFunc { out := make([]toolSummary, 0, len(names)) for _, n := range names { - enabled := !disabledSet[n] && (!whitelistActive || enabledSet[n]) - out = append(out, toolSummary{Name: n, Enabled: enabled}) + // Shared filter rule (introspect.go) — the same one the + // agent-facing list_tools tool applies. + out = append(out, toolSummary{Name: n, Enabled: toolEnabled(n, enabledSet, disabledSet, whitelistActive)}) } writeAPIJSON(w, http.StatusOK, map[string]any{ "tools": out, @@ -696,68 +697,17 @@ func stripUntrustedEnvelopes(s string) string { // ── GET /api/config (sanitized) ─────────────────────────────────────── // handleConfigView reports the operator-relevant resolved configuration as -// scalars and flags ONLY. Secrets (api_key, base_url, env maps, search -// backends) are deliberately excluded — a config view that leaks the LLM -// endpoint credentials would turn a read-only endpoint into key -// exfiltration for any local process that can guess the port. +// scalars and flags ONLY — the sanitization contract lives on +// buildConfigView (introspect.go), which excludes secrets structurally. The +// agent-facing config_view tool renders the same map; parity is pinned by +// TestRESTConfigViewMatchesToolAll. func handleConfigView(resolved config.ResolvedConfig) http.HandlerFunc { - boolPtr := func(p *bool) any { - if p == nil { - return nil - } - return *p - } return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - writeAPIJSON(w, http.StatusOK, map[string]any{ - "model": resolved.Model, - "stream": resolved.Stream, - "compaction": resolved.Compaction, - "prompt_caching": resolved.PromptCaching, - "thinking": resolved.Thinking != "", - "max_iterations": resolved.MaxIter, - "max_tool_parallel": resolved.MaxToolParallel, - "max_concurrency": resolved.MaxConcurrency, - "interaction_mode": resolved.InteractionMode, - "no_agents_md": resolved.NoAgents, - "sandbox": map[string]any{ - "enabled": resolved.Sandbox, - "image": resolved.SandboxImage, - "network": resolved.SandboxNetwork, - "readonly": resolved.SandboxReadonly, - "memory": resolved.SandboxMemory, - "cpus": resolved.SandboxCPUs, - "user": resolved.SandboxUser, - }, - "memory": map[string]any{ - "enabled": boolPtr(resolved.Memory.Enabled), - "facts_limit_user": resolved.Memory.FactsLimitUser, - "facts_limit_env": resolved.Memory.FactsLimitEnv, - "extract_on_end": boolPtr(resolved.Memory.ExtractOnEnd), - "consolidate_on_end": boolPtr(resolved.Memory.ConsolidateOnEnd), - "min_turns_for_extraction": resolved.Memory.MinTurnsForExtraction, - }, - "skills": map[string]any{ - "max_auto_load": resolved.Skills.MaxAutoLoad, - "max_lazy_slots": resolved.Skills.MaxLazySlots, - }, - "tools": map[string]any{ - "enabled": resolved.Tools.Enabled, - "disabled": resolved.Tools.Disabled, - }, - "maintenance": map[string]any{ - "enabled": resolved.Maintenance.Enabled, - "interval_minutes": resolved.Maintenance.IntervalMinutes, - "sessions_max_age_days": resolved.Maintenance.SessionsMaxAgeDays, - "audit_max_age_days": resolved.Maintenance.AuditMaxAgeDays, - "plans_max_age_days": resolved.Maintenance.PlansMaxAgeDays, - }, - "dangerous_default_action": resolved.Dangerous.DefaultAction, - "guard_scan": guardScanView(resolved.Guard.Scan), - }) + writeAPIJSON(w, http.StatusOK, buildConfigView(resolved)) } } @@ -778,44 +728,30 @@ func guardScanView(sc *guard.ScanConfig) map[string]any { // ── GET /api/mcp ────────────────────────────────────────────────────── +// mcpEntry is one sanitized MCP server row shared by GET /api/mcp and the +// list_tools tool (buildMCPServersView). +type mcpEntry struct { + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Project bool `json:"project,omitempty"` + AutoApprove bool `json:"auto_approve,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + MaxResponseBytes int64 `json:"max_response_bytes,omitempty"` + MaxResultChars int `json:"max_result_chars,omitempty"` +} + // handleMCPServers lists configured MCP servers with their extension // limits. Command/args are operator config (the interactive approval UI // already displays them verbatim); env values are withheld — they may carry -// credentials. +// credentials. The row shape lives on buildMCPServersView (introspect.go). func handleMCPServers(resolved config.ResolvedConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - project := map[string]bool{} - for _, n := range resolved.ProjectMCPServerNames { - project[n] = true - } - type mcpEntry struct { - Name string `json:"name"` - Command string `json:"command"` - Args []string `json:"args,omitempty"` - Project bool `json:"project,omitempty"` - AutoApprove bool `json:"auto_approve,omitempty"` - TimeoutSeconds int `json:"timeout_seconds,omitempty"` - MaxResponseBytes int64 `json:"max_response_bytes,omitempty"` - MaxResultChars int `json:"max_result_chars,omitempty"` - } - out := make([]mcpEntry, 0, len(resolved.MCPServers)) - for name, cfg := range resolved.MCPServers { - out = append(out, mcpEntry{ - Name: name, - Command: cfg.Command, - Args: cfg.Args, - Project: project[name], - AutoApprove: cfg.AutoApprove, - TimeoutSeconds: cfg.TimeoutSeconds, - MaxResponseBytes: cfg.MaxResponseBytes, - MaxResultChars: cfg.MaxResultChars, - }) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + out := buildMCPServersView(resolved) writeAPIJSON(w, http.StatusOK, map[string]any{"servers": out, "count": len(out)}) } } diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index fedcd25..8a805ec 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -423,4 +423,6 @@ odek mcp # stdio transport | `send_message` | Send text/photo/document to Telegram | | `skill_load`, `skill_list` | Read the loaded skill / list available skills | | `list_subagent_profiles` | Discover operator-defined sub-agent capability profiles (+ built-in default) | +| `config_view` | Read the sanitized resolved config (security posture, sub-agent budgets, limits; secrets excluded) | +| `list_tools` | List the live tool registry, filter state, and MCP server posture (credential argv redacted) | | `artifact_read` | Read a sub-agent result artifact by id | diff --git a/docs/MCP.md b/docs/MCP.md index 54ad7b9..99ccede 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -48,6 +48,7 @@ Default exposure (no `tools` config, no SearXNG): | `session_search`, `transcribe`, `vision` | sessions & media | | `plan`* | planning (*only when planning is enabled) | | `skill_load`, `skill_list`, `artifact_read`, `list_subagent_profiles` | skills, artifacts, sub-agent profiles | +| `config_view`, `list_tools` | sanitized config + tool-registry introspection | ### Sandbox diff --git a/docs/TOOL_SELECTION.md b/docs/TOOL_SELECTION.md index 9ea7c88..812fe80 100644 --- a/docs/TOOL_SELECTION.md +++ b/docs/TOOL_SELECTION.md @@ -23,6 +23,10 @@ environment supports: is initialized) - Sub-agent profiles: `list_subagent_profiles` (operator-defined capability profiles + the built-in default) +- Introspection: `config_view` (sanitized resolved config — security + posture, sub-agent budgets, limits; secrets structurally excluded), + `list_tools` (live registry + enabled/disabled filter state + MCP server + posture with credential argv redacted) - Artifacts: `artifact_read` (parent-side reader for sub-agent result artifacts) - MCP tools: prefixed as `__` (only when `mcp_servers` are configured) @@ -216,6 +220,7 @@ Use these exact names in config, env vars, and CLI flags: | Session search | `session_search` | | Skills | `skill_load`, `skill_list` | | Sub-agent support | `list_subagent_profiles`, `artifact_read` | +| Introspection | `config_view`, `list_tools` | | Telegram-only | `send_message`, `clarify` (auto-injected by `odek telegram`; ignored by other modes) | | MCP | `__` | diff --git a/docs/WEBUI.md b/docs/WEBUI.md index e39af69..a1cbc4e 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -501,8 +501,11 @@ handler's defers tear down the agent and sandbox cleanly. Sanitized resolved-config view: model, sandbox knobs, stream/compaction/ caching flags, iteration/parallelism limits, memory/skills/tool-filter summaries, maintenance retention, dangerous default action, guard scan -toggles. Secrets (`api_key`, `base_url`, env values, search backends) are -never included. +toggles, sub-agent budgets (`subagent`), background-command settings +(`background`), and execution budgets with effective token prices +(`limits`). Secrets (`api_key`, `base_url`, env values, search backends) +are never included. The agent-facing `config_view` tool renders this same +view (section-filterable); parity is pinned by tests. ### `GET /api/mcp`