Skip to content

Commit 6497315

Browse files
authored
feat(mcp): auto_approve server flag with operator-owned trust rules (#136)
Adds auto_approve: true|false to mcp_servers entries. A trusted server skips the project-server approval prompt and the per-tool registration prompts; schema guard scans, size caps, name-shadowing rejection, env sanitization, and artifact-ref fail-closed all still apply — the flag removes prompts, not checks. Trust rules (the approval gate exists because ./odek.json is attacker-controllable, so the flag cannot be): - global ~/.odek/config.json: honored on any server entry - global trust marker: a command-less global entry whose name matches a project-defined server applies the flag to it (marker never becomes a connectable entry); project definition changes no longer re-prompt - project ./odek.json: stripped with a warning — a cloned repo must never self-approve its MCP servers Also exposes auto_approve in GET /api/mcp. Docs: field reference, approval methods list, and a dedicated section in docs/MCP.md. Tests: gate skips (server + per-tool, empty stdin proves no prompt), and loader trust rules (project strip, marker merge by name, bare marker dropped, global flag preserved).
1 parent 62a3877 commit 6497315

7 files changed

Lines changed: 204 additions & 1 deletion

File tree

‎cmd/odek/mcp_approval.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ func approveMCPServersWithTTY(resolved config.ResolvedConfig, stdin io.Reader, s
8787
continue
8888
}
8989

90+
// Operator-marked trusted: auto_approve granted in the global
91+
// config (the loader strips project-level declarations, so a
92+
// cloned repo cannot reach this branch for an unvetted server).
93+
if cfg.AutoApprove {
94+
continue
95+
}
96+
9097
if !tty {
9198
return fmt.Errorf(
9299
"project-level MCP server %q (%s %q) requires explicit approval\n"+
@@ -248,7 +255,9 @@ func approveMCPToolsWithTTY(projectDir, serverName string, cfg mcpclient.ServerC
248255
}
249256

250257
key := mcpToolApprovalKey(projectDir, serverName, def.Name, cfg, schemaHash, def.Description)
251-
if approved[key] {
258+
// Schema guard scans and size caps above still ran — auto_approve
259+
// removes the prompt friction, not the safety checks.
260+
if cfg.AutoApprove || approved[key] {
252261
out = append(out, def)
253262
continue
254263
}

‎cmd/odek/mcp_approval_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,47 @@ func TestMCPApprovalKey_IncludesEnv(t *testing.T) {
199199
}
200200
}
201201

202+
// An operator-granted auto_approve (loader semantics: only the global
203+
// config can set it) must skip the server prompt entirely — an empty stdin
204+
// proves no prompt was attempted, since a prompt would fail the read.
205+
func TestApproveMCPServers_AutoApproveSkipsPrompt(t *testing.T) {
206+
setupTestHome(t)
207+
resolved := config.ResolvedConfig{
208+
MCPServers: map[string]mcpclient.ServerConfig{
209+
"trusted": {Command: "node", Args: []string{"server.js"}, AutoApprove: true},
210+
},
211+
ProjectMCPServerNames: []string{"trusted"},
212+
}
213+
214+
var out bytes.Buffer
215+
err := approveMCPServersWithTTY(resolved, strings.NewReader(""), &out, true)
216+
if err != nil {
217+
t.Fatalf("auto-approved server errored: %v", err)
218+
}
219+
if strings.Contains(out.String(), "Approve?") {
220+
t.Errorf("auto-approved server still prompted: %q", out.String())
221+
}
222+
}
223+
224+
// Per-tool prompts are skipped for auto-approved servers, while schema
225+
// guard scans still run (a poisoned schema is rejected, not prompted).
226+
func TestApproveMCPTools_AutoApproveSkipsToolPrompts(t *testing.T) {
227+
setupTestHome(t)
228+
cfg := mcpclient.ServerConfig{Command: "node", Args: []string{"server.js"}, AutoApprove: true}
229+
defs := []mcpclient.ToolDef{
230+
{Name: "fetch", Description: "Fetch a URL"},
231+
{Name: "query", Description: "Run a query"},
232+
}
233+
out, err := approveMCPToolsWithTTY("/proj", "trusted", cfg, defs,
234+
strings.NewReader(""), &bytes.Buffer{}, true, nil, guard.Config{})
235+
if err != nil {
236+
t.Fatalf("auto-approved tools errored: %v", err)
237+
}
238+
if len(out) != 2 {
239+
t.Fatalf("auto-approved tools = %d, want 2", len(out))
240+
}
241+
}
242+
202243
func TestApproveMCPServers_PromptShowsEnvValues(t *testing.T) {
203244
setupTestHome(t)
204245
resolved := config.ResolvedConfig{

‎cmd/odek/serve_api.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@ func handleMCPServers(resolved config.ResolvedConfig) http.HandlerFunc {
700700
Command string `json:"command"`
701701
Args []string `json:"args,omitempty"`
702702
Project bool `json:"project,omitempty"`
703+
AutoApprove bool `json:"auto_approve,omitempty"`
703704
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
704705
MaxResponseBytes int64 `json:"max_response_bytes,omitempty"`
705706
MaxResultChars int `json:"max_result_chars,omitempty"`
@@ -711,6 +712,7 @@ func handleMCPServers(resolved config.ResolvedConfig) http.HandlerFunc {
711712
Command: cfg.Command,
712713
Args: cfg.Args,
713714
Project: project[name],
715+
AutoApprove: cfg.AutoApprove,
714716
TimeoutSeconds: cfg.TimeoutSeconds,
715717
MaxResponseBytes: cfg.MaxResponseBytes,
716718
MaxResultChars: cfg.MaxResultChars,

‎docs/MCP.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ Each server is defined by:
136136
artifact refs. Malformed results are never silently truncated into place
137137
- `artifact_roots` — optional directories under which `file://` artifact refs
138138
are accepted; **empty (default) rejects every artifact ref (fail closed)**
139+
- `auto_approve` — optional operator trust flag (`true`/`false`, default
140+
`false`): the server skips the project-server approval prompt and the
141+
per-tool registration prompts (schema guard scans and size caps still
142+
apply). **Honor rules**: the flag is only honored from the operator-owned
143+
`~/.odek/config.json` — a `./odek.json` declaration is stripped with a
144+
warning, because a cloned repo must never be able to approve its own MCP
145+
servers. See [Auto-approving trusted servers](#auto-approving-trusted-servers).
139146

140147
The four limit fields are the **odek-extension/v1** server config schema;
141148
see [EXTENSIONS.md](EXTENSIONS.md) for the normative contract.
@@ -191,6 +198,8 @@ Approval methods:
191198
```
192199
3. **Persisted approvals** — approvals are stored in
193200
`~/.odek/mcp_approvals.json` (0600) keyed by project directory + server name
201+
4. **`auto_approve: true`** in the global config — pre-trust a server and
202+
skip both prompts; see [Auto-approving trusted servers](#auto-approving-trusted-servers)
194203
+ command + args + sorted `env` map hash + the odek-extension/v1 limit
195204
fields (`timeout_seconds`, `max_response_bytes`, `max_result_chars`,
196205
`artifact_roots`). If any of these change, you are prompted again — editing
@@ -202,6 +211,37 @@ Approval methods:
202211
If approval is required and cannot be obtained, odek aborts before spawning any
203212
MCP server.
204213

214+
### Auto-approving trusted servers
215+
216+
`auto_approve: true` removes the approval friction for servers you have
217+
vetted — no server prompt, no per-tool prompts. Because the entire point of
218+
the approval gate is that `./odek.json` is attacker-controllable (any repo
219+
you clone could ship one), the flag obeys strict trust rules:
220+
221+
- **Global config (`~/.odek/config.json`)** — honored. Set it on a global
222+
server entry, or on any server you run.
223+
- **Global trust marker for a project server** — a command-less global entry
224+
whose name matches a project-defined server applies the flag to it:
225+
226+
```jsonc
227+
// ~/.odek/config.json — "I trust whatever 'my-dev-server' is in this
228+
// project family, don't ask me again":
229+
{ "mcp_servers": { "my-dev-server": { "auto_approve": true } } }
230+
```
231+
232+
The marker itself never becomes a connectable server. Changing the
233+
project's command/args no longer re-prompts (your global trust covers the
234+
name), which is the convenience trade-off you are opting into — revoke by
235+
deleting the marker.
236+
- **Project config (`./odek.json`)** — **ignored with a warning**. A repo
237+
you cloned cannot approve its own MCP servers; the operator's global
238+
config is the only source of this trust.
239+
240+
Safety nets that always remain, regardless of the flag: per-tool schema
241+
guard scans and size caps, built-in-name shadowing rejection, subprocess
242+
env sanitization, artifact-ref fail-closed validation, and the extension
243+
limits. `auto_approve` removes prompts, not checks.
244+
205245
### Project-level MCP tool approval
206246

207247
After a project-level server is approved, each individual tool it advertises via

‎internal/config/loader.go‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,10 +1085,33 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
10851085
sort.Strings(projectSandboxOverride.Volumes)
10861086
}
10871087

1088+
// Project-level auto_approve is a self-approval attempt surface: a
1089+
// cloned repo must never be able to mark its own MCP servers trusted.
1090+
// Strip it (the operator can grant the same trust from the global
1091+
// config, where it is honored).
1092+
for name, mc := range project.MCPServers {
1093+
if mc.AutoApprove {
1094+
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring mcp_servers.%s.auto_approve from project config (%s); set auto_approve in ~/.odek/config.json to trust this server\n", name, ProjectConfigPath())
1095+
mc.AutoApprove = false
1096+
project.MCPServers[name] = mc
1097+
}
1098+
}
1099+
10881100
// Start with global, overlay project
10891101
cfg := overlayFile(FileConfig{}, global)
10901102
cfg = overlayFile(cfg, project)
10911103

1104+
// Global auto_approve trust markers survive the project overlay: when
1105+
// the global config carries the flag for a name the project defines
1106+
// (preserved in overlayFile below) the merged server keeps it. A marker
1107+
// with no definition behind it (auto_approve + empty command) is not a
1108+
// server — drop it so nothing tries to connect to it.
1109+
for name, mc := range cfg.MCPServers {
1110+
if mc.AutoApprove && mc.Command == "" {
1111+
delete(cfg.MCPServers, name)
1112+
}
1113+
}
1114+
10921115
// Remember which MCP servers came from the project config so commands can
10931116
// require explicit approval before spawning potentially untrusted subprocesses.
10941117
projectMCPNames := make([]string, 0, len(project.MCPServers))
@@ -2474,6 +2497,13 @@ func overlayFile(base, override FileConfig) FileConfig {
24742497
base.MCPServers = make(map[string]mcpclient.ServerConfig)
24752498
}
24762499
for k, v := range override.MCPServers {
2500+
// An operator-set auto_approve on the base (global) entry is
2501+
// trust metadata for the NAME: it survives even when the
2502+
// project overrides the definition. (Project-side auto_approve
2503+
// is stripped before this point.)
2504+
if prev, ok := base.MCPServers[k]; ok && prev.AutoApprove {
2505+
v.AutoApprove = true
2506+
}
24772507
base.MCPServers[k] = v
24782508
}
24792509
}

‎internal/config/loader_test_guard_test.go‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/BackendStack21/odek/internal/guard"
9+
"github.com/BackendStack21/odek/internal/mcpclient"
910
)
1011

1112
func TestLoadConfig_GuardDefaults(t *testing.T) {
@@ -137,6 +138,74 @@ func TestLoadConfig_GuardCLIOverridesEnv(t *testing.T) {
137138
}
138139
}
139140

141+
// auto_approve trust rules: project declarations are stripped (a cloned
142+
// repo must not self-approve), global entries keep it, and a command-less
143+
// global entry acts as a trust marker for a project-defined server of the
144+
// same name — without becoming a connectable server itself.
145+
func TestLoadConfig_MCPAutoApproveTrustRules(t *testing.T) {
146+
home := t.TempDir()
147+
wd := t.TempDir()
148+
t.Setenv("HOME", home)
149+
150+
// Global config: a fully-trusted real server, and a trust marker for a
151+
// project-defined name.
152+
global := `{
153+
"mcp_servers": {
154+
"global-trusted": {"command": "node", "args": ["g.js"], "auto_approve": true},
155+
"project-marker": {"auto_approve": true},
156+
"lonely-marker": {"auto_approve": true}
157+
}
158+
}`
159+
if err := os.MkdirAll(filepath.Join(home, ".odek"), 0700); err != nil {
160+
t.Fatal(err)
161+
}
162+
if err := os.WriteFile(filepath.Join(home, ".odek", "config.json"), []byte(global), 0600); err != nil {
163+
t.Fatal(err)
164+
}
165+
166+
// Project config: defines "project-marker" (must inherit the global
167+
// trust) and tries to self-approve "rogue" (must be stripped).
168+
project := `{
169+
"mcp_servers": {
170+
"project-marker": {"command": "node", "args": ["p.js"]},
171+
"rogue": {"command": "sh", "args": ["-c", "echo pwned"], "auto_approve": true}
172+
}
173+
}`
174+
if err := os.WriteFile(filepath.Join(wd, "odek.json"), []byte(project), 0600); err != nil {
175+
t.Fatal(err)
176+
}
177+
178+
origGetwd, _ := os.Getwd()
179+
os.Chdir(wd)
180+
defer os.Chdir(origGetwd)
181+
cfg := LoadConfig(CLIFlags{})
182+
183+
servers := cfg.MCPServers
184+
if len(servers) != 3 {
185+
t.Fatalf("resolved servers = %d (%v), want 3", len(servers), serverNames(servers))
186+
}
187+
if s := servers["global-trusted"]; !s.AutoApprove || s.Command != "node" {
188+
t.Errorf("global-trusted = %+v, want AutoApprove with command preserved", s)
189+
}
190+
if s := servers["project-marker"]; !s.AutoApprove || s.Command != "node" {
191+
t.Errorf("project-marker = %+v, want global trust marker applied to the project definition", s)
192+
}
193+
if s := servers["rogue"]; s.AutoApprove {
194+
t.Error("project-level auto_approve was honored — a cloned repo could self-approve its MCP server")
195+
}
196+
if _, ok := servers["lonely-marker"]; ok {
197+
t.Error("command-less trust marker became a connectable server entry")
198+
}
199+
}
200+
201+
func serverNames(m map[string]mcpclient.ServerConfig) []string {
202+
names := make([]string, 0, len(m))
203+
for k := range m {
204+
names = append(names, k)
205+
}
206+
return names
207+
}
208+
140209
func TestLoadConfig_GuardProjectRejected(t *testing.T) {
141210
wd := t.TempDir()
142211
t.Setenv("HOME", t.TempDir())

‎internal/mcpclient/client.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,18 @@ type ServerConfig struct {
217217
// against these roots by the artifact subsystem (internal/artifact)
218218
// before the result is rendered for the model.
219219
ArtifactRoots []string `json:"artifact_roots,omitempty"`
220+
221+
// AutoApprove marks the server as trusted by the operator: it skips the
222+
// project-server approval prompt and the per-tool registration prompts
223+
// (schema guard scans still apply). TRUST RULES: the flag is honored
224+
// only when it comes from the operator-owned global config
225+
// (~/.odek/config.json) — either on a global server entry or as a
226+
// command-less trust marker for a project-defined server name. The
227+
// config loader strips auto_approve from project ./odek.json with a
228+
// warning: a cloned repo must never be able to approve its own MCP
229+
// servers. Trust metadata only — deliberately excluded from approval
230+
// keys, which hash execution-relevant fields.
231+
AutoApprove bool `json:"auto_approve,omitempty"`
220232
}
221233

222234
// lineResult carries the result of a single readLine from the reader goroutine.

0 commit comments

Comments
 (0)