From 5e86e9430af400805472dbc9af5e5bfc78f82391 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 07:39:18 +0300 Subject: [PATCH 01/23] =?UTF-8?q?test(scope):=20Spec=20105=20PR=20H0=20red?= =?UTF-8?q?=20phase=20=E2=80=94=20FR-012=20stored-script=20enumeration=20i?= =?UTF-8?q?s=20admin-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing tests for gaps FR01x-G1 and FR01x-G2 (tasks T062/T063), plus the spec.md:116 positive controls as regression pins: - TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing (G1, RED): an agent token ["*"] requesting a missing stored script with alpha-SENTINEL.js present gets an error naming neither the sentinel, the other script nor "Available scripts (N)", byte-equal to the same caller's refusal against the empty directory; proven at the handler and through the /mcp and /mcp/code JSON-RPC seams. The administrator control keeps the enumeration (TestCodeExecution_ScriptNotFoundListsAvailable stays as the nil-auth admin control). - TestCodeExecutionDescriptions_EnumerationIsAdminOnly (G2, RED): the live code_execution.description / script.description no longer advertise discovery-by-failed-call and name the administrator-only rule; the three goldens differ from the new frozen testdata/toolslist_goldens/pre105/ baseline in exactly those two strings of the code_execution entry and in nothing else (tool set and every other entry byte-equal). - TestCodeExecution_StoredScript_ScopedPositiveControls (pin, green): an a-only token runs a constant-returning stored script and gets the constant; a stored script calling b is refused at the nested call with ACCESS_DENIED. - TestScopedInitialize_PublishesCustomInstructions (pin, green): a scoped initialize on /mcp and the direct server publishes custom instructions mentioning b:private_search verbatim, identical for admin — documented operator-published content. pre105/ is a byte copy of the current goldens (not a regeneration); .gitattributes pins LF for the frozen sub-directories. Co-Authored-By: Claude Opus 5 --- .gitattributes | 3 + internal/server/mcp_code_scripts_test.go | 156 ++++- .../server/mcp_instructions_scope_test.go | 121 ++++ .../pre105/code_execution_mode.json | 324 +++++++++++ .../pre105/default_server.json | 544 ++++++++++++++++++ .../pre105/retrieve_tools_mode.json | 540 +++++++++++++++++ internal/server/toolslist_snapshot_test.go | 133 +++++ 7 files changed, 1819 insertions(+), 2 deletions(-) create mode 100644 internal/server/mcp_instructions_scope_test.go create mode 100644 internal/server/testdata/toolslist_goldens/pre105/code_execution_mode.json create mode 100644 internal/server/testdata/toolslist_goldens/pre105/default_server.json create mode 100644 internal/server/testdata/toolslist_goldens/pre105/retrieve_tools_mode.json diff --git a/.gitattributes b/.gitattributes index aa9ee1954..b7a8890aa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,6 +24,9 @@ internal/server/testdata/**/*.golden.json text eol=lf # Without this Windows checks out CRLF and all three # TestToolsListSnapshot_MatchesMergeBaseGoldens surfaces fail on \r alone. internal/server/testdata/toolslist_goldens/*.json text eol=lf +# The frozen baselines (pre099/, pre105/) live one level down, where a single +# `*` does not reach. +internal/server/testdata/toolslist_goldens/**/*.json text eol=lf # Self-contained verification/QA reports embed base64 PNG screenshots, so a # single file is multiple MB of "HTML". They are point-in-time artifacts, not diff --git a/internal/server/mcp_code_scripts_test.go b/internal/server/mcp_code_scripts_test.go index 5a957a3ea..69a92918f 100644 --- a/internal/server/mcp_code_scripts_test.go +++ b/internal/server/mcp_code_scripts_test.go @@ -14,10 +14,12 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/cache" "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/index" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/profile" "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" @@ -294,8 +296,10 @@ func TestCodeExecution_ScriptLanguageContradiction(t *testing.T) { assert.False(t, ok.IsError, "an agreeing language must not be rejected: %s", resultText(t, ok)) } -// TestCodeExecution_ScriptNotFoundListsAvailable pins FR-004: the not-found -// error IS the MCP discovery mechanism. +// TestCodeExecution_ScriptNotFoundListsAvailable pins Spec 097 FR-004: the +// not-found error IS the MCP discovery mechanism — for the in-process caller +// with no auth context (an administrator). Kept as the Spec 105 FR-012 ADMIN +// CONTROL; the agent-token cell is TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing. func TestCodeExecution_ScriptNotFoundListsAvailable(t *testing.T) { proxy, scriptsDir := newStoredScriptProxy(t) writeStoredScript(t, scriptsDir, "alpha.js", "1") @@ -315,6 +319,154 @@ func TestCodeExecution_ScriptNotFoundListsAvailable(t *testing.T) { }) } +// callCodeExecutionAs is callCodeExecution under an explicit caller context. +func callCodeExecutionAs(t *testing.T, ctx context.Context, proxy *MCPProxyServer, args map[string]interface{}) *mcp.CallToolResult { + t.Helper() + request := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "code_execution", Arguments: args}} + result, err := proxy.handleCodeExecution(ctx, request) + require.NoError(t, err) + require.NotNil(t, result) + return result +} + +// callCodeExecutionOnWire drives a routing-mode server through the JSON-RPC +// seam (initialize, then tools/call code_execution) under ctx and returns the +// decoded tools/call result object — the exact bytes an HTTP caller of that +// surface receives. +func callCodeExecutionOnWire(t *testing.T, ctx context.Context, srv interface { + HandleMessage(context.Context, json.RawMessage) mcp.JSONRPCMessage +}, args map[string]interface{}) (isError bool, text string) { + t.Helper() + require.NotNil(t, srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}`))) + rawArgs, err := json.Marshal(args) + require.NoError(t, err) + encoded, err := json.Marshal(srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"code_execution","arguments":`+string(rawArgs)+`}}`))) + require.NoError(t, err) + var envelope struct { + Error *json.RawMessage `json:"error"` + Result *struct { + IsError bool `json:"isError"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(encoded, &envelope)) + require.Nil(t, envelope.Error, "tools/call must answer with a result, not a JSON-RPC error: %s", encoded) + require.NotNil(t, envelope.Result) + require.NotEmpty(t, envelope.Result.Content) + return envelope.Result.IsError, envelope.Result.Content[0].Text +} + +// TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing (Spec 105 T062, +// FR01x-G1, spec.md:116): a missing-script request under an agent token is +// a NON-DISCLOSING refusal — it names neither the other stored scripts nor +// how many there are — and it is byte-equal to the refusal the same caller +// gets when the directory is empty, so a failed call is not an oracle for +// what is stored. The administrator keeps today's enumeration (SC-005). +// +// The unrestricted ["*"] token is the strongest cell: server scope plays no +// part, the caller KIND alone decides. +func TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing(t *testing.T) { + const sentinel = "SENTINEL" + scoped := agentCtx([]string{"*"}, []string{auth.PermRead, auth.PermWrite, auth.PermDestructive}, "") + + t.Run("agent token: no enumeration, byte-equal to the empty directory", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + + // Same proxy, same directory path, same caller: first with nothing + // stored, then with two scripts — the two refusals must not differ. + empty := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "gamma"}) + require.True(t, empty.IsError, "a missing script is an error for every caller") + emptyText := resultText(t, empty) + + writeStoredScript(t, scriptsDir, "alpha-"+sentinel+".js", "1") + writeStoredScript(t, scriptsDir, "beta.ts", "1") + + populated := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "gamma"}) + require.True(t, populated.IsError) + text := resultText(t, populated) + assert.Contains(t, text, "gamma", "the caller's own requested name may be echoed") + assert.NotContains(t, text, sentinel, "an agent-token caller must not learn other script names (FR-012)") + assert.NotContains(t, text, "beta", "an agent-token caller must not learn other script names (FR-012)") + assert.NotContains(t, text, "Available scripts", "an agent-token caller must not be handed an enumeration (FR-012)") + assert.NotContains(t, text, "(2)", "an agent-token caller must not learn the script count (FR-012)") + assert.Equal(t, emptyText, text, + "the agent-token refusal must be byte-equal whether the directory is empty or populated (no oracle)") + }) + + t.Run("administrator control: still enumerates", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "alpha-"+sentinel+".js", "1") + writeStoredScript(t, scriptsDir, "beta.ts", "1") + + result := callCodeExecutionAs(t, adminCtx(), proxy, map[string]interface{}{"script": "gamma"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "alpha-"+sentinel) + assert.Contains(t, text, "beta") + assert.Contains(t, text, "Available scripts (2)", "the administrator keeps the Spec 097 FR-004 enumeration (SC-005)") + }) + + t.Run("wire level: /mcp/code and /mcp carry the same non-disclosing refusal", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "alpha-"+sentinel+".js", "1") + writeStoredScript(t, scriptsDir, "beta.ts", "1") + require.NotNil(t, proxy.codeExecServer, "fixture: the /mcp/code server must exist") + + for label, srv := range map[string]interface { + HandleMessage(context.Context, json.RawMessage) mcp.JSONRPCMessage + }{"code-exec": proxy.codeExecServer, "default": proxy.server} { + label, srv := label, srv + t.Run(label, func(t *testing.T) { + isError, text := callCodeExecutionOnWire(t, scoped, srv, map[string]interface{}{"script": "gamma"}) + require.True(t, isError, "%s: a missing script is an error: %s", label, text) + assert.NotContains(t, text, sentinel, "%s: agent-token refusal leaks a script name (FR-012)", label) + assert.NotContains(t, text, "Available scripts", "%s: agent-token refusal leaks the enumeration (FR-012)", label) + + adminErr, adminText := callCodeExecutionOnWire(t, adminCtx(), srv, map[string]interface{}{"script": "gamma"}) + require.True(t, adminErr) + assert.Contains(t, adminText, sentinel, "%s: the administrator keeps the enumeration", label) + }) + } + }) +} + +// TestCodeExecution_StoredScript_ScopedPositiveControls pins the two +// documented, PUBLISHED behaviours of spec.md:116 that bound FR-012: stored +// scripts are operator-published content — a scoped token may run one and +// receive any constant it returns without an upstream call — while every +// upstream call the script makes stays scope-checked, so a nested call to a +// server outside the token's scope is refused at the nested call (FR-009). +// Both cells hold on the merge base and are kept as regression pins. +func TestCodeExecution_StoredScript_ScopedPositiveControls(t *testing.T) { + aOnly := agentCtx([]string{"a"}, []string{auth.PermRead}, "") + + t.Run("a-only token runs a constant-returning script and gets the constant", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "constant.js", `({published: "operator-constant"})`) + + result := callCodeExecutionAs(t, aOnly, proxy, map[string]interface{}{"script": "constant"}) + require.False(t, result.IsError, resultText(t, result)) + assert.Contains(t, resultText(t, result), `"published":"operator-constant"`, + "a constant a stored script returns is published content, visible to a scoped caller (spec.md:116)") + }) + + t.Run("a stored script calling b is refused at the nested call", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "reach-b.js", + `var r = call_tool('b', 'private_search', {q: 'x'}); ({ok: r.ok, code: r.ok ? null : r.error.code, message: r.ok ? null : r.error.message})`) + + result := callCodeExecutionAs(t, aOnly, proxy, map[string]interface{}{"script": "reach-b"}) + require.False(t, result.IsError, "the script itself runs; only its nested call is refused: %s", resultText(t, result)) + text := resultText(t, result) + assert.Contains(t, text, `"ok":false`) + assert.Contains(t, text, `"code":"`+string(jsruntime.ErrorCodeAccessDenied)+`"`, + "the nested call must be refused by the token's server scope, before any upstream lookup (FR-009): %s", text) + }) +} + // TestCodeExecution_RecordsCarryScriptAndSource pins FR-005 / research R6: // history keeps the executed SOURCE as code (Spec 024 parity) and additionally // names the script. diff --git a/internal/server/mcp_instructions_scope_test.go b/internal/server/mcp_instructions_scope_test.go new file mode 100644 index 000000000..464d700a6 --- /dev/null +++ b/internal/server/mcp_instructions_scope_test.go @@ -0,0 +1,121 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cache" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/index" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/truncate" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream" +) + +// Spec 105 FR-012 (PR H0, spec.md:116 "Operator-published content"): custom +// initialization `instructions` are operator-authored text published to EVERY +// caller by design, and sit OUTSIDE the semantic-disclosure guarantee. A +// scoped token therefore receives them verbatim — even when they mention a +// server it cannot reach — which is why the agent-token documentation warns +// operators not to put server names or secrets in them. This pins that +// documented behaviour so a later "scrub instructions per caller" change is +// a deliberate spec decision, not drift. It holds on the merge base. + +// newCustomInstructionsProxy builds a bare proxy whose config carries the +// operator's `instructions` at CONSTRUCTION time (mcp-go fixes +// WithInstructions on the server instance, so a post-construction edit would +// not reach initialize). +func newCustomInstructionsProxy(t *testing.T, instructions string) *MCPProxyServer { + t.Helper() + + tmpDir := t.TempDir() + logger := zap.NewNop() + + sm, err := storage.NewManager(tmpDir, logger.Sugar()) + require.NoError(t, err) + t.Cleanup(func() { sm.Close() }) + + idx, err := index.NewManager(tmpDir, logger) + require.NoError(t, err) + t.Cleanup(func() { idx.Close() }) + + cfg := config.DefaultConfig() + cfg.DataDir = tmpDir + cfg.Instructions = instructions + + um := upstream.NewManager(logger, cfg, nil, secret.NewResolver(), nil) + + cm, err := cache.NewManager(sm.GetDB(), logger) + require.NoError(t, err) + t.Cleanup(func() { cm.Close() }) + + tr := truncate.NewTruncator(0) + proxy := NewMCPProxyServer(sm, idx, um, cm, func() *truncate.Truncator { return tr }, logger, nil, false, cfg, nil) + t.Cleanup(func() { proxy.Close() }) + return proxy +} + +// initializeInstructions performs the JSON-RPC initialize handshake on srv +// under ctx and returns the `instructions` the caller is handed. +func initializeInstructions(t *testing.T, ctx context.Context, srv jsonRPCHandler) string { + t.Helper() + encoded, err := json.Marshal(srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}`))) + require.NoError(t, err) + var envelope struct { + Error *json.RawMessage `json:"error"` + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(encoded, &envelope)) + require.Nil(t, envelope.Error, "initialize must succeed: %s", encoded) + return envelope.Result.Instructions +} + +// TestScopedInitialize_PublishesCustomInstructions (T062 positive control): +// a scoped initialization publishes the operator's custom instructions +// verbatim, including a mention of `b:private_search` on a server the token +// cannot reach — published, documented content (spec.md:116). +func TestScopedInitialize_PublishesCustomInstructions(t *testing.T) { + const custom = "Team conventions: run b:private_search before answering; never paste raw output." + proxy := newCustomInstructionsProxy(t, custom) + require.NotNil(t, proxy.directServer, "fixture: the direct server must exist") + + aOnly := agentCtx([]string{"a"}, []string{auth.PermRead}, "") + require.False(t, auth.AuthContextFromContext(aOnly).CanAccessServer("b"), "precondition: b is outside the token's scope") + + // The two surfaces that carry instructions today: the default /mcp server + // (resolveInstructions) and the direct server (resolveDirectInstructions, + // which appends its deferral legend to the operator's text). + for label, srv := range map[string]jsonRPCHandler{ + "default": proxy.server, + "direct": proxy.directServer, + } { + label, srv := label, srv + t.Run(label, func(t *testing.T) { + scoped := initializeInstructions(t, aOnly, srv) + assert.Contains(t, scoped, custom, + "%s: a scoped initialization must publish the operator's custom instructions verbatim (spec.md:116)", label) + assert.Contains(t, scoped, "b:private_search", + "%s: the mention of an out-of-scope server in operator-authored instructions is published by design — the docs warn operators, the proxy does not scrub", label) + + admin := initializeInstructions(t, adminCtx(), srv) + assert.Equal(t, admin, scoped, + "%s: instructions are the same text for every caller kind (SC-005)", label) + }) + } +} + +// jsonRPCHandler is the seam every routing-mode server exposes: the raw +// JSON-RPC message handler an HTTP transport feeds. +type jsonRPCHandler interface { + HandleMessage(context.Context, json.RawMessage) mcp.JSONRPCMessage +} diff --git a/internal/server/testdata/toolslist_goldens/pre105/code_execution_mode.json b/internal/server/testdata/toolslist_goldens/pre105/code_execution_mode.json new file mode 100644 index 000000000..a3d175784 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre105/code_execution_mode.json @@ -0,0 +1,324 @@ +{ + "code_execution": { + "annotations": { + "title": "Code Execution", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", + "inputSchema": { + "properties": { + "code": { + "description": "JavaScript or TypeScript source code (ES2020+) to execute. Supports modern syntax: arrow functions, const/let, template literals, destructuring, optional chaining, nullish coalescing. Use `input` to access input data, `call_tool(serverName, toolName, args)` to invoke one upstream tool and `call_tools([{server, tool, args}, ...], {max_parallel})` to invoke independent tools in parallel. Both are SYNCHRONOUS — do not use await. Return value is the last evaluated expression and must be JSON-serializable. Example: `const res = call_tool('github', 'get_user', {username: input.username}); const data = JSON.parse(res.result.content[0].text); ({user: data, timestamp: Date.now()})`", + "type": "string" + }, + "input": { + "description": "Input data accessible as global `input` variable in code (default: {})", + "properties": {}, + "type": "object" + }, + "language": { + "description": "Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution. Type annotations are stripped, enums and namespaces are converted to JavaScript equivalents. Default: 'javascript'.", + "enum": [ + "javascript", + "typescript" + ], + "type": "string" + }, + "options": { + "description": "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (\u003e= 0, 0=unlimited), allowed_servers (array of server names, empty=all allowed). Batch concurrency is not an execution option: call_tools() defaults to the configured code_execution_max_parallel and is overridden per batch with call_tools(requests, {max_parallel}).", + "properties": {}, + "type": "object" + }, + "script": { + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "code_execution" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management AND TPA scanning for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs), and scan a server for them: 'scan_server' runs the always-on offline baseline scan (in-process, no Docker required) and 'get_scan_report' returns the latest verdict and findings. Every listing/inspection response also carries a one-line scan status, so an unscanned server is visible as unscanned. Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts, scan_server, get_scan_report)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts, scan_server, get_scan_report. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved. 'scan_server' starts the offline TPA baseline scan for one server (no Docker needed) and returns the verdict once it settles, or a job id to poll; 'get_scan_report' returns that server's latest verdict, counts and findings.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts", + "scan_server", + "get_scan_report" + ], + "type": "string" + }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Search and discover available upstream tools using BM25 full-text search. Use this to find tools, then use the `code_execution` tool to call them via `call_tool(serverName, toolName, args)` in JavaScript. Do NOT use call_tool_read/write/destructive — they are not available in this mode. Use natural language to describe what you want to accomplish. Response includes a structured `session_risk` object (level, lethal_trifecta, has_open_world_tools, has_destructive_tools, has_write_tools). ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish.", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)\n\nREDACTION (update/patch): the returned 'changes' diff keeps every field PATH exact, but MASKS values under secret-bearing keys (env vars, headers, oauth secrets, credential-shaped argv tokens) in both this response and the activity log. Non-secret values round-trip unchanged; do not read a masked value back as what was stored.", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/testdata/toolslist_goldens/pre105/default_server.json b/internal/server/testdata/toolslist_goldens/pre105/default_server.json new file mode 100644 index 000000000..8e860dbf1 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre105/default_server.json @@ -0,0 +1,544 @@ +{ + "call_tool_destructive": { + "annotations": { + "title": "Call Tool (Destructive)", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:delete_repo'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_destructive" + }, + "call_tool_read": { + "annotations": { + "title": "Call Tool (Read)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this tool being called? Provide context like 'User asked to check status' or 'Gathering data for report'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:get_user'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_read" + }, + "call_tool_write": { + "annotations": { + "title": "Call Tool (Write)", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this modification needed? Provide context like 'User requested update' or 'Fixing reported issue'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:create_issue'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_write" + }, + "code_execution": { + "annotations": { + "title": "Code Execution", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", + "inputSchema": { + "properties": { + "code": { + "description": "JavaScript or TypeScript source code (ES2020+) to execute. Supports modern syntax: arrow functions, const/let, template literals, destructuring, optional chaining, nullish coalescing. Use `input` to access input data, `call_tool(serverName, toolName, args)` to invoke one upstream tool and `call_tools([{server, tool, args}, ...], {max_parallel})` to invoke independent tools in parallel. Both are SYNCHRONOUS — do not use await. Return value is the last evaluated expression and must be JSON-serializable. Example: `const res = call_tool('github', 'get_user', {username: input.username}); const data = JSON.parse(res.result.content[0].text); ({user: data, timestamp: Date.now()})`", + "type": "string" + }, + "input": { + "description": "Input data accessible as global `input` variable in code (default: {})", + "properties": {}, + "type": "object" + }, + "language": { + "description": "Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution. Type annotations are stripped, enums and namespaces are converted to JavaScript equivalents. Default: 'javascript'.", + "enum": [ + "javascript", + "typescript" + ], + "type": "string" + }, + "options": { + "description": "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (\u003e= 0, 0=unlimited), allowed_servers (array of server names, empty=all allowed). Batch concurrency is not an execution option: call_tools() defaults to the configured code_execution_max_parallel and is overridden per batch with call_tools(requests, {max_parallel}).", + "properties": {}, + "type": "object" + }, + "script": { + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "code_execution" + }, + "describe_tool": { + "annotations": { + "title": "Describe Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Return full JSON Schema + long description for listed tools. Use when a signature is marked lossy ('~') or you need the exact schema before calling. With check:true it returns one availability verdict per id, not schemas ('ready', or a reason code with retryable/action), to gate a plan before its first call.", + "inputSchema": { + "properties": { + "check": { + "description": "Check availability only, no schemas (default false).", + "type": "boolean" + }, + "filters": { + "description": "check:true only. Annotation filters.", + "properties": { + "exclude_destructive": { + "type": "boolean" + }, + "exclude_open_world": { + "type": "boolean" + }, + "read_only_only": { + "type": "boolean" + } + }, + "type": "object" + }, + "tool_ids": { + "description": "Tool ids as listed: '\u003cserver\u003e:\u003ctool\u003e' or '\u003cserver\u003e__\u003ctool\u003e'. Max 5, or 50 with check:true.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tool_ids" + ], + "type": "object" + }, + "name": "describe_tool" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management AND TPA scanning for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs), and scan a server for them: 'scan_server' runs the always-on offline baseline scan (in-process, no Docker required) and 'get_scan_report' returns the latest verdict and findings. Every listing/inspection response also carries a one-line scan status, so an unscanned server is visible as unscanned. Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts, scan_server, get_scan_report)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts, scan_server, get_scan_report. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved. 'scan_server' starts the offline TPA baseline scan for one server (no Docker needed) and returns the verdict once it settles, or a job id to poll; 'get_scan_report' returns that server's latest verdict, counts and findings.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts", + "scan_server", + "get_scan_report" + ], + "type": "string" + }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "read_cache": { + "annotations": { + "title": "Read Cache", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Retrieve paginated data when mcpproxy indicates a tool response was truncated. Use the cache key provided in truncation messages to access the complete dataset with pagination.", + "inputSchema": { + "properties": { + "key": { + "description": "Cache key provided by mcpproxy when a response was truncated (e.g. 'Use read_cache tool: key=\"abc123def...\"')", + "type": "string" + }, + "limit": { + "description": "Maximum number of records to return per page (default: 50, max: 1000)", + "type": "number" + }, + "offset": { + "description": "Starting record offset for pagination (default: 0)", + "type": "number" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "name": "read_cache" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "🔍 CALL THIS FIRST to discover relevant tools! This is the primary tool discovery mechanism that searches across ALL upstream MCP servers using intelligent BM25 full-text search. Always use this before attempting to call any specific tools. Use natural language to describe what you want to accomplish (e.g., 'create GitHub repository', 'query database', 'weather forecast'). Results include 'annotations' (tool behavior hints like destructiveHint) and 'call_with' recommendation indicating which tool variant to use (call_tool_read/write/destructive). Then use the recommended variant with an 'intent' parameter. Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. NOTE: Quarantined servers are excluded from search results for security. Use 'quarantine_security' tool to examine and manage quarantined servers. TO ADD NEW SERVERS: Use 'list_registries' then 'search_servers' to find and add new MCP servers. ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "debug": { + "description": "Enable debug mode with detailed scoring and ranking explanations (default: false)", + "type": "boolean" + }, + "detail": { + "description": "Per-call response serialization override: 'compact' returns one-line signatures (sig/desc/lossy) instead of full schemas; 'full' returns complete inputSchema entries. Unset: the server's configured tool_response_mode applies.", + "enum": [ + "compact", + "full" + ], + "type": "string" + }, + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "explain_tool": { + "description": "When debug=true, explain why a specific tool was ranked low (format: 'server:tool')", + "type": "string" + }, + "include_disabled": { + "description": "Set true to also surface tools that exist but are currently locked by config, user, or quarantine (default: false). Returns a 'disabled' list (name/server/description/status) plus a 'remediation' map; callable results are unaffected and listed first.", + "type": "boolean" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "include_stats": { + "description": "Include usage statistics for returned tools (default: false)", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish. Be specific about your task (e.g., 'create a new GitHub repository', 'get weather for London', 'query SQLite database for users'). The search will find the most relevant tools across all connected servers.", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)\n\nREDACTION (update/patch): the returned 'changes' diff keeps every field PATH exact, but MASKS values under secret-bearing keys (env vars, headers, oauth secrets, credential-shaped argv tokens) in both this response and the activity log. Non-secret values round-trip unchanged; do not read a masked value back as what was stored.", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/testdata/toolslist_goldens/pre105/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/pre105/retrieve_tools_mode.json new file mode 100644 index 000000000..a3911775e --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre105/retrieve_tools_mode.json @@ -0,0 +1,540 @@ +{ + "call_tool_destructive": { + "annotations": { + "title": "Call Tool (Destructive)", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:delete_repo'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_destructive" + }, + "call_tool_read": { + "annotations": { + "title": "Call Tool (Read)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this tool being called? Provide context like 'User asked to check status' or 'Gathering data for report'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:get_user'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_read" + }, + "call_tool_write": { + "annotations": { + "title": "Call Tool (Write)", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this modification needed? Provide context like 'User requested update' or 'Fixing reported issue'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:create_issue'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_write" + }, + "code_execution": { + "annotations": { + "title": "Code Execution", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", + "inputSchema": { + "properties": { + "code": { + "description": "JavaScript or TypeScript source code (ES2020+) to execute. Supports modern syntax: arrow functions, const/let, template literals, destructuring, optional chaining, nullish coalescing. Use `input` to access input data, `call_tool(serverName, toolName, args)` to invoke one upstream tool and `call_tools([{server, tool, args}, ...], {max_parallel})` to invoke independent tools in parallel. Both are SYNCHRONOUS — do not use await. Return value is the last evaluated expression and must be JSON-serializable. Example: `const res = call_tool('github', 'get_user', {username: input.username}); const data = JSON.parse(res.result.content[0].text); ({user: data, timestamp: Date.now()})`", + "type": "string" + }, + "input": { + "description": "Input data accessible as global `input` variable in code (default: {})", + "properties": {}, + "type": "object" + }, + "language": { + "description": "Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution. Type annotations are stripped, enums and namespaces are converted to JavaScript equivalents. Default: 'javascript'.", + "enum": [ + "javascript", + "typescript" + ], + "type": "string" + }, + "options": { + "description": "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (\u003e= 0, 0=unlimited), allowed_servers (array of server names, empty=all allowed). Batch concurrency is not an execution option: call_tools() defaults to the configured code_execution_max_parallel and is overridden per batch with call_tools(requests, {max_parallel}).", + "properties": {}, + "type": "object" + }, + "script": { + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "code_execution" + }, + "describe_tool": { + "annotations": { + "title": "Describe Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Return full JSON Schema + long description for listed tools. Use when a signature is marked lossy ('~') or you need the exact schema before calling. With check:true it returns one availability verdict per id, not schemas ('ready', or a reason code with retryable/action), to gate a plan before its first call.", + "inputSchema": { + "properties": { + "check": { + "description": "Check availability only, no schemas (default false).", + "type": "boolean" + }, + "filters": { + "description": "check:true only. Annotation filters.", + "properties": { + "exclude_destructive": { + "type": "boolean" + }, + "exclude_open_world": { + "type": "boolean" + }, + "read_only_only": { + "type": "boolean" + } + }, + "type": "object" + }, + "tool_ids": { + "description": "Tool ids as listed: '\u003cserver\u003e:\u003ctool\u003e' or '\u003cserver\u003e__\u003ctool\u003e'. Max 5, or 50 with check:true.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tool_ids" + ], + "type": "object" + }, + "name": "describe_tool" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management AND TPA scanning for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs), and scan a server for them: 'scan_server' runs the always-on offline baseline scan (in-process, no Docker required) and 'get_scan_report' returns the latest verdict and findings. Every listing/inspection response also carries a one-line scan status, so an unscanned server is visible as unscanned. Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts, scan_server, get_scan_report)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts, scan_server, get_scan_report. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved. 'scan_server' starts the offline TPA baseline scan for one server (no Docker needed) and returns the verdict once it settles, or a job id to poll; 'get_scan_report' returns that server's latest verdict, counts and findings.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts", + "scan_server", + "get_scan_report" + ], + "type": "string" + }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "read_cache": { + "annotations": { + "title": "Read Cache", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Retrieve paginated data when mcpproxy indicates a tool response was truncated. Use the cache key provided in truncation messages.", + "inputSchema": { + "properties": { + "key": { + "description": "Cache key provided by mcpproxy when a response was truncated.", + "type": "string" + }, + "limit": { + "description": "Maximum number of records to return per page (default: 50, max: 1000)", + "type": "number" + }, + "offset": { + "description": "Starting record offset for pagination (default: 0)", + "type": "number" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "name": "read_cache" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Search and discover available upstream tools using BM25 full-text search. WORKFLOW: 1) Call this tool first to find relevant tools, 2) Check the 'call_with' field in results to determine which variant to use, 3) Call the tool using call_tool_read, call_tool_write, or call_tool_destructive. Results include 'annotations' (tool behavior hints like destructiveHint), 'call_with' recommendation, and a structured `session_risk` object (level, lethal_trifecta, has_open_world_tools, has_destructive_tools, has_write_tools). Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. Use natural language to describe what you want to accomplish. ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "debug": { + "description": "Enable debug mode with detailed scoring and ranking explanations (default: false)", + "type": "boolean" + }, + "detail": { + "description": "Per-call response serialization override: 'compact' returns one-line signatures (sig/desc/lossy) instead of full schemas; 'full' returns complete inputSchema entries. Unset: the server's configured tool_response_mode applies.", + "enum": [ + "compact", + "full" + ], + "type": "string" + }, + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "explain_tool": { + "description": "When debug=true, explain why a specific tool was ranked low (format: 'server:tool')", + "type": "string" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "include_stats": { + "description": "Include usage statistics for returned tools (default: false)", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish. Be specific (e.g., 'create a new GitHub repository', 'get weather for London').", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)\n\nREDACTION (update/patch): the returned 'changes' diff keeps every field PATH exact, but MASKS values under secret-bearing keys (env vars, headers, oauth secrets, credential-shaped argv tokens) in both this response and the activity log. Non-secret values round-trip unchanged; do not read a masked value back as what was stored.", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/toolslist_snapshot_test.go b/internal/server/toolslist_snapshot_test.go index e4266dbe1..eb8c84d92 100644 --- a/internal/server/toolslist_snapshot_test.go +++ b/internal/server/toolslist_snapshot_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -321,3 +322,135 @@ func reportToolsListDiff(t *testing.T, surface string, want, got []byte) { "surface %s: tool %q schema changed (FR-015)", surface, name) } } + +// --------------------------------------------------------------------------- +// Spec 105 FR-012 (PR H0, FR01x-G2): the ONE narrow golden exception. +// +// The code_execution definition told every caller to discover stored scripts +// by requesting a name that does not exist. Under FR-012 that enumeration is +// administrator-only (an agent-token caller gets a non-disclosing refusal, +// see mcp_code_scripts_test.go), so the published text has to say so — and +// the goldens that pin the text move by exactly those two strings and +// nothing else. testdata/toolslist_goldens/pre105/ is the FROZEN copy of the +// three goldens as they stood before this spec (never regenerated); the live +// goldens are compared against it entry by entry and field by field. +// --------------------------------------------------------------------------- + +const ( + // toolsListPre105Dir holds the frozen pre-Spec-105 capture of the three + // surfaces, the baseline the FR-012 narrow-diff assertion measures against. + toolsListPre105Dir = "pre105" + + // spec105CodeExecutionTool is the only entry allowed to differ from the + // pre-105 baseline, and only in the two description strings below. + spec105CodeExecutionTool = "code_execution" +) + +// spec105EnumerationPhrases are the pre-105 fragments that advertised +// discovery-by-failed-call. Neither may survive in the live strings. +var spec105EnumerationPhrases = []string{ + "returns the available names, which is how you discover what is stored", + "DISCOVERY: calling with a name that does not exist returns an error listing the available script names", + "so the current set can always be recovered from a single failed call", +} + +// TestCodeExecutionDescriptions_EnumerationIsAdminOnly (T063) pins the +// reworded definition text and the narrow golden delta together: the live +// strings no longer teach enumeration by failed call and name the +// administrator-only rule, and the regenerated goldens differ from the frozen +// pre-105 capture in code_execution.description and +// code_execution.inputSchema.properties.script.description ONLY. +func TestCodeExecutionDescriptions_EnumerationIsAdminOnly(t *testing.T) { + t.Run("live strings", func(t *testing.T) { + for _, phrase := range spec105EnumerationPhrases { + assert.NotContains(t, codeExecutionToolDescription, phrase, + "code_execution.description must not advertise discovery by failed call (FR-012)") + assert.NotContains(t, codeExecutionScriptDescription, phrase, + "script.description must not advertise discovery by failed call (FR-012)") + } + for _, text := range []string{codeExecutionToolDescription, codeExecutionScriptDescription} { + lower := strings.ToLower(text) + assert.Contains(t, lower, "administrator", + "the definition must say enumeration is administrator-only (FR-012)") + assert.Contains(t, lower, "agent", + "the definition must tell agent-token callers they need to already know the name (FR-012)") + } + }) + + for _, surface := range toolsListGoldenSurfaces { + surface := surface + t.Run(surface, func(t *testing.T) { + before := decodeToolsListGolden(t, filepath.Join("testdata", toolsListGoldenDir, toolsListPre105Dir, surface+".json")) + after := decodeToolsListGolden(t, toolsListGoldenPath(surface)) + + // The tool SET is untouched: nothing added, nothing removed. + assert.Equal(t, sortedToolNames(before), sortedToolNames(after), + "surface %s: the FR-012 exception changes two strings, never the tool set", surface) + + // Every other entry is byte-equal to the frozen capture. + for name, pre := range before { + if name == spec105CodeExecutionTool { + continue + } + assert.True(t, bytes.Equal(pre, after[name]), + "surface %s: tool %q must be byte-identical to the pre-105 golden (FR-012: only code_execution may move)", surface, name) + } + + preTool, ok := before[spec105CodeExecutionTool] + require.True(t, ok, "surface %s: frozen baseline carries code_execution", surface) + postTool, ok := after[spec105CodeExecutionTool] + require.True(t, ok, "surface %s: live golden carries code_execution", surface) + + var preM, postM map[string]interface{} + require.NoError(t, json.Unmarshal(preTool, &preM)) + require.NoError(t, json.Unmarshal(postTool, &postM)) + + preDesc, _ := preM["description"].(string) + postDesc, _ := postM["description"].(string) + preScript := codeExecScriptDescriptionOf(t, preM) + postScript := codeExecScriptDescriptionOf(t, postM) + + // Both strings MOVED (a regenerated golden that still carries the + // pre-105 wording is the description lying about the runtime), and + // the live golden carries exactly the live constants. + assert.NotEqual(t, preDesc, postDesc, + "surface %s: code_execution.description must be regenerated with the FR-012 wording", surface) + assert.NotEqual(t, preScript, postScript, + "surface %s: script.description must be regenerated with the FR-012 wording", surface) + assert.Equal(t, codeExecutionToolDescription, postDesc, "surface %s: golden description == live constant", surface) + assert.Equal(t, codeExecutionScriptDescription, postScript, "surface %s: golden script.description == live constant", surface) + for _, phrase := range spec105EnumerationPhrases { + assert.NotContains(t, postDesc, phrase, "surface %s: regenerated golden still advertises enumeration", surface) + assert.NotContains(t, postScript, phrase, "surface %s: regenerated golden still advertises enumeration", surface) + } + + // And NOTHING else moved: put the two pre-105 strings back into the + // live entry and it must deep-equal the frozen one. + postM["description"] = preDesc + setCodeExecScriptDescription(t, postM, preScript) + assert.Equal(t, preM, postM, + "surface %s: code_execution may differ from the pre-105 golden in description and script.description only (FR-012)", surface) + }) + } +} + +// codeExecScriptDescriptionOf reads inputSchema.properties.script.description +// from a decoded tool entry. +func codeExecScriptDescriptionOf(t *testing.T, tool map[string]interface{}) string { + t.Helper() + schema, _ := tool["inputSchema"].(map[string]interface{}) + props, _ := schema["properties"].(map[string]interface{}) + script, _ := props["script"].(map[string]interface{}) + require.NotNil(t, script, "code_execution must expose the `script` parameter") + desc, _ := script["description"].(string) + return desc +} + +func setCodeExecScriptDescription(t *testing.T, tool map[string]interface{}, desc string) { + t.Helper() + schema, _ := tool["inputSchema"].(map[string]interface{}) + props, _ := schema["properties"].(map[string]interface{}) + script, _ := props["script"].(map[string]interface{}) + require.NotNil(t, script) + script["description"] = desc +} From 3cd939f3034055ed02c2ba115a0e8b2b5523cff9 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 07:53:23 +0300 Subject: [PATCH 02/23] =?UTF-8?q?feat(scope):=20Spec=20105=20PR=20H0=20?= =?UTF-8?q?=E2=80=94=20stored-script=20enumeration=20is=20administrator-on?= =?UTF-8?q?ly=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green phase for tasks T064-T067 (gaps FR01x-G1..G3), closing the red tests committed in bcf691b7a. - Caller-kind branch (T064): resolveCodeExecutionSource passes a stored-script resolution failure through scopeStoredScriptRefusal. For a scoped caller (auth.IsScopedCaller — the caller KIND decides, never AllowedServers) the Spec 097 FR-004 NotFoundError is swapped for its NonDisclosing() copy, whose text carries only the requested name: no other script names, no count, no directory path, and byte-equal for an empty and a populated directory. The typed identity survives, so the REST classifier still answers 404 SCRIPT_NOT_FOUND. An absent auth context (in-process caller) and administrators keep today's "Available scripts (N): ..." enumeration (SC-005 named exception; TestCodeExecution_ScriptNotFoundListsAvailable kept as the admin control). - Definition text (T065): code_execution.description and the script parameter description no longer advertise discovery-by-failed-call and say enumeration is administrator-only / agent-token callers must already know the name. The three tools-list goldens were regenerated with MCPPROXY_WRITE_TOOLSLIST_GOLDENS=testdata/toolslist_goldens (the ONE narrow golden exception of Spec 105); TestCodeExecutionDescriptions_ EnumerationIsAdminOnly proves the delta against the frozen pre105/ copy is limited to exactly those two strings of the code_execution entry. - Docs (T066): docs/code_execution/{overview,cookbook,troubleshooting, api-reference}.md state the administrator-only rule and show the agent-token refusal; docs/features/agent-tokens.md gains the "What a scoped token cannot learn" section (invariant sentence, covered-surface list, retained-effects list, custom-instructions/stored-scripts secrets warning); docs/configuration.md warns that `instructions` is published to every caller. Links use docs.mcpproxy.app URLs. - codescripts: TestNotFoundError_NonDisclosing pins the stripped form. - tasks.md T062-T067 ticked; ROADMAP.md regenerated by scripts/gen-roadmap.py. Verification: go test -race -count=1 (with the internal/server skip list) ./internal/server/... ./internal/codescripts/... ./internal/httpapi/... green; go build ./cmd/mcpproxy and -tags server clean; gofmt/goimports/vet clean on touched files; git diff --stat origin/main -- internal/server/testdata shows only the three regenerated goldens plus the frozen pre105/ baseline. Co-Authored-By: Claude Opus 5 --- ROADMAP.md | 4 +- docs/code_execution/api-reference.md | 7 +- docs/code_execution/cookbook.md | 14 ++-- docs/code_execution/overview.md | 30 +++++++-- docs/code_execution/troubleshooting.md | 17 +++-- docs/configuration.md | 2 + docs/features/agent-tokens.md | 66 +++++++++++++++++++ internal/codescripts/codescripts.go | 26 ++++++++ internal/codescripts/codescripts_test.go | 43 ++++++++++++ internal/server/mcp_code_execution.go | 36 ++++++++-- .../code_execution_mode.json | 4 +- .../toolslist_goldens/default_server.json | 4 +- .../retrieve_tools_mode.json | 4 +- specs/105-agent-scope-hardening/tasks.md | 12 ++-- 14 files changed, 236 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91b2833c4..0a40757bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -862,7 +862,7 @@ graph LR | Web UI + macOS app UX audit | In progress | P0 | — | | | | Release qualification gate (auto-QA matrix blocks the tag) | In progress | P0 | — | [081-release-qa-gate](./specs/081-release-qa-gate/) | | | Action log / transparency — info at a glance | In progress | P1 | — | | | -| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 0/109 (0%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | | +| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 6/109 (6%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | | | Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | | | Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | | | Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | | @@ -1008,5 +1008,5 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) | | [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) | | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — | -| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `drafted` | 0/109 (0%) | +| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 6/109 (6%) | | [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) | diff --git a/docs/code_execution/api-reference.md b/docs/code_execution/api-reference.md index da4f9b7b3..909599d62 100644 --- a/docs/code_execution/api-reference.md +++ b/docs/code_execution/api-reference.md @@ -588,8 +588,9 @@ executed source under `code` and additionally carry `script: ""`. | Situation | Message (abbreviated) | |-----------|-----------------------| | Both or neither of `code` / `script` | `Provide exactly one of 'code' (inline source) or 'script' (the name of a script stored in the 'scripts' directory next to mcpproxy's config file) — not both, not neither.` | -| Unknown name | `stored script "X" not found in . Available scripts (N): a, b, c …` | -| No scripts at all | `stored script "X" not found: no stored scripts in (create X.js or X.ts there)` | +| Unknown name (administrator) | `stored script "X" not found in . Available scripts (N): a, b, c …` | +| No scripts at all (administrator) | `stored script "X" not found: no stored scripts in (create X.js or X.ts there)` | +| Unknown name ([agent token](https://docs.mcpproxy.app/features/agent-tokens/), any scope) | `stored script "X" not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name)` — identical for an empty and a populated directory; the listing is administrator-only | | Invalid name | `invalid script name "…": character "/" is not allowed …` | | Both extensions present | `stored script "X" is ambiguous: /X.js and /X.ts both exist — remove one` | | Empty / oversized / unreadable / non-regular | `stored script "X" () is oversized: scripts are limited to 262144 bytes` | @@ -633,7 +634,7 @@ never re-sends a request that cannot succeed: | Situation | Status | `error.code` | |-----------|--------|--------------| | `enable_code_execution` is `false` | 403 | `FEATURE_DISABLED` | -| Unknown script name (carries the available names) | 404 | `SCRIPT_NOT_FOUND` | +| Unknown script name (carries the available names for an administrator; an agent token gets the non-disclosing message) | 404 | `SCRIPT_NOT_FOUND` | | Invalid script name | 400 | `INVALID_SCRIPT_NAME` | | Ambiguous, empty, oversized, unreadable or non-regular | 400 | `SCRIPT_UNUSABLE` | | `language` contradicts the extension | 400 | `INVALID_LANGUAGE` | diff --git a/docs/code_execution/cookbook.md b/docs/code_execution/cookbook.md index 9cf4e97c5..9a8d2df54 100644 --- a/docs/code_execution/cookbook.md +++ b/docs/code_execution/cookbook.md @@ -138,10 +138,16 @@ Things to know when converting a recipe: - **Edit by atomic replace** (write a temp file, `mv` it over) and the next invocation runs the new content — no daemon restart, so the authoring loop is still "edit, rerun". -- **Discovery** is the not‑found error: naming a script that does not exist - returns the available names (first 20 alphabetically, plus the total), so an - agent never needs the list out of band. `mcpproxy code scripts list` shows the - full set, including `ambiguous` and `invalid` entries. +- **Discovery is administrator‑only**: for an administrator (admin API key, + tray, in‑process caller) naming a script that does not exist returns the + available names (first 20 alphabetically, plus the total). An + [agent token](https://docs.mcpproxy.app/features/agent-tokens/) must already + know the name — its not‑found error lists nothing, so hand the agent the + script names out of band (or in its custom instructions). `mcpproxy code + scripts list` shows the full set, including `ambiguous` and `invalid` entries. +- **Scripts are published content**: whoever can run one sees whatever it + returns without an upstream call; only its `call_tool()` calls are + scope‑checked. Keep server names and secrets out of script source. - **Read‑only surface**: nothing writes scripts for you — no tool, no endpoint, no CLI verb. Authoring is the filesystem, deliberately. diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index d736b276a..5dd268444 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -377,16 +377,38 @@ curl -H "X-API-Key: $KEY" http://127.0.0.1:8080/api/v1/code/scripts ``` MCP clients do not get a listing tool — registrations are static, so an embedded -list would go stale. Discovery is **error-driven** instead: invoking a name that -does not exist returns an error listing the first 20 available names -alphabetically plus the total, so an agent recovers the current name set from a -single failed call. +list would go stale. For **administrators** (the admin API key, the tray over the +local socket, or an in-process caller) discovery is **error-driven** instead: +invoking a name that does not exist returns an error listing the first 20 +available names alphabetically plus the total, so the current name set is +recovered from a single failed call. ```text Cannot execute stored script: stored script "fetch-pr" not found in /Users/me/.mcpproxy/scripts. Available scripts (3): daily-report, fetch-prs, triage ``` +**Enumeration is administrator-only.** An +[agent token](https://docs.mcpproxy.app/features/agent-tokens/) — whatever its +server scope, even `--servers "*"` — must already know the script name. Its +not-found error names neither the other stored scripts, nor how many there are, +nor the directory, and it is byte-for-byte the same whether the directory is +empty or full, so a failed call cannot be used to probe what is stored: + +```text +Cannot execute stored script: stored script "fetch-pr" not found (the stored-script +listing is available to administrators only; an agent-token caller must already +know the script name) +``` + +Stored scripts are operator-published content: any caller allowed to run +`code_execution` can run a script it knows the name of and receive whatever the +script returns without an upstream call, while every `call_tool()` the script +makes is still checked against the caller's server scope and permission tier. +Do not put server names, credentials or other secrets in a script's source or +its constant return values — see the +[agent-token invariant](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn). + ### No write path Nothing in mcpproxy creates, edits, or deletes a stored script: no MCP tool, no diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index ab748a0ce..5f6f6be20 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -613,15 +613,24 @@ Or, with an empty/absent directory: Cannot execute stored script: stored script "fetch-pr" not found: no stored scripts in /Users/me/.mcpproxy/scripts (create fetch-pr.js or fetch-pr.ts there) ``` +Or, when the caller is an [agent token](https://docs.mcpproxy.app/features/agent-tokens/) +rather than an administrator — the listing, the count and the directory are +withheld, and the message is the same whether the directory is empty or full: +``` +Cannot execute stored script: stored script "fetch-pr" not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name) +``` + **Cause**: No `.js` / `.ts` in the scripts directory. Usually a typo (names are **case-sensitive**), a file that is not a script (uppercase or other extension: `.JS`, `.mjs`, `.jsx` are ignored), or the wrong directory — the scripts directory follows the **active config file**, not `--data-dir`. -**Solution**: This error *is* the discovery mechanism — it lists the first 20 -available names alphabetically plus the total, so an MCP client can recover the -name set from the failed call. For the full picture, including where the daemon -looked: +**Solution**: For an administrator this error *is* the discovery mechanism — it +lists the first 20 available names alphabetically plus the total, so the name +set is recovered from the failed call. An agent token gets no listing: give the +agent the script names out of band (or in its custom instructions) and check +them against the administrator's view. For the full picture, including where +the daemon looked: ```bash mcpproxy code scripts list mcpproxy code scripts list --config /etc/mcpproxy/mcp_config.json # a non-default config diff --git a/docs/configuration.md b/docs/configuration.md index d9df0f076..cd59eb621 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1253,6 +1253,8 @@ You can edit this from the Web UI under **Settings → Advanced → MCP server i **Note:** Applied at startup / on the next client connect — editing this value does not hot-reload into already-connected MCP sessions. +**Warning:** the text is operator-published content, returned verbatim to **every** client that initializes — including [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn) scoped to a subset of servers. Do not put server names, hostnames, credentials or other secrets in it. + --- ## Tool-Level Quarantine diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index a8e7e889c..c4b09855a 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -280,6 +280,72 @@ Server scoping is enforced at three levels: below an authenticated admin: it cannot page an entry an API-key admin produced. +### What a scoped token cannot learn + +**Invariant.** No proxy-produced response to an agent-token caller — a tool +result, a refusal, a listing, a count, a suggestion, a notification, a cached +page or a log line — names, counts or otherwise discloses a server, tool, +prompt, profile or stored resource outside the caller's effective scope, and an +out-of-scope resource is refused exactly as a nonexistent one would be. +Administrators (the admin API key, the tray over the local socket, and native +stdio) keep every capability they have today; the exceptions where an +administrator's answer deliberately differs from a token's are named and tested +one by one. + +**Covered surfaces.** The invariant holds for agent-token requests on every +HTTP MCP surface — `/mcp`, `/mcp/all`, `/mcp/call`, `/mcp/code`, +`/mcp/p/` and the trailing-slash alias of each (see +[Routing Modes](https://docs.mcpproxy.app/features/routing-modes/)) — and on +the REST doors listed above. Native stdio is local-administrator-only and is +not a token surface. + +**Retained, documented effects.** Some shared resources are fleet-wide by +construction and this invariant does not change them; a hidden server can still +*affect* what an authorized caller experiences, without being *named*: + +- **Display-name collision admission on `/mcp/all`** — two servers exposing the + same display name collide fleet-wide, so a hidden server can withhold an + authorized entry from the direct listing. +- **Prompt collision rule and the global prompt cap** — evaluated over the whole + fleet. +- **Fleet-wide `list_changed` notifications** on the fixed surfaces — a hidden + server's change still emits the (content-free) notification. +- **Shared call limiter** — the proxy-wide concurrency limit is global, so calls + held on a hidden server can make a call to an authorized server fail with the + existing "proxy-wide limit saturated" response. +- **Cross-server security-scan admission** — under `trust_mode: scan`, a + same-name near-identical tool on a hidden server can hold an authorized + server's newly added tool pending as a shadowing finding, changing that + tool's discovery and dispatch outcome (see + [Security Quarantine](https://docs.mcpproxy.app/features/security-quarantine/)). +- **Shared prompt-refresh deadline** — prompts are collected under one + fleet-wide deadline, so a slow hidden server can exhaust it before an + authorized server's prompts are collected. +- **Shared log rotation and retention** — attribution filters what a token can + read back, not what history survives rotation. + +**Operator-published content — keep secrets out.** Two kinds of operator-authored +content are published to every caller by design and sit outside the invariant: + +1. **Custom initialization `instructions`** (the `instructions` key in the + [config file](https://docs.mcpproxy.app/configuration/)) are returned + verbatim to every client that initializes, scoped or not. +2. **Stored code-execution scripts** — any caller allowed to run + `code_execution` can run a script it knows the name of and receive whatever + the script returns without an upstream call. What the invariant *does* + cover: a missing-script error never enumerates the other script names, the + script count or the scripts directory to an agent-token caller (the refusal + is identical for an empty and a populated directory), while administrators + keep today's listing; and every `call_tool()` a script makes is checked + against the caller's server scope and permission tier. The published + `code_execution` definition says so — enumeration is administrator-only and + an agent-token caller must already know the script name. See + [Stored scripts](https://docs.mcpproxy.app/code_execution/overview/#stored-scripts). + +Do **not** place server names, hostnames, credentials, tokens or any other +secret in either — a scoped agent can read them, and a script's constant return +value is as public as its name. + ## Administrative Operations Are Admin-Only Agent tokens can **discover and call** tools (within their scope and permission tier) but can **never administer servers**. Server-mutating operations require the admin API key (or a local tray/socket connection, which is admin by OS-level auth) on **every** surface — the MCP tools and the REST API share one policy (`internal/auth`), so an agent cannot do over HTTP what it is blocked from doing over MCP. diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index b2f047ee9..aa29e83f6 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -97,14 +97,40 @@ func (e *InvalidNameError) Error() string { // NotFoundError reports a name with no script file behind it, carrying the // available names so the caller can recover in one round trip (FR-004). +// +// The enumeration is administrator-only (Spec 105 FR-012): a scoped caller +// receives the same error with Undisclosed set, whose text names neither +// the other scripts, their count nor the directory — see NonDisclosing(). type NotFoundError struct { Name string Dir string Available []string // first MaxErrorNames ok names, alphabetical Total int // total ok scripts in the directory + + // Undisclosed marks the agent-token form of the error: the listing is + // withheld and the message is independent of the directory's contents, + // so a failed call cannot serve as an oracle for what is stored. + Undisclosed bool +} + +// nonDisclosingNotFoundFormat is the agent-token refusal text. It carries only +// the caller's own requested name and must never depend on the directory's +// contents (Spec 105 FR-012: byte-equal for an empty and a populated +// directory). +const nonDisclosingNotFoundFormat = "stored script %q not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name)" + +// NonDisclosing returns a copy of the error stripped of everything that +// discloses the directory's contents — names, count and path — for delivery +// to a scoped (agent-token) caller. The typed identity is preserved, so the +// REST surface still classifies it as SCRIPT_NOT_FOUND. +func (e *NotFoundError) NonDisclosing() *NotFoundError { + return &NotFoundError{Name: e.Name, Undisclosed: true} } func (e *NotFoundError) Error() string { + if e.Undisclosed { + return fmt.Sprintf(nonDisclosingNotFoundFormat, e.Name) + } if e.Total == 0 { return fmt.Sprintf("stored script %q not found: no stored scripts in %s (create %s%s or %s%s there)", e.Name, e.Dir, e.Name, extJS, e.Name, extTS) diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index fdb8c9b14..a1bc326c0 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -339,6 +339,49 @@ func TestResolve_NotFoundEmptyDirectory(t *testing.T) { assert.Contains(t, err.Error(), "no stored scripts") } +// TestNotFoundError_NonDisclosing pins the Spec 105 FR-012 agent-token form: +// the text carries only the requested name — no listing, no count, no +// directory — and is identical for an empty and a populated directory, while +// the typed identity survives errors.As (the REST surface still answers 404). +func TestNotFoundError_NonDisclosing(t *testing.T) { + populated := t.TempDir() + writeScript(t, populated, "alpha-SENTINEL.js", "1") + writeScript(t, populated, "beta.ts", "1") + + _, _, errPopulated := Resolve(populated, "missing", "") + _, _, errEmpty := Resolve(t.TempDir(), "missing", "") + + var full, none *NotFoundError + require.True(t, errors.As(errPopulated, &full)) + require.True(t, errors.As(errEmpty, &none)) + require.Equal(t, 2, full.Total, "fixture: the administrator form enumerates") + + stripped := full.NonDisclosing() + require.NotNil(t, stripped) + assert.True(t, stripped.Undisclosed) + assert.Empty(t, stripped.Available) + assert.Zero(t, stripped.Total) + assert.Empty(t, stripped.Dir, "the directory path is not disclosed either") + assert.Equal(t, "missing", stripped.Name) + + msg := stripped.Error() + assert.Contains(t, msg, `"missing"`, "the caller's own requested name is echoed") + assert.NotContains(t, msg, "SENTINEL") + assert.NotContains(t, msg, "beta") + assert.NotContains(t, msg, "Available scripts") + assert.NotContains(t, msg, populated, "the directory path is not disclosed") + assert.Contains(t, strings.ToLower(msg), "administrator") + assert.Equal(t, none.NonDisclosing().Error(), msg, + "the non-disclosing text must not depend on the directory's contents") + + // The original is untouched: the administrator keeps the enumeration. + assert.Equal(t, 2, full.Total) + assert.Contains(t, full.Error(), "alpha-SENTINEL") + + var typed *NotFoundError + assert.True(t, errors.As(error(stripped), &typed), "typed identity is preserved for the REST classifier") +} + func TestResolve_Ambiguous(t *testing.T) { dir := t.TempDir() jsPath := writeScript(t, dir, "dup.js", "1") diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index d2683f681..ffa880dcc 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -50,8 +50,9 @@ const ( "**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. " + "Types are automatically stripped before execution.\n\n" + "**Stored scripts**: Instead of `code`, pass `script: \"\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — " + - "a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the " + - "available names, which is how you discover what is stored.\n\n" + + "a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only " + + "(`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — " + + "a name that does not exist is refused without naming what is stored.\n\n" + "**Important runtime rules**:\n" + "- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n" + "- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n" + @@ -71,8 +72,9 @@ const ( "directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. " + "Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. " + "The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. " + - "DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), " + - "so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code." + "ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing " + + "the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error " + + "names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code." codeExecutionInputDescription = "Input data accessible as global `input` variable in code (default: {})" @@ -497,6 +499,7 @@ func (p *MCPProxyServer) resolveCodeExecutionSource(ctx context.Context, args ma source, language, err := codescripts.Resolve(p.scriptsDir(), scriptName, options.Language) if err != nil { + err = p.scopeStoredScriptRefusal(ctx, scriptName, err) // Keep the typed identity reachable for the REST surface (404 for a // name that is not there, 400 for one that cannot run) — the text alone // would force it to classify these by prose. @@ -507,6 +510,31 @@ func (p *MCPProxyServer) resolveCodeExecutionSource(ctx context.Context, args ma return string(source), scriptName, "" } +// scopeStoredScriptRefusal applies the Spec 105 FR-012 caller-kind rule to a +// stored-script resolution failure. The Spec 097 FR-004 not-found error +// enumerates the stored names and their count so an administrator recovers +// the set from one failed call; for a scoped caller (an agent token, whatever +// its server scope — the caller KIND decides, never AllowedServers) that +// listing is withheld and the refusal is made independent of the directory's +// contents, so a failed call is not an oracle for what is stored. Every other +// refusal (invalid name, ambiguous, unreadable, language mismatch) already +// speaks only about the caller's own request and passes through unchanged. +// An absent auth context (in-process caller) or an administrator keeps the +// enumeration (SC-005: the named FR-012 admin exception). +func (p *MCPProxyServer) scopeStoredScriptRefusal(ctx context.Context, scriptName string, err error) error { + if !auth.IsScopedCaller(ctx) { + return err + } + var notFound *codescripts.NotFoundError + if !errors.As(err, ¬Found) || notFound.Undisclosed { + return err + } + p.logger.Debug("Withholding stored-script enumeration from scoped caller (Spec 105 FR-012)", + zap.String("script", scriptName), + zap.Int("available_total", notFound.Total)) + return notFound.NonDisclosing() +} + // activeConfigFilePath returns the configuration FILE this server belongs to: // the path declared at construction (WithConfigFilePath — every production // surface passes it), else the running server's own resolution. diff --git a/internal/server/testdata/toolslist_goldens/code_execution_mode.json b/internal/server/testdata/toolslist_goldens/code_execution_mode.json index a3d175784..3dc25c486 100644 --- a/internal/server/testdata/toolslist_goldens/code_execution_mode.json +++ b/internal/server/testdata/toolslist_goldens/code_execution_mode.json @@ -7,7 +7,7 @@ "idempotentHint": false, "openWorldHint": true }, - "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", + "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only (`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — a name that does not exist is refused without naming what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", "inputSchema": { "properties": { "code": { @@ -33,7 +33,7 @@ "type": "object" }, "script": { - "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", "type": "string" } }, diff --git a/internal/server/testdata/toolslist_goldens/default_server.json b/internal/server/testdata/toolslist_goldens/default_server.json index 8e860dbf1..8083efeb1 100644 --- a/internal/server/testdata/toolslist_goldens/default_server.json +++ b/internal/server/testdata/toolslist_goldens/default_server.json @@ -127,7 +127,7 @@ "idempotentHint": false, "openWorldHint": true }, - "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", + "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only (`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — a name that does not exist is refused without naming what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", "inputSchema": { "properties": { "code": { @@ -153,7 +153,7 @@ "type": "object" }, "script": { - "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", "type": "string" } }, diff --git a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json index a3911775e..846578bd1 100644 --- a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json +++ b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json @@ -127,7 +127,7 @@ "idempotentHint": false, "openWorldHint": true }, - "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", + "description": "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (\u003e2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of {server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only (`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — a name that does not exist is refused without naming what is stored.\n\n**Important runtime rules**:\n- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions.", "inputSchema": { "properties": { "code": { @@ -153,7 +153,7 @@ "type": "object" }, "script": { - "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", "type": "string" } }, diff --git a/specs/105-agent-scope-hardening/tasks.md b/specs/105-agent-scope-hardening/tasks.md index cc9fd9387..7f246c1fc 100644 --- a/specs/105-agent-scope-hardening/tasks.md +++ b/specs/105-agent-scope-hardening/tasks.md @@ -159,18 +159,18 @@ ### Failing tests -- [ ] T062 [US1] FR01x-G1: agent ctx `["*"]`, script `gamma`, `alpha-SENTINEL.js` present → IsError, no `SENTINEL`/`Available scripts`, byte-equal to empty-dir proxy; admin control kept; positive controls per `spec.md:116`: `a`-only token runs a stored script returning a constant (no upstream call) and gets the constant; a stored script calling `b` is refused at the nested call; scoped initialization publishes custom `instructions` mentioning `b:private_search` (documented) — `internal/server/mcp_code_scripts_test.go` + `internal/server/mcp_instructions_scope_test.go` (new) -- [ ] T063 [P] [US1] FR01x-G2: `TestCodeExecutionDescriptions_EnumerationIsAdminOnly` asserting `code_execution.description` and `script.description` no longer advertise enumeration, and a golden-delta assertion that only those two strings changed — `internal/server/toolslist_snapshot_test.go` +- [x] T062 [US1] FR01x-G1: agent ctx `["*"]`, script `gamma`, `alpha-SENTINEL.js` present → IsError, no `SENTINEL`/`Available scripts`, byte-equal to empty-dir proxy; admin control kept; positive controls per `spec.md:116`: `a`-only token runs a stored script returning a constant (no upstream call) and gets the constant; a stored script calling `b` is refused at the nested call; scoped initialization publishes custom `instructions` mentioning `b:private_search` (documented) — `internal/server/mcp_code_scripts_test.go` + `internal/server/mcp_instructions_scope_test.go` (new) +- [x] T063 [P] [US1] FR01x-G2: `TestCodeExecutionDescriptions_EnumerationIsAdminOnly` asserting `code_execution.description` and `script.description` no longer advertise enumeration, and a golden-delta assertion that only those two strings changed — `internal/server/toolslist_snapshot_test.go` ### Implementation -- [ ] T064 [US1] Caller-kind branch: enumeration only for non-scoped callers — `internal/server/mcp_code_execution.go:473-499` (or `internal/codescripts/codescripts.go:338-356` with a caller flag) -- [ ] T065 [US1] Reword `internal/server/mcp_code_execution.go:52-53,73-74`; regenerate goldens with `MCPPROXY_WRITE_TOOLSLIST_GOLDENS=testdata/toolslist_goldens go test -run TestToolsListSnapshot ./internal/server/` (the variable is the OUTPUT DIRECTORY, `toolslist_snapshot_test.go:151-158`), then rerun with it unset; diff limited to `internal/server/testdata/toolslist_goldens/{default_server,retrieve_tools_mode,code_execution_mode}.json` -- [ ] T066 [P] [US1] Docs: enumeration is admin-only in `docs/code_execution/overview.md:379-387`, `cookbook.md:141`, `troubleshooting.md:604-613`, `api-reference.md:591`; add invariant sentence, covered-surface list (`/mcp`, `/mcp/all`, `/mcp/code`, `/mcp/call`, `/mcp/p/`, aliases), retained-effects list and custom-instructions/stored-scripts secrets warning to `docs/features/agent-tokens.md` (FR01x-G3) +- [x] T064 [US1] Caller-kind branch: enumeration only for non-scoped callers — `internal/server/mcp_code_execution.go:473-499` (or `internal/codescripts/codescripts.go:338-356` with a caller flag) +- [x] T065 [US1] Reword `internal/server/mcp_code_execution.go:52-53,73-74`; regenerate goldens with `MCPPROXY_WRITE_TOOLSLIST_GOLDENS=testdata/toolslist_goldens go test -run TestToolsListSnapshot ./internal/server/` (the variable is the OUTPUT DIRECTORY, `toolslist_snapshot_test.go:151-158`), then rerun with it unset; diff limited to `internal/server/testdata/toolslist_goldens/{default_server,retrieve_tools_mode,code_execution_mode}.json` +- [x] T066 [P] [US1] Docs: enumeration is admin-only in `docs/code_execution/overview.md:379-387`, `cookbook.md:141`, `troubleshooting.md:604-613`, `api-reference.md:591`; add invariant sentence, covered-surface list (`/mcp`, `/mcp/all`, `/mcp/code`, `/mcp/call`, `/mcp/p/`, aliases), retained-effects list and custom-instructions/stored-scripts secrets warning to `docs/features/agent-tokens.md` (FR01x-G3) ### Verification -- [ ] T067 [US1] Common verification; `git diff --stat -- internal/server/testdata` shows only the three goldens +- [x] T067 [US1] Common verification; `git diff --stat -- internal/server/testdata` shows only the three goldens - [~] T068 [US1] Astra rounds on FR-012 + FR01x-G1…G3; quote final `VERDICT:` --- From 2aab29e2a80ac674d4039d4c325c0cc0d7358f97 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 08:32:37 +0300 Subject: [PATCH 03/23] =?UTF-8?q?fix(scope):=20PR=20H0=20critique=20round?= =?UTF-8?q?=201=20=E2=80=94=20gate=20the=20REST=20script=20listing,=20scop?= =?UTF-8?q?ed=20resolver=20never=20lists,=20path-free=20sibling=20refusals?= =?UTF-8?q?=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critique 1/2 #1 (MUST): GET /api/v1/code/scripts answered any agent token with every stored name, host path and the scripts directory — the exact enumeration the missing-script refusal now withholds, and the published code_execution description claimed the listing was administrator-only. handleListScripts opens with requireAdminRead (the same !IsAdmin predicate the MCP refusal keys on): admin key / tray socket / nil context pass, every agent token gets 403 with a body naming nothing. @Failure 403 added and oas/{swagger.yaml,docs.go} regenerated (diff limited to that route). Critique 1 #2/#3 (SHOULD): the scoped refusal was built by resolving with the administrator form (List → one lstat per stored script) and stripping the result afterwards, and the ambiguous / unusable / unreadable-directory refusals still carried the host paths and the raw OS error. codescripts gains ResolveScoped: a shared resolve(…, disclose) body where the scoped not-found error is constructed without touching the directory (listForNotFound seam + witness test proves 0 listings) and AmbiguousError / InvalidError get an Undisclosed form (name + reason, no Path/Paths/ Detail) with typed identity preserved for the REST classifier. The server picks Resolve / ResolveScoped by auth.IsScopedCaller; the post-hoc copy and the debug log of the withheld count are gone (#6). Critique 1 #4 / 2 #3: the anonymous /mcp caller under require_mcp_auth=false is administrator-shaped by design (spec FR-002); code unchanged, docs now say who counts as an administrator and how to close that door. Critique 1 #5: the nested-b positive control now has a real hidden b (counting upstream): a-only refusal byte-equal to the nonexistent-b run, zero upstream calls, admin control reaches b. Critique 2 #2: agent-tokens.md invariant carries a rollout-status note (lands surface by surface; present-tense rules are the enforced ones). #4: REST classification rows for the three non-disclosing forms. #5: stale comments in code_exec.go / code_cmd.go. #6: T064/T067 wording. #7: reuse jsonRPCHandler and a configure-hook fixture. #8: errors.As through a %w wrapper instead of on the bare pointer. Co-Authored-By: Claude Opus 5 --- cmd/mcpproxy/code_cmd.go | 6 +- docs/code_execution/api-reference.md | 19 ++- docs/code_execution/overview.md | 16 +- docs/code_execution/troubleshooting.md | 4 +- docs/features/agent-tokens.md | 38 ++++- internal/codescripts/codescripts.go | 122 +++++++++++++-- internal/codescripts/codescripts_test.go | 118 +++++++++++++- internal/httpapi/code_exec.go | 16 +- internal/httpapi/code_exec_status_test.go | 48 +++++- internal/httpapi/code_scripts.go | 18 ++- internal/httpapi/code_scripts_test.go | 33 ++++ internal/server/mcp_code_execution.go | 44 +++--- internal/server/mcp_code_scripts_test.go | 144 ++++++++++++++++-- .../server/mcp_instructions_scope_test.go | 41 +---- oas/docs.go | 2 +- oas/swagger.yaml | 10 +- specs/105-agent-scope-hardening/tasks.md | 4 +- 17 files changed, 567 insertions(+), 116 deletions(-) diff --git a/cmd/mcpproxy/code_cmd.go b/cmd/mcpproxy/code_cmd.go index c81bce3bb..e15a1b2e7 100644 --- a/cmd/mcpproxy/code_cmd.go +++ b/cmd/mcpproxy/code_cmd.go @@ -607,8 +607,10 @@ func outputResult(result *cliclient.CodeExecResult) error { func outputResultFromMCP(result *mcp.CallToolResult) error { // A tool ERROR is plain text, not the execution envelope — and for a stored // script that text is the recovery path: naming one that does not exist - // answers with the available names (FR-004). Parsing it as JSON and giving - // up ("unexpected result format") threw that away. + // answers with the available names (FR-004) because the CLI authenticates + // with the admin API key (an agent token would get the non-disclosing + // form, Spec 105 FR-012). Parsing it as JSON and giving up ("unexpected + // result format") threw that away. if result.IsError { for _, content := range result.Content { if textContent, ok := mcp.AsTextContent(content); ok { diff --git a/docs/code_execution/api-reference.md b/docs/code_execution/api-reference.md index 909599d62..589d4c13e 100644 --- a/docs/code_execution/api-reference.md +++ b/docs/code_execution/api-reference.md @@ -592,8 +592,10 @@ executed source under `code` and additionally carry `script: ""`. | No scripts at all (administrator) | `stored script "X" not found: no stored scripts in (create X.js or X.ts there)` | | Unknown name ([agent token](https://docs.mcpproxy.app/features/agent-tokens/), any scope) | `stored script "X" not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name)` — identical for an empty and a populated directory; the listing is administrator-only | | Invalid name | `invalid script name "…": character "/" is not allowed …` | -| Both extensions present | `stored script "X" is ambiguous: /X.js and /X.ts both exist — remove one` | -| Empty / oversized / unreadable / non-regular | `stored script "X" () is oversized: scripts are limited to 262144 bytes` | +| Both extensions present (administrator) | `stored script "X" is ambiguous: /X.js and /X.ts both exist — remove one` | +| Both extensions present (agent token) | `stored script "X" is ambiguous: both a .js and a .ts file exist — ask an administrator to remove one` — no host path | +| Empty / oversized / unreadable / non-regular (administrator) | `stored script "X" () is oversized: scripts are limited to 262144 bytes` | +| Empty / oversized / unreadable / non-regular (agent token) | `stored script "X" is oversized: scripts are limited to 262144 bytes` — the reason stays, the host path and any raw OS error are withheld | | `language` contradicts the extension | `stored script "X" is a .ts file (typescript) but language "javascript" was requested …` | The not-found error **is** the MCP discovery mechanism (FR-004): it lists the @@ -636,7 +638,7 @@ never re-sends a request that cannot succeed: | `enable_code_execution` is `false` | 403 | `FEATURE_DISABLED` | | Unknown script name (carries the available names for an administrator; an agent token gets the non-disclosing message) | 404 | `SCRIPT_NOT_FOUND` | | Invalid script name | 400 | `INVALID_SCRIPT_NAME` | -| Ambiguous, empty, oversized, unreadable or non-regular | 400 | `SCRIPT_UNUSABLE` | +| Ambiguous, empty, oversized, unreadable or non-regular (an agent token gets the path-free message) | 400 | `SCRIPT_UNUSABLE` | | `language` contradicts the extension | 400 | `INVALID_LANGUAGE` | | Execution fault (pool, storage, internal) | 500 | `EXECUTION_FAILED` | @@ -650,7 +652,12 @@ switching the feature off also stops stored scripts from being read from disk. ### REST: `GET /api/v1/code/scripts` Read-only listing of the stored scripts, using the same API-key auth as the rest -of `/api/v1` (`X-API-Key` header or `?apikey=`): +of `/api/v1` (`X-API-Key` header or `?apikey=`). **Administrator-only**: the +admin API key (and the tray over the local socket) get the listing; an +[agent token](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn) +— whatever its server scope — is refused with `403` and a body that names +nothing about the directory, because this listing is exactly the enumeration +the missing-script error withholds from a scoped caller: ```bash curl -H "X-API-Key: $MCPPROXY_API_KEY" http://127.0.0.1:8080/api/v1/code/scripts @@ -683,6 +690,10 @@ curl -H "X-API-Key: $MCPPROXY_API_KEY" http://127.0.0.1:8080/api/v1/code/scripts An absent or empty directory returns an empty `scripts` list, not an error. Statuses are advisory — the tool re-checks at invocation time. +```json +{"success": false, "error": "Agent tokens cannot list stored scripts (the stored-script listing is available to administrators only)"} +``` + **There is no write surface.** No endpoint, tool, or CLI verb creates, updates, or deletes a script; the filesystem is the sole authoring interface. diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index 5dd268444..b090d9062 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -376,9 +376,15 @@ mcpproxy code scripts list -o json # {"dir": "...", "scripts": [{"name","paths" curl -H "X-API-Key: $KEY" http://127.0.0.1:8080/api/v1/code/scripts ``` +Both are administrator views: the REST listing answers only the admin API key +(or the tray over the local socket) and refuses an agent token with `403`. + MCP clients do not get a listing tool — registrations are static, so an embedded list would go stale. For **administrators** (the admin API key, the tray over the -local socket, or an in-process caller) discovery is **error-driven** instead: +local socket, an in-process caller — and, under the default +`require_mcp_auth: false`, an unauthenticated `/mcp` client, which the proxy +treats as an administrator for backward compatibility) discovery is +**error-driven** instead: invoking a name that does not exist returns an error listing the first 20 available names alphabetically plus the total, so the current name set is recovered from a single failed call. @@ -393,7 +399,9 @@ Cannot execute stored script: stored script "fetch-pr" not found in server scope, even `--servers "*"` — must already know the script name. Its not-found error names neither the other stored scripts, nor how many there are, nor the directory, and it is byte-for-byte the same whether the directory is -empty or full, so a failed call cannot be used to probe what is stored: +empty or full, so a failed call cannot be used to probe what is stored — and +the proxy does not even read the directory listing on its behalf, so the +refusal's cost does not grow with the number of stored scripts: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script @@ -401,6 +409,10 @@ listing is available to administrators only; an agent-token caller must already know the script name) ``` +The same rule covers the other refusals: an ambiguous, empty, oversized or +unreadable script is reported to an agent token by name and reason only — no +host path, no raw OS error — while an administrator sees the full path. + Stored scripts are operator-published content: any caller allowed to run `code_execution` can run a script it knows the name of and receive whatever the script returns without an upstream call, while every `call_tool()` the script diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index 5f6f6be20..21fcb98ae 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -744,7 +744,9 @@ running in the sandbox has no filesystem access either. **Solution**: Author scripts with your normal filesystem tooling (editor, `scp`, configuration management). `GET /api/v1/code/scripts` and `mcpproxy code scripts -list` are read-only views of the result. +list` are read-only, administrator-only views of the result (an +[agent token](https://docs.mcpproxy.app/features/agent-tokens/) is refused with +`403`). --- diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index c4b09855a..6c0246f22 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -234,6 +234,11 @@ Server scoping is enforced at three levels: masked, so this is a credential *inventory* rather than a disclosure, but it names the secrets of servers the caller may not enumerate. A strictly narrower view of the document `GET /api/v1/config` already denies. + - `GET /api/v1/code/scripts` — the stored-script listing (every name, its + host path and the scripts directory) is exactly the enumeration the + missing-script error withholds from a scoped caller, so the door is + closed on the REST surface too (see + [What a scoped token cannot learn](#what-a-scoped-token-cannot-learn)). **Withheld rather than denied.** `GET /api/v1/status` stays open — agents legitimately poll it for liveness — but its `activation` block is omitted for @@ -292,6 +297,24 @@ stdio) keep every capability they have today; the exceptions where an administrator's answer deliberately differs from a token's are named and tested one by one. +> **Rollout status.** This invariant is being landed surface by surface as the +> agent-scope hardening series (Spec 105) merges; each release's notes list the +> surfaces it closes. The rules on this page that are stated as present-tense +> guarantees — the stored-script rules below, the REST doors listed above and +> the `read_cache` rule — are enforced by the version that documents them. Until +> the series is complete, a listing or suggestion on a surface not yet covered +> can still name an out-of-scope resource; treat that as a known gap, not a +> configuration mistake. + +> **Who counts as an administrator.** The admin API key, the tray over the +> local socket, native stdio, an in-process caller — and, under the default +> `require_mcp_auth: false`, an **unauthenticated** `/mcp` client, which the +> proxy has always treated as an administrator for backward compatibility. Only +> an agent token is a scoped caller; if unauthenticated clients must not see +> administrator answers, set +> [`require_mcp_auth: true`](https://docs.mcpproxy.app/configuration/) so every +> `/mcp` request carries a key or a token. + **Covered surfaces.** The invariant holds for agent-token requests on every HTTP MCP surface — `/mcp`, `/mcp/all`, `/mcp/call`, `/mcp/code`, `/mcp/p/` and the trailing-slash alias of each (see @@ -335,11 +358,16 @@ content are published to every caller by design and sit outside the invariant: the script returns without an upstream call. What the invariant *does* cover: a missing-script error never enumerates the other script names, the script count or the scripts directory to an agent-token caller (the refusal - is identical for an empty and a populated directory), while administrators - keep today's listing; and every `call_tool()` a script makes is checked - against the caller's server scope and permission tier. The published - `code_execution` definition says so — enumeration is administrator-only and - an agent-token caller must already know the script name. See + is identical for an empty and a populated directory, and the directory is + not even read on the caller's behalf); an ambiguous or unusable script is + reported by name and reason only, without its host path or a raw OS error; + the REST listing `GET /api/v1/code/scripts` answers an agent token with + `403`; administrators keep today's listing and paths; and every + `call_tool()` a script makes is checked against the caller's server scope + and permission tier — a hidden server is refused exactly as a nonexistent + one. The published `code_execution` definition says so — enumeration is + administrator-only and an agent-token caller must already know the script + name. See [Stored scripts](https://docs.mcpproxy.app/code_execution/overview/#stored-scripts). Do **not** place server names, hostnames, credentials, tokens or any other diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index aa29e83f6..8da06ac13 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -144,26 +144,66 @@ func (e *NotFoundError) Error() string { } // AmbiguousError reports a name backed by both a .js and a .ts file. +// +// The paths are host filesystem locations (they reveal the config directory), +// so the scoped form withholds them — see NonDisclosing(). type AmbiguousError struct { Name string Paths []string + + // Undisclosed marks the agent-token form: the message names the caller's + // own script and the reason only, never a host path (Spec 105 FR-012). + Undisclosed bool +} + +// NonDisclosing returns a copy stripped of the host paths, for delivery to a +// scoped (agent-token) caller. The typed identity is preserved, so the REST +// surface still classifies it as SCRIPT_UNUSABLE. +func (e *AmbiguousError) NonDisclosing() *AmbiguousError { + return &AmbiguousError{Name: e.Name, Undisclosed: true} } func (e *AmbiguousError) Error() string { + if e.Undisclosed { + return fmt.Sprintf("stored script %q is ambiguous: both a %s and a %s file exist — ask an administrator to remove one", + e.Name, extJS, extTS) + } return fmt.Sprintf("stored script %q is ambiguous: %s both exist — remove one", e.Name, strings.Join(e.Paths, " and ")) } // InvalidError reports a script file that exists but cannot be executed. +// +// Path is a host filesystem location and Detail is frequently a raw OS error +// carrying another one, so the scoped form withholds both — see +// NonDisclosing(). type InvalidError struct { Name string Path string Reason string Detail string + + // Undisclosed marks the agent-token form: the message names the caller's + // own script and the reason only — no path, no OS error (Spec 105 FR-012). + Undisclosed bool +} + +// NonDisclosing returns a copy stripped of the host path and the raw detail, +// for delivery to a scoped (agent-token) caller. The typed identity and the +// reason are preserved, so the REST surface still classifies it as +// SCRIPT_UNUSABLE and the caller still learns what is wrong with its own +// script. +func (e *InvalidError) NonDisclosing() *InvalidError { + return &InvalidError{Name: e.Name, Reason: e.Reason, Undisclosed: true} } func (e *InvalidError) Error() string { - msg := fmt.Sprintf("stored script %q (%s) is %s", e.Name, e.Path, e.Reason) + var msg string + if e.Undisclosed { + msg = fmt.Sprintf("stored script %q is %s", e.Name, e.Reason) + } else { + msg = fmt.Sprintf("stored script %q (%s) is %s", e.Name, e.Path, e.Reason) + } switch e.Reason { case ReasonOversized: msg += fmt.Sprintf(": scripts are limited to %d bytes", MaxSizeBytes) @@ -240,38 +280,71 @@ func DeriveLanguage(name, ext, explicitLanguage string) (string, error) { } // Resolve reads the stored script `name` from scriptsDir and returns its -// source together with the language derived from its extension. +// source together with the language derived from its extension. This is the +// ADMINISTRATOR form: a not-found error carries the directory's listing +// (FR-004) and every other refusal names the host path it is about. // // Order matters: the name is validated BEFORE any filesystem call (SC-003), // then the directory decides which candidates exist, then the surviving // candidate is opened with the platform's no-follow idiom and read through a // bounded reader. Exactly one open and one read per call — no cache, no re-read. func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { + return resolve(scriptsDir, name, explicitLanguage, true) +} + +// ResolveScoped is Resolve for a scoped (agent-token) caller — Spec 105 +// FR-012. It reads the script exactly as Resolve does, but every refusal it +// returns is already the non-disclosing form: a not-found error is built +// WITHOUT listing the directory (no per-entry stat for a caller that is never +// shown the result — the refusal's cost does not grow with what is stored), +// and the ambiguous / invalid forms carry the caller's own name and the +// reason but no host path and no raw OS error. Typed identities are the +// same, so the REST classifier does not tell the two callers apart. +func ResolveScoped(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { + return resolve(scriptsDir, name, explicitLanguage, false) +} + +// resolve is the shared body of Resolve and ResolveScoped; disclose selects +// the administrator (true) or the scoped (false) refusal forms. +func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source []byte, language string, err error) { if err := ValidateName(name); err != nil { return nil, "", err } + notFound := func() error { return notFoundErrorFor(scriptsDir, name, disclose) } + invalid := func(path, reason, detail string) error { + e := &InvalidError{Name: name, Path: path, Reason: reason, Detail: detail} + if !disclose { + return e.NonDisclosing() + } + return e + } + // An empty scripts dir would make filepath.Join produce a bare relative // path resolved against the process CWD — never that. No authority means // no scripts. if scriptsDir == "" { - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() } found, err := candidatesFor(scriptsDir, name) if err != nil { if errors.Is(err, fs.ErrNotExist) { - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() } - return nil, "", &InvalidError{Name: name, Path: scriptsDir, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(scriptsDir, ReasonUnreadable, err.Error()) } switch len(found) { case 0: - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() case 1: default: - return nil, "", &AmbiguousError{Name: name, Paths: found} + ambiguous := &AmbiguousError{Name: name, Paths: found} + if !disclose { + return nil, "", ambiguous.NonDisclosing() + } + return nil, "", ambiguous } path := found[0] @@ -284,12 +357,12 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language if err != nil { switch { case errors.Is(err, errNonRegular): - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonNonRegular} + return nil, "", invalid(path, ReasonNonRegular, "") case errors.Is(err, fs.ErrNotExist): // Removed between the probe and the open. - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() default: - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(path, ReasonUnreadable, err.Error()) } } defer f.Close() @@ -298,10 +371,10 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language // read, whatever the path pointed at a moment ago. info, err := f.Stat() if err != nil { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(path, ReasonUnreadable, err.Error()) } if !info.Mode().IsRegular() { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonNonRegular} + return nil, "", invalid(path, ReasonNonRegular, "") } // Bound the read itself rather than trusting the stat size: a file that @@ -309,13 +382,13 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language // One extra byte is requested purely to detect the overflow. data, err := io.ReadAll(io.LimitReader(f, MaxSizeBytes+1)) if err != nil { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(path, ReasonUnreadable, err.Error()) } if len(data) > MaxSizeBytes { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonOversized} + return nil, "", invalid(path, ReasonOversized, "") } if len(data) == 0 { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonEmpty} + return nil, "", invalid(path, ReasonEmpty, "") } return data, lang, nil @@ -359,6 +432,23 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { return found, nil } +// listForNotFound is the directory listing newNotFoundError attaches to the +// administrator's error. A variable so the package's tests can witness that +// the scoped form never invokes it. +var listForNotFound = List + +// notFoundErrorFor builds the not-found error for one caller kind: the +// discovery-carrying administrator form (FR-004), or the scoped form that is +// constructed without touching the directory at all (Spec 105 FR-012 — the +// listing would only be thrown away, and its per-entry stat would make the +// refusal's latency grow with the number of stored scripts). +func notFoundErrorFor(scriptsDir, name string, disclose bool) *NotFoundError { + if !disclose { + return &NotFoundError{Name: name, Undisclosed: true} + } + return newNotFoundError(scriptsDir, name) +} + // newNotFoundError builds the discovery-carrying not-found error (FR-004). // A listing failure is not fatal here: the caller still gets "not found". func newNotFoundError(scriptsDir, name string) *NotFoundError { @@ -366,7 +456,7 @@ func newNotFoundError(scriptsDir, name string) *NotFoundError { if scriptsDir == "" { return err } - entries, listErr := List(scriptsDir) + entries, listErr := listForNotFound(scriptsDir) if listErr != nil { return err } diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index a1bc326c0..b0ceee9b2 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -378,8 +378,124 @@ func TestNotFoundError_NonDisclosing(t *testing.T) { assert.Equal(t, 2, full.Total) assert.Contains(t, full.Error(), "alpha-SENTINEL") + // The dispatch layer wraps the error before the REST classifier sees it, + // so the identity must survive a %w wrapper — asserting errors.As on the + // bare *NotFoundError would be vacuous. var typed *NotFoundError - assert.True(t, errors.As(error(stripped), &typed), "typed identity is preserved for the REST classifier") + wrapped := fmt.Errorf("tool call failed: %w", stripped) + require.True(t, errors.As(wrapped, &typed), "typed identity is preserved for the REST classifier") + assert.True(t, typed.Undisclosed) +} + +// TestResolveScoped_NeverListsTheDirectory (Spec 105 FR-012, critique r1 #2): +// the scoped not-found refusal is constructed without the directory listing +// the administrator's error carries. The listing is a per-entry stat the +// scoped caller is never shown, so it must not be paid for on its behalf — +// otherwise the refusal's latency grows with the number of stored scripts +// (the spec's "timing class" is part of a non-disclosing refusal). +func TestResolveScoped_NeverListsTheDirectory(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha-SENTINEL.js", "1") + writeScript(t, dir, "beta.ts", "1") + + var listings int + original := listForNotFound + listForNotFound = func(scriptsDir string) ([]Entry, error) { + listings++ + return original(scriptsDir) + } + t.Cleanup(func() { listForNotFound = original }) + + _, _, err := ResolveScoped(dir, "missing", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + assert.Zero(t, notFound.Total) + assert.Empty(t, notFound.Available) + assert.Empty(t, notFound.Dir) + assert.Equal(t, 0, listings, "the scoped refusal must not list the directory it will never disclose") + assert.NotContains(t, err.Error(), "SENTINEL") + + // Administrator control: the same miss on the same directory enumerates. + _, _, adminErr := Resolve(dir, "missing", "") + require.True(t, errors.As(adminErr, ¬Found)) + assert.Equal(t, 2, notFound.Total) + assert.Equal(t, 1, listings, "the administrator's error is built from one listing") + assert.Contains(t, adminErr.Error(), "alpha-SENTINEL") +} + +// TestResolveScoped_RefusalsCarryNoHostPath (Spec 105 FR-012, critique r1 +// #3): the sibling refusals — ambiguous, unusable, unreadable directory — +// name the caller's own script and the reason, never the scripts directory, +// a host path or a raw OS error; the administrator form keeps them. The +// typed identity survives a %w wrapper for the REST classifier in both forms. +func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { + t.Run("ambiguous", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "dup.js", "1") + writeScript(t, dir, "dup.ts", "1") + + _, _, err := ResolveScoped(dir, "dup", "") + var ambiguous *AmbiguousError + require.True(t, errors.As(fmt.Errorf("wrap: %w", err), &ambiguous), "want *AmbiguousError, got %T: %v", err, err) + assert.True(t, ambiguous.Undisclosed) + assert.Empty(t, ambiguous.Paths) + assert.Contains(t, err.Error(), `"dup"`) + assert.Contains(t, err.Error(), "ambiguous") + assert.NotContains(t, err.Error(), dir) + + _, _, adminErr := Resolve(dir, "dup", "") + assert.Contains(t, adminErr.Error(), dir, "the administrator keeps the paths") + }) + + for _, cell := range []struct { + name string + content string + reason string + }{ + {"empty", "", ReasonEmpty}, + {"oversized", strings.Repeat("x", MaxSizeBytes+1), ReasonOversized}, + } { + cell := cell + t.Run(cell.name, func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "bad.js", cell.content) + + _, _, err := ResolveScoped(dir, "bad", "") + var invalid *InvalidError + require.True(t, errors.As(fmt.Errorf("wrap: %w", err), &invalid), "want *InvalidError, got %T: %v", err, err) + assert.True(t, invalid.Undisclosed) + assert.Equal(t, cell.reason, invalid.Reason, "the reason is the caller's recovery path and stays") + assert.Empty(t, invalid.Path) + assert.Contains(t, err.Error(), cell.reason) + assert.NotContains(t, err.Error(), dir) + + _, _, adminErr := Resolve(dir, "bad", "") + assert.Contains(t, adminErr.Error(), dir, "the administrator keeps the path") + }) + } + + t.Run("unreadable directory withholds the OS error", func(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("directory permission bits are not enforced here") + } + dir := t.TempDir() + writeScript(t, dir, "x.js", "1") + require.NoError(t, os.Chmod(dir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + _, _, err := ResolveScoped(dir, "x", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonUnreadable, invalid.Reason) + assert.Empty(t, invalid.Detail) + assert.NotContains(t, err.Error(), dir) + assert.NotContains(t, err.Error(), "permission denied") + + _, _, adminErr := Resolve(dir, "x", "") + assert.Contains(t, adminErr.Error(), dir) + assert.Contains(t, adminErr.Error(), "permission denied", "the administrator keeps the OS error") + }) } func TestResolve_Ambiguous(t *testing.T) { diff --git a/internal/httpapi/code_exec.go b/internal/httpapi/code_exec.go index 678019259..44b9dcc8b 100644 --- a/internal/httpapi/code_exec.go +++ b/internal/httpapi/code_exec.go @@ -182,11 +182,12 @@ func (h *CodeExecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { result, err := h.toolCaller.CallTool(ctx, "code_execution", args) if err != nil { // A refusal the caller could have avoided is not a server fault. Naming - // a script that does not exist is the documented discovery path, and a - // mistyped or ambiguous name is a caller mistake; answered as 500 they - // look retryable to an agent's retry policy and count as server errors - // in monitoring. The tool's own explanation is what travels, since that - // text is how the caller recovers. + // a script that does not exist is the administrator's documented + // discovery path (an agent token gets the non-disclosing form, Spec + // 105 FR-012), and a mistyped or ambiguous name is a caller mistake; + // answered as 500 they look retryable to an agent's retry policy and + // count as server errors in monitoring. The tool's own explanation is + // what travels, since that text is how the caller recovers. if status, code, message, ok := classifyCodeExecError(err); ok { h.logger.Debugw("Code execution refused", "status", status, "code", code, "error", err) h.writeError(w, r, status, code, message) @@ -229,7 +230,10 @@ func classifyCodeExecError(err error) (status int, code, message string, ok bool var notFound *codescripts.NotFoundError if errors.As(err, ¬Found) { // 404 rather than 400: the request is well formed, the script is not - // there — and the message carries the available names (FR-004). + // there. The message is the error's own: the available names for an + // administrator (FR-004), the non-disclosing text for an agent token + // (Spec 105 FR-012) — the same typed identity either way, which is why + // the status is decided here and the wording is not rebuilt. return http.StatusNotFound, "SCRIPT_NOT_FOUND", notFound.Error(), true } diff --git a/internal/httpapi/code_exec_status_test.go b/internal/httpapi/code_exec_status_test.go index 786a5b963..58c63dedd 100644 --- a/internal/httpapi/code_exec_status_test.go +++ b/internal/httpapi/code_exec_status_test.go @@ -46,11 +46,12 @@ func TestCodeExec_ScriptResolutionFailuresAreClientErrors(t *testing.T) { wrap := func(err error) error { return fmt.Errorf("tool call failed: %w", err) } tests := []struct { - name string - err error - wantStatus int - wantCode string - wantInMsg string + name string + err error + wantStatus int + wantCode string + wantInMsg string + wantNotInMsg []string }{ { name: "not found carries the discovery listing", @@ -64,6 +65,39 @@ func TestCodeExec_ScriptResolutionFailuresAreClientErrors(t *testing.T) { wantCode: "SCRIPT_NOT_FOUND", wantInMsg: "daily-report", }, + { + // Spec 105 FR-012: the scoped form keeps the typed identity — the + // same 404 / SCRIPT_NOT_FOUND — with its own non-disclosing text. + // The classifier must keep using .Error() rather than rebuilding + // the message from the (now empty) fields. + name: "not found, agent-token form, discloses nothing", + err: wrap((&codescripts.NotFoundError{ + Name: "nope", + Dir: "/cfg/scripts", + Available: []string{"daily-report"}, + Total: 1, + }).NonDisclosing()), + wantStatus: http.StatusNotFound, + wantCode: "SCRIPT_NOT_FOUND", + wantInMsg: "administrators only", + wantNotInMsg: []string{"daily-report", "/cfg/scripts", "(1)"}, + }, + { + name: "ambiguous, agent-token form, discloses no path", + err: wrap((&codescripts.AmbiguousError{Name: "dup", Paths: []string{"/cfg/scripts/dup.js", "/cfg/scripts/dup.ts"}}).NonDisclosing()), + wantStatus: http.StatusBadRequest, + wantCode: "SCRIPT_UNUSABLE", + wantInMsg: "ambiguous", + wantNotInMsg: []string{"/cfg/scripts"}, + }, + { + name: "unreadable, agent-token form, discloses no path or OS error", + err: wrap((&codescripts.InvalidError{Name: "x", Path: "/cfg/scripts", Reason: codescripts.ReasonUnreadable, Detail: "open /cfg/scripts: permission denied"}).NonDisclosing()), + wantStatus: http.StatusBadRequest, + wantCode: "SCRIPT_UNUSABLE", + wantInMsg: codescripts.ReasonUnreadable, + wantNotInMsg: []string{"/cfg/scripts", "permission denied"}, + }, { name: "invalid name", err: wrap(&codescripts.InvalidNameError{Name: "../etc/passwd", Reason: "character \"/\" is not allowed"}), @@ -105,6 +139,10 @@ func TestCodeExec_ScriptResolutionFailuresAreClientErrors(t *testing.T) { assert.Equal(t, tc.wantCode, decoded.Error.Code) assert.Contains(t, decoded.Error.Message, tc.wantInMsg, "the tool's own explanation must survive the status mapping — it is how a caller recovers") + for _, absent := range tc.wantNotInMsg { + assert.NotContains(t, decoded.Error.Message, absent, + "the REST surface must not re-disclose what the scoped form withheld (FR-012)") + } }) } diff --git a/internal/httpapi/code_scripts.go b/internal/httpapi/code_scripts.go index 8282eeaa2..d60e9df73 100644 --- a/internal/httpapi/code_scripts.go +++ b/internal/httpapi/code_scripts.go @@ -14,17 +14,33 @@ type CodeScriptsResponse struct { Dir string `json:"dir"` } +// scriptsListingDenialMessage is the body an agent token receives from the +// stored-script listing. It names nothing about the directory. +const scriptsListingDenialMessage = "Agent tokens cannot list stored scripts (the stored-script listing is available to administrators only)" + // handleListScripts godoc // @Summary List stored code-execution scripts -// @Description List the stored scripts available to the code_execution tool. Scripts are `.js` / `.ts` files in the `scripts/` directory next to the active configuration file. Entries are advisory: `ok` scripts are invocable, `ambiguous` names have both extensions, and `invalid` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. +// @Description List the stored scripts available to the code_execution tool. Scripts are `.js` / `.ts` files in the `scripts/` directory next to the active configuration file. Entries are advisory: `ok` scripts are invocable, `ambiguous` names have both extensions, and `invalid` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller. // @Tags code // @Produce json // @Security ApiKeyAuth // @Security ApiKeyQuery // @Success 200 {object} contracts.SuccessResponse "Stored scripts and the directory they were read from" +// @Failure 403 {object} contracts.ErrorResponse "Agent tokens cannot list stored scripts" // @Failure 500 {object} contracts.ErrorResponse "Internal server error" // @Router /api/v1/code/scripts [get] func (s *Server) handleListScripts(w http.ResponseWriter, r *http.Request) { + // Spec 105 FR-012: the listing — names, count, paths AND the directory — + // is exactly what the missing-script refusal withholds from a scoped + // caller, so it is administrator-only here too. requireAdminRead keys on + // the same predicate (!IsAdmin) the MCP refusal uses, so the two doors + // share one definition of "administrator": the admin API key, the tray + // over the socket and an absent context pass; every agent token is + // refused before the directory is read. + if !s.requireAdminRead(w, r, scriptsListingDenialMessage) { + return + } + // The scripts directory follows the ACTIVE config file, the same authority // the code_execution handler resolves against — a listing that disagreed // with what executes would be worse than no listing at all. diff --git a/internal/httpapi/code_scripts_test.go b/internal/httpapi/code_scripts_test.go index c20c4f54c..00c33861a 100644 --- a/internal/httpapi/code_scripts_test.go +++ b/internal/httpapi/code_scripts_test.go @@ -139,3 +139,36 @@ func TestHandleListScripts_RequiresAPIKey(t *testing.T) { recorder := getCodeScripts(t, srv, "") assert.Equal(t, http.StatusUnauthorized, recorder.Code, "body: %s", recorder.Body.String()) } + +// TestHandleListScripts_AgentTokenForbidden (Spec 105 FR-012, critique r1 #1): +// the listing is the enumeration the missing-script refusal withholds from a +// scoped caller, so it must be administrator-only on the REST surface too — +// otherwise `GET /api/v1/code/scripts` is the oracle a failed call no longer +// is. The unrestricted ["*"] token is the strongest cell: the caller KIND +// decides, never its server scope. The admin API key keeps the listing +// (SC-005); the socket/tray and nil-context callers share requireAdminRead's +// one definition of "not an administrator". +func TestHandleListScripts_AgentTokenForbidden(t *testing.T) { + const sentinel = "alpha-SENTINEL" + ctrl := &codeScriptsController{apiKey: "admin-secret", configPath: filepath.Join(t.TempDir(), "mcp_config.json")} + scriptsDir := codescripts.DirFor(ctrl.configPath) + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, sentinel+".js"), []byte("1"), 0o600)) + + srv, agentToken := agentTokenServer(t, ctrl) + + t.Run("agent token is refused without the listing", func(t *testing.T) { + recorder := getCodeScripts(t, srv, agentToken) + assert.Equal(t, http.StatusForbidden, recorder.Code, "body: %s", recorder.Body.String()) + body := recorder.Body.String() + assert.NotContains(t, body, sentinel, "an agent-token caller must not learn stored script names (FR-012)") + assert.NotContains(t, body, scriptsDir, "an agent-token caller must not learn the scripts directory (FR-012)") + }) + + t.Run("administrator control keeps the listing", func(t *testing.T) { + recorder := getCodeScripts(t, srv, ctrl.apiKey) + require.Equal(t, http.StatusOK, recorder.Code, "body: %s", recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), sentinel) + assert.Contains(t, recorder.Body.String(), scriptsDir) + }) +} diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index ffa880dcc..394f68c4f 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -497,9 +497,8 @@ func (p *MCPProxyServer) resolveCodeExecutionSource(ctx context.Context, args ma return code, "", "" } - source, language, err := codescripts.Resolve(p.scriptsDir(), scriptName, options.Language) + source, language, err := p.resolveStoredScript(ctx, scriptName, options.Language) if err != nil { - err = p.scopeStoredScriptRefusal(ctx, scriptName, err) // Keep the typed identity reachable for the REST surface (404 for a // name that is not there, 400 for one that cannot run) — the text alone // would force it to classify these by prose. @@ -510,29 +509,32 @@ func (p *MCPProxyServer) resolveCodeExecutionSource(ctx context.Context, args ma return string(source), scriptName, "" } -// scopeStoredScriptRefusal applies the Spec 105 FR-012 caller-kind rule to a -// stored-script resolution failure. The Spec 097 FR-004 not-found error -// enumerates the stored names and their count so an administrator recovers -// the set from one failed call; for a scoped caller (an agent token, whatever -// its server scope — the caller KIND decides, never AllowedServers) that -// listing is withheld and the refusal is made independent of the directory's -// contents, so a failed call is not an oracle for what is stored. Every other -// refusal (invalid name, ambiguous, unreadable, language mismatch) already -// speaks only about the caller's own request and passes through unchanged. -// An absent auth context (in-process caller) or an administrator keeps the +// resolveStoredScript applies the Spec 105 FR-012 caller-kind rule to +// stored-script resolution. The Spec 097 FR-004 not-found error enumerates +// the stored names and their count so an administrator recovers the set from +// one failed call, and its sibling refusals (ambiguous, unusable, unreadable) +// name the host path they are about; for a scoped caller (an agent token, +// whatever its server scope — the caller KIND decides, never AllowedServers) +// the listing is never even computed and every refusal is the non-disclosing +// form (codescripts.ResolveScoped): the caller's own name and the reason, +// independent of the directory's contents and location, so a failed call is +// not an oracle for what is stored or where. An absent auth context +// (in-process caller) or an administrator — including the anonymous, +// admin-shaped /mcp caller under require_mcp_auth=false — keeps the // enumeration (SC-005: the named FR-012 admin exception). -func (p *MCPProxyServer) scopeStoredScriptRefusal(ctx context.Context, scriptName string, err error) error { +func (p *MCPProxyServer) resolveStoredScript(ctx context.Context, scriptName, explicitLanguage string) ([]byte, string, error) { if !auth.IsScopedCaller(ctx) { - return err + return codescripts.Resolve(p.scriptsDir(), scriptName, explicitLanguage) } - var notFound *codescripts.NotFoundError - if !errors.As(err, ¬Found) || notFound.Undisclosed { - return err + source, language, err := codescripts.ResolveScoped(p.scriptsDir(), scriptName, explicitLanguage) + if err != nil { + // The refusal deliberately carries no count or path; the log line + // records only that a scoped probe was refused, for the same reason. + p.logger.Debug("Stored-script refusal delivered in non-disclosing form to scoped caller (Spec 105 FR-012)", + zap.String("script", scriptName), + zap.String("refusal", fmt.Sprintf("%T", err))) } - p.logger.Debug("Withholding stored-script enumeration from scoped caller (Spec 105 FR-012)", - zap.String("script", scriptName), - zap.Int("available_total", notFound.Total)) - return notFound.NonDisclosing() + return source, language, err } // activeConfigFilePath returns the configuration FILE this server belongs to: diff --git a/internal/server/mcp_code_scripts_test.go b/internal/server/mcp_code_scripts_test.go index 69a92918f..437f1c672 100644 --- a/internal/server/mcp_code_scripts_test.go +++ b/internal/server/mcp_code_scripts_test.go @@ -32,6 +32,15 @@ import ( // returns it with the scripts directory that authority implies. func newStoredScriptProxy(t *testing.T, opts ...MCPProxyOption) (*MCPProxyServer, string) { t.Helper() + return newStoredScriptProxyCfg(t, nil, opts...) +} + +// newStoredScriptProxyCfg is newStoredScriptProxy with a hook to edit the +// config BEFORE the proxy is constructed, for fixtures that need a +// construction-time setting (mcp-go fixes WithInstructions on the server +// instance, so a post-construction edit would not reach initialize). +func newStoredScriptProxyCfg(t *testing.T, configure func(*config.Config), opts ...MCPProxyOption) (*MCPProxyServer, string) { + t.Helper() tmpDir := t.TempDir() logger := zap.NewNop() @@ -48,6 +57,9 @@ func newStoredScriptProxy(t *testing.T, opts ...MCPProxyOption) (*MCPProxyServer cfg.DataDir = tmpDir cfg.EnableCodeExecution = true cfg.CodeExecutionPoolSize = 1 + if configure != nil { + configure(cfg) + } um := upstream.NewManager(logger, cfg, sm.GetBoltDB(), secret.NewResolver(), sm) @@ -333,9 +345,7 @@ func callCodeExecutionAs(t *testing.T, ctx context.Context, proxy *MCPProxyServe // seam (initialize, then tools/call code_execution) under ctx and returns the // decoded tools/call result object — the exact bytes an HTTP caller of that // surface receives. -func callCodeExecutionOnWire(t *testing.T, ctx context.Context, srv interface { - HandleMessage(context.Context, json.RawMessage) mcp.JSONRPCMessage -}, args map[string]interface{}) (isError bool, text string) { +func callCodeExecutionOnWire(t *testing.T, ctx context.Context, srv jsonRPCHandler, args map[string]interface{}) (isError bool, text string) { t.Helper() require.NotNil(t, srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}`))) rawArgs, err := json.Marshal(args) @@ -415,9 +425,7 @@ func TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing(t *testing.T) { writeStoredScript(t, scriptsDir, "beta.ts", "1") require.NotNil(t, proxy.codeExecServer, "fixture: the /mcp/code server must exist") - for label, srv := range map[string]interface { - HandleMessage(context.Context, json.RawMessage) mcp.JSONRPCMessage - }{"code-exec": proxy.codeExecServer, "default": proxy.server} { + for label, srv := range map[string]jsonRPCHandler{"code-exec": proxy.codeExecServer, "default": proxy.server} { label, srv := label, srv t.Run(label, func(t *testing.T) { isError, text := callCodeExecutionOnWire(t, scoped, srv, map[string]interface{}{"script": "gamma"}) @@ -433,6 +441,88 @@ func TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing(t *testing.T) { }) } +// TestCodeExecution_StoredScriptSiblingRefusals_AgentTokenNonDisclosing +// (critique r1 #3): the refusals that are NOT "not found" — an ambiguous +// name, a present-but-unusable file, an unreadable directory — speak about +// the operator's filesystem (the scripts directory and full host paths), and +// that is the same class of disclosure NonDisclosing strips from the +// not-found form. A scoped caller gets the name and the reason only; the +// administrator keeps the paths (SC-005). +func TestCodeExecution_StoredScriptSiblingRefusals_AgentTokenNonDisclosing(t *testing.T) { + scoped := agentCtx([]string{"*"}, []string{auth.PermRead, auth.PermWrite, auth.PermDestructive}, "") + + cells := []struct { + name string + prepare func(t *testing.T, scriptsDir string) + reason string // a fragment of the reason the scoped caller may still see + }{ + { + name: "ambiguous name", + prepare: func(t *testing.T, scriptsDir string) { + writeStoredScript(t, scriptsDir, "dup.js", "1") + writeStoredScript(t, scriptsDir, "dup.ts", "1") + }, + reason: "ambiguous", + }, + { + name: "empty file", + prepare: func(t *testing.T, scriptsDir string) { + writeStoredScript(t, scriptsDir, "dup.js", "") + }, + reason: codescripts.ReasonEmpty, + }, + { + name: "oversized file", + prepare: func(t *testing.T, scriptsDir string) { + writeStoredScript(t, scriptsDir, "dup.js", strings.Repeat("x", codescripts.MaxSizeBytes+1)) + }, + reason: codescripts.ReasonOversized, + }, + } + for _, cell := range cells { + cell := cell + t.Run(cell.name, func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + cell.prepare(t, scriptsDir) + + result := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "dup"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "dup", "the caller's own requested name may be echoed") + assert.Contains(t, text, cell.reason, "the reason is the caller's recovery path and stays") + assert.NotContains(t, text, scriptsDir, + "an agent-token refusal must not disclose the scripts directory or a host path (FR-012): %s", text) + + admin := callCodeExecutionAs(t, adminCtx(), proxy, map[string]interface{}{"script": "dup"}) + require.True(t, admin.IsError) + assert.Contains(t, resultText(t, admin), scriptsDir, "the administrator keeps the host path (SC-005)") + }) + } + + t.Run("unreadable directory", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "dup.js", "1") + require.NoError(t, os.Chmod(scriptsDir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(scriptsDir, 0o755) }) + + result := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "dup"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, codescripts.ReasonUnreadable) + assert.NotContains(t, text, scriptsDir, + "an agent-token refusal must not disclose the scripts directory (FR-012): %s", text) + assert.NotContains(t, text, "permission denied", + "the raw OS error is withheld from an agent-token caller: %s", text) + + admin := callCodeExecutionAs(t, adminCtx(), proxy, map[string]interface{}{"script": "dup"}) + require.True(t, admin.IsError) + assert.Contains(t, resultText(t, admin), scriptsDir, "the administrator keeps the directory and the OS error (SC-005)") + }) +} + // TestCodeExecution_StoredScript_ScopedPositiveControls pins the two // documented, PUBLISHED behaviours of spec.md:116 that bound FR-012: stored // scripts are operator-published content — a scoped token may run one and @@ -453,17 +543,47 @@ func TestCodeExecution_StoredScript_ScopedPositiveControls(t *testing.T) { "a constant a stored script returns is published content, visible to a scoped caller (spec.md:116)") }) + // reachB is the stored script both nested-call cells run: it reports the + // nested call's outcome as data so the refusal can be compared byte for + // byte between a hidden and a nonexistent b. + const reachB = `var r = call_tool('b', 'private_search', {q: 'x'}); ({ok: r.ok, code: r.ok ? null : r.error.code, message: r.ok ? null : r.error.message})` + t.Run("a stored script calling b is refused at the nested call", func(t *testing.T) { + // Cell 1 — b does not exist at all. proxy, scriptsDir := newStoredScriptProxy(t) - writeStoredScript(t, scriptsDir, "reach-b.js", - `var r = call_tool('b', 'private_search', {q: 'x'}); ({ok: r.ok, code: r.ok ? null : r.error.code, message: r.ok ? null : r.error.message})`) + writeStoredScript(t, scriptsDir, "reach-b.js", reachB) result := callCodeExecutionAs(t, aOnly, proxy, map[string]interface{}{"script": "reach-b"}) require.False(t, result.IsError, "the script itself runs; only its nested call is refused: %s", resultText(t, result)) - text := resultText(t, result) - assert.Contains(t, text, `"ok":false`) - assert.Contains(t, text, `"code":"`+string(jsruntime.ErrorCodeAccessDenied)+`"`, - "the nested call must be refused by the token's server scope, before any upstream lookup (FR-009): %s", text) + nonexistent := resultText(t, result) + assert.Contains(t, nonexistent, `"ok":false`) + assert.Contains(t, nonexistent, `"code":"`+string(jsruntime.ErrorCodeAccessDenied)+`"`, + "the nested call must be refused by the token's server scope, before any upstream lookup (FR-009): %s", nonexistent) + + // Cell 2 — b EXISTS, is connected and serves private_search (the + // spec fixture's hidden server). The a-only token's refusal must be + // byte-equal to cell 1 (a hidden b is indistinguishable from a + // nonexistent one) and b must witness zero calls; the administrator + // control proves the upstream is reachable. + hidden, rt := createTestProxyWithRuntimeCfg(t, nil, func(cfg *config.Config) { + cfg.EnableCodeExecution = true + cfg.CodeExecutionPoolSize = 1 + }) + b := startCountingUpstream(t, hidden, rt, "b", readSpec("private_search")) + hiddenScripts := hidden.scriptsDir() + require.NoError(t, os.MkdirAll(hiddenScripts, 0o755)) + writeStoredScript(t, hiddenScripts, "reach-b.js", reachB) + + result = callCodeExecutionAs(t, aOnly, hidden, map[string]interface{}{"script": "reach-b"}) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, nonexistent, resultText(t, result), + "a hidden b must be refused exactly as a nonexistent b (non-disclosing refusal)") + assert.Zero(t, b.count.Load(), "the refused nested call must never reach the hidden upstream") + + admin := callCodeExecutionAs(t, adminCtx(), hidden, map[string]interface{}{"script": "reach-b"}) + require.False(t, admin.IsError, resultText(t, admin)) + assert.Contains(t, resultText(t, admin), `"ok":true`, "administrator control: the same script reaches b: %s", resultText(t, admin)) + assert.Equal(t, int64(1), b.count.Load(), "administrator control: b witnesses the call") }) } diff --git a/internal/server/mcp_instructions_scope_test.go b/internal/server/mcp_instructions_scope_test.go index 464d700a6..33278bb47 100644 --- a/internal/server/mcp_instructions_scope_test.go +++ b/internal/server/mcp_instructions_scope_test.go @@ -8,16 +8,9 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/zap" "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/cache" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/index" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/truncate" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream" ) // Spec 105 FR-012 (PR H0, spec.md:116 "Operator-published content"): custom @@ -29,37 +22,13 @@ import ( // documented behaviour so a later "scrub instructions per caller" change is // a deliberate spec decision, not drift. It holds on the merge base. -// newCustomInstructionsProxy builds a bare proxy whose config carries the -// operator's `instructions` at CONSTRUCTION time (mcp-go fixes -// WithInstructions on the server instance, so a post-construction edit would -// not reach initialize). +// newCustomInstructionsProxy builds a proxy whose config carries the +// operator's `instructions` at CONSTRUCTION time, on the shared stored-script +// fixture (mcp-go fixes WithInstructions on the server instance, so a +// post-construction edit would not reach initialize). func newCustomInstructionsProxy(t *testing.T, instructions string) *MCPProxyServer { t.Helper() - - tmpDir := t.TempDir() - logger := zap.NewNop() - - sm, err := storage.NewManager(tmpDir, logger.Sugar()) - require.NoError(t, err) - t.Cleanup(func() { sm.Close() }) - - idx, err := index.NewManager(tmpDir, logger) - require.NoError(t, err) - t.Cleanup(func() { idx.Close() }) - - cfg := config.DefaultConfig() - cfg.DataDir = tmpDir - cfg.Instructions = instructions - - um := upstream.NewManager(logger, cfg, nil, secret.NewResolver(), nil) - - cm, err := cache.NewManager(sm.GetDB(), logger) - require.NoError(t, err) - t.Cleanup(func() { cm.Close() }) - - tr := truncate.NewTruncator(0) - proxy := NewMCPProxyServer(sm, idx, um, cm, func() *truncate.Truncator { return tr }, logger, nil, false, cfg, nil) - t.Cleanup(func() { proxy.Close() }) + proxy, _ := newStoredScriptProxyCfg(t, func(cfg *config.Config) { cfg.Instructions = instructions }) return proxy } diff --git a/oas/docs.go b/oas/docs.go index 10ba71894..79c73e5c3 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -9,7 +9,7 @@ const docTemplate = `{ "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", \"edit_url\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, "info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, - "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change","preflight","prompt_get"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, + "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change","preflight","prompt_get"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot list stored scripts"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 33b9a74c2..15af2bd89 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -4175,7 +4175,9 @@ paths: to the active configuration file. Entries are advisory: `ok` scripts are invocable, `ambiguous` names have both extensions, and `invalid` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface - for stored scripts.' + for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, + whatever its server scope, is refused with 403 — the listing is the enumeration + the missing-script error withholds from a scoped caller.' responses: "200": content: @@ -4183,6 +4185,12 @@ paths: schema: $ref: '#/components/schemas/contracts.SuccessResponse' description: Stored scripts and the directory they were read from + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Agent tokens cannot list stored scripts "500": content: application/json: diff --git a/specs/105-agent-scope-hardening/tasks.md b/specs/105-agent-scope-hardening/tasks.md index 7f246c1fc..294f0e913 100644 --- a/specs/105-agent-scope-hardening/tasks.md +++ b/specs/105-agent-scope-hardening/tasks.md @@ -164,13 +164,13 @@ ### Implementation -- [x] T064 [US1] Caller-kind branch: enumeration only for non-scoped callers — `internal/server/mcp_code_execution.go:473-499` (or `internal/codescripts/codescripts.go:338-356` with a caller flag) +- [x] T064 [US1] Caller-kind branch: enumeration only for non-scoped callers — `internal/server/mcp_code_execution.go:473-499` (or `internal/codescripts/codescripts.go:338-356` with a caller flag). Critique r1: the scoped form (`codescripts.ResolveScoped`) never lists the directory and strips host paths / OS errors from the ambiguous and unusable refusals too; `GET /api/v1/code/scripts` is gated with `requireAdminRead` (403 for agent tokens) - [x] T065 [US1] Reword `internal/server/mcp_code_execution.go:52-53,73-74`; regenerate goldens with `MCPPROXY_WRITE_TOOLSLIST_GOLDENS=testdata/toolslist_goldens go test -run TestToolsListSnapshot ./internal/server/` (the variable is the OUTPUT DIRECTORY, `toolslist_snapshot_test.go:151-158`), then rerun with it unset; diff limited to `internal/server/testdata/toolslist_goldens/{default_server,retrieve_tools_mode,code_execution_mode}.json` - [x] T066 [P] [US1] Docs: enumeration is admin-only in `docs/code_execution/overview.md:379-387`, `cookbook.md:141`, `troubleshooting.md:604-613`, `api-reference.md:591`; add invariant sentence, covered-surface list (`/mcp`, `/mcp/all`, `/mcp/code`, `/mcp/call`, `/mcp/p/`, aliases), retained-effects list and custom-instructions/stored-scripts secrets warning to `docs/features/agent-tokens.md` (FR01x-G3) ### Verification -- [x] T067 [US1] Common verification; `git diff --stat -- internal/server/testdata` shows only the three goldens +- [x] T067 [US1] Common verification; `git diff --stat -- internal/server/testdata` shows only the three live goldens plus their deliberately frozen pre-105 copies (`toolslist_goldens/pre105/*.json`, byte-identical to the merge base — the baseline the golden-delta assertion diffs against) - [~] T068 [US1] Astra rounds on FR-012 + FR01x-G1…G3; quote final `VERDICT:` --- From 69f38220c4072257469c8319572578cff5b527b2 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 09:33:24 +0300 Subject: [PATCH 04/23] =?UTF-8?q?fix(scope):=20PR=20H0=20codex=20round=201?= =?UTF-8?q?=20=E2=80=94=20scoped=20script=20resolution=20never=20reads=20t?= =?UTF-8?q?he=20directory=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit candidatesFor still os.ReadDir'd and walked every entry on the way to the count-independent not-found refusal, so a scoped miss cost ~11 µs in an empty scripts directory and ~4.7 ms with 10k entries: a timing-class oracle the non-disclosing-refusal definition forbids. It now Lstat's the two constructed candidate paths and never lists; the byte-exact name rule the listing existed for on case-insensitive volumes is kept by a single-entry entryName primitive (darwin O_SYMLINK + F_GETPATH, Windows FindFirstFile, other Unix trusts the exact-name Lstat). Tests: readDir/lstat seam counters — a scoped hit or miss reads zero directories (the administrator miss exactly one), and an empty vs a 10 000-entry directory perform identical filesystem calls. Existing case-exactness tests pin the primitive (mutation-checked on APFS). Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 5 +- internal/codescripts/codescripts.go | 82 +++++++++++++--------- internal/codescripts/codescripts_test.go | 85 +++++++++++++++++++++++ internal/codescripts/entryname_darwin.go | 39 +++++++++++ internal/codescripts/entryname_other.go | 16 +++++ internal/codescripts/entryname_windows.go | 25 +++++++ 6 files changed, 216 insertions(+), 36 deletions(-) create mode 100644 internal/codescripts/entryname_darwin.go create mode 100644 internal/codescripts/entryname_other.go create mode 100644 internal/codescripts/entryname_windows.go diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index b090d9062..b9a56a4e5 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -400,8 +400,9 @@ server scope, even `--servers "*"` — must already know the script name. Its not-found error names neither the other stored scripts, nor how many there are, nor the directory, and it is byte-for-byte the same whether the directory is empty or full, so a failed call cannot be used to probe what is stored — and -the proxy does not even read the directory listing on its behalf, so the -refusal's cost does not grow with the number of stored scripts: +the proxy does not read the directory on its behalf at all — it probes the +requested name's two candidate files and nothing else — so the refusal's cost +does not grow with the number of stored scripts: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index 8da06ac13..3558e57cb 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -285,9 +285,10 @@ func DeriveLanguage(name, ext, explicitLanguage string) (string, error) { // (FR-004) and every other refusal names the host path it is about. // // Order matters: the name is validated BEFORE any filesystem call (SC-003), -// then the directory decides which candidates exist, then the surviving -// candidate is opened with the platform's no-follow idiom and read through a -// bounded reader. Exactly one open and one read per call — no cache, no re-read. +// then the two candidate paths are probed directly (never a directory +// listing), then the surviving candidate is opened with the platform's +// no-follow idiom and read through a bounded reader. Exactly one open and one +// read per call — no cache, no re-read. func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { return resolve(scriptsDir, name, explicitLanguage, true) } @@ -295,8 +296,9 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language // ResolveScoped is Resolve for a scoped (agent-token) caller — Spec 105 // FR-012. It reads the script exactly as Resolve does, but every refusal it // returns is already the non-disclosing form: a not-found error is built -// WITHOUT listing the directory (no per-entry stat for a caller that is never -// shown the result — the refusal's cost does not grow with what is stored), +// WITHOUT listing the directory — neither the discovery listing nor a +// directory read on the way to the miss; the two candidate paths are probed +// and nothing else, so the refusal's cost does not grow with what is stored — // and the ambiguous / invalid forms carry the caller's own name and the // reason but no host path and no raw OS error. Typed identities are the // same, so the REST classifier does not tell the two callers apart. @@ -395,39 +397,42 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ } // candidatesFor returns the paths of the script files backing `name`, in -// extension order (.js then .ts), by reading the directory and comparing entry -// names BYTE FOR BYTE — the same rule List applies. +// extension order (.js then .ts), by probing the two constructed paths +// directly. The cost is a fixed number of single-path calls whatever the +// directory holds: a scoped caller's refusal must not grow with the number of +// stored scripts (Spec 105 FR-012 — timing class is part of a non-disclosing +// refusal), so the directory is never listed here. // -// The obvious implementation, stat-ing the two constructed paths, delegates the -// name→file decision to the filesystem, and on the default macOS and Windows -// volumes that decision is case-insensitive. `backdoor.JS` then satisfied a -// probe for `backdoor.js` and executed, while every discovery surface — the -// listing, GET /api/v1/code/scripts, the not-found error — skipped it as an -// unknown extension; conversely `foo.js` plus `FOO.ts` were two ok listing -// entries that both refused to run as ambiguous. Reading the directory removes -// the filesystem's matching from the loop entirely, so the two agree on every -// platform. Resolve's no-follow open remains the authoritative check. +// The probe alone would delegate the name→file decision to the filesystem, and +// on the default macOS and Windows volumes that decision is case-insensitive: +// `backdoor.JS` satisfied a probe for `backdoor.js` and executed, while every +// discovery surface — the listing, GET /api/v1/code/scripts, the not-found +// error — skipped it as an unknown extension; conversely `foo.js` plus +// `FOO.ts` were two ok listing entries that both refused to run as ambiguous. +// So a path that exists is accepted only when the entry's stored spelling +// (entryName, a single-entry platform call) is byte-for-byte the requested +// one; a case-folded match is not a stored script, exactly as List decides. +// Resolve's no-follow open remains the authoritative check. func candidatesFor(scriptsDir, name string) ([]string, error) { - dirEntries, err := os.ReadDir(scriptsDir) - if err != nil { - return nil, err - } - - present := make(map[string]bool, 2) - for _, d := range dirEntries { - switch d.Name() { - case name + extJS: - present[extJS] = true - case name + extTS: - present[extTS] = true - } - } - found := make([]string, 0, 2) for _, ext := range []string{extJS, extTS} { - if present[ext] { - found = append(found, filepath.Join(scriptsDir, name+ext)) + want := name + ext + path := filepath.Join(scriptsDir, want) + if _, err := lstat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return nil, err + } + if stored, err := entryName(path); err == nil && stored != want && strings.EqualFold(stored, want) { + // The filesystem folded the case: the entry is spelled differently + // and no discovery surface reports it under this name. Only a + // case-only difference is a fold; any other answer (a hard link's + // other name, or no answer at all) leaves the Lstat verdict in + // force, and the no-follow open below still decides usability. + continue } + found = append(found, path) } return found, nil } @@ -437,6 +442,15 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { // the scoped form never invokes it. var listForNotFound = List +// readDir and lstat are the package's two directory-touching primitives, +// variables so the tests can count them: a scoped resolution must never +// enumerate the directory (readDir) and must probe a fixed number of paths +// (lstat) whatever the directory holds (Spec 105 FR-012 timing class). +var ( + readDir = os.ReadDir + lstat = os.Lstat +) + // notFoundErrorFor builds the not-found error for one caller kind: the // discovery-carrying administrator form (FR-004), or the scoped form that is // constructed without touching the directory at all (Spec 105 FR-012 — the @@ -479,7 +493,7 @@ func List(scriptsDir string) ([]Entry, error) { if scriptsDir == "" { return []Entry{}, nil } - dirEntries, err := os.ReadDir(scriptsDir) + dirEntries, err := readDir(scriptsDir) if err != nil { if errors.Is(err, fs.ErrNotExist) { return []Entry{}, nil diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index b0ceee9b2..588deb692 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -774,3 +774,88 @@ func TestResolveEmptyScriptsDirNeverTouchesCWD(t *testing.T) { t.Fatalf("empty scriptsDir must report no scripts, got %+v", nf) } } + +// countDirectoryPrimitives routes the package's two directory-touching +// primitives through counters for the duration of the test. +func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { + t.Helper() + var rd, ls int + origReadDir, origLstat := readDir, lstat + readDir = func(name string) ([]os.DirEntry, error) { + rd++ + return origReadDir(name) + } + lstat = func(name string) (os.FileInfo, error) { + ls++ + return origLstat(name) + } + t.Cleanup(func() { readDir, lstat = origReadDir, origLstat }) + return &rd, &ls +} + +// TestResolveScoped_NeverReadsTheDirectory (Spec 105 FR-012, codex r1 #1): +// a scoped resolution — hit or miss — never enumerates the scripts directory. +// Skipping the not-found LISTING is not enough: an os.ReadDir on the way to +// the refusal still costs time and allocation proportional to what is stored, +// and the spec's non-disclosing refusal is indistinguishable in timing class, +// not only in body. The administrator's miss is the one place a listing is +// paid for, and exactly once. +func TestResolveScoped_NeverReadsTheDirectory(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha-SENTINEL.js", "1") + writeScript(t, dir, "beta.ts", "1") + + readDirs, _ := countDirectoryPrimitives(t) + + _, _, err := ResolveScoped(dir, "missing", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + assert.Equal(t, 0, *readDirs, "a scoped miss must not read the directory") + + src, _, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err) + assert.Equal(t, "1", string(src)) + assert.Equal(t, 0, *readDirs, "a scoped hit must not read the directory either") + + _, _, err = Resolve(dir, "beta", "") + require.NoError(t, err) + assert.Equal(t, 0, *readDirs, "an administrator hit has no listing to pay for") + + _, _, err = Resolve(dir, "missing", "") + require.True(t, errors.As(err, ¬Found)) + assert.Equal(t, 2, notFound.Total) + assert.Equal(t, 1, *readDirs, "the administrator's miss is built from exactly one listing") +} + +// TestResolveScoped_MissCostIsIndependentOfDirectorySize pins the timing +// class directly: the same scoped miss against an empty directory and against +// one holding ten thousand unrelated scripts performs the same filesystem +// calls — a fixed number of path probes and no enumeration — so the refusal's +// latency and allocation cannot serve as a count oracle. +func TestResolveScoped_MissCostIsIndependentOfDirectorySize(t *testing.T) { + empty := t.TempDir() + crowded := t.TempDir() + for i := 0; i < 10_000; i++ { + f, err := os.Create(filepath.Join(crowded, fmt.Sprintf("script-%05d.js", i))) + require.NoError(t, err) + require.NoError(t, f.Close()) + } + + probe := func(dir string) (readDirs, lstats int) { + rd, ls := countDirectoryPrimitives(t) + _, _, err := ResolveScoped(dir, "gamma", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + return *rd, *ls + } + + emptyReadDirs, emptyLstats := probe(empty) + crowdedReadDirs, crowdedLstats := probe(crowded) + + assert.Equal(t, 0, emptyReadDirs) + assert.Equal(t, 0, crowdedReadDirs, "ten thousand entries must not be enumerated on a scoped caller's behalf") + assert.Equal(t, emptyLstats, crowdedLstats, "the number of path probes is independent of the directory's contents") + assert.Greater(t, crowdedLstats, 0, "the candidate paths are probed directly") +} diff --git a/internal/codescripts/entryname_darwin.go b/internal/codescripts/entryname_darwin.go new file mode 100644 index 000000000..9c95b36ff --- /dev/null +++ b/internal/codescripts/entryname_darwin.go @@ -0,0 +1,39 @@ +//go:build darwin + +package codescripts + +import ( + "bytes" + "os" + "path/filepath" + "syscall" + "unsafe" +) + +// entryName returns the name the filesystem actually stores for the directory +// entry at path, without following a symlink and without listing the +// directory. The default APFS/HFS+ volumes are case-insensitive but +// case-PRESERVING: a probe for `backdoor.js` opens `backdoor.JS`, and the +// on-disk spelling is what F_GETPATH on the descriptor reports. +// +// O_SYMLINK opens a symlink itself rather than its target (the no-follow +// counterpart to Lstat), so a link's own entry name is the one verified; +// O_NONBLOCK keeps a FIFO from parking the open, as in openScriptFile. +func entryName(path string) (string, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_SYMLINK|syscall.O_NONBLOCK, 0) + if err != nil { + return "", err + } + defer f.Close() + + var buf [1024]byte // MAXPATHLEN + _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, f.Fd(), syscall.F_GETPATH, uintptr(unsafe.Pointer(&buf[0]))) + if errno != 0 { + return "", errno + } + n := bytes.IndexByte(buf[:], 0) + if n < 0 { + n = len(buf) + } + return filepath.Base(string(buf[:n])), nil +} diff --git a/internal/codescripts/entryname_other.go b/internal/codescripts/entryname_other.go new file mode 100644 index 000000000..df22e3d4a --- /dev/null +++ b/internal/codescripts/entryname_other.go @@ -0,0 +1,16 @@ +//go:build !darwin && !windows + +package codescripts + +import "path/filepath" + +// entryName returns the name the filesystem stores for the directory entry at +// path. Linux and the BSDs resolve names case-sensitively on their native +// filesystems, so an entry found by an exact-name Lstat IS that name; there +// is no portable single-entry "what is this spelled" call to consult, and a +// listing is exactly what the resolver must not perform (Spec 105 FR-012). +// A case-folding mount (vfat, an ext4 casefold directory, a bind mount from +// a case-insensitive host) is outside what this probe can tell apart. +func entryName(path string) (string, error) { + return filepath.Base(path), nil +} diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go new file mode 100644 index 000000000..f771ad8b7 --- /dev/null +++ b/internal/codescripts/entryname_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package codescripts + +import "golang.org/x/sys/windows" + +// entryName returns the name the filesystem actually stores for the directory +// entry at path, without following a reparse point and without listing the +// directory. NTFS is case-insensitive but case-PRESERVING: a probe for +// `backdoor.js` finds `backdoor.JS`, and FindFirstFile on the exact path is +// the single-entry lookup that reports the stored spelling (the same call the +// standard library's filepath.EvalSymlinks uses to normalise case). +func entryName(path string) (string, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + var data windows.Win32finddata + h, err := windows.FindFirstFile(p, &data) + if err != nil { + return "", err + } + _ = windows.FindClose(h) + return windows.UTF16ToString(data.FileName[:]), nil +} From 51ce3c378804905250822c297abb43c72d58030a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 10:00:36 +0300 Subject: [PATCH 05/23] =?UTF-8?q?test(scope):=20PR=20H0=20=E2=80=94=20make?= =?UTF-8?q?=20the=20FR-012=20tests=20portable=20to=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The administrator-listing control compared the raw JSON body with the OS path (backslashes are escaped on Windows); decode data.dir instead. The unreadable-directory cell relies on chmod 0, which Windows ignores; skip it there like the root case. Co-Authored-By: Claude Opus 5 --- internal/httpapi/code_scripts_test.go | 11 ++++++++++- internal/server/mcp_code_scripts_test.go | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/httpapi/code_scripts_test.go b/internal/httpapi/code_scripts_test.go index 00c33861a..ac143463a 100644 --- a/internal/httpapi/code_scripts_test.go +++ b/internal/httpapi/code_scripts_test.go @@ -169,6 +169,15 @@ func TestHandleListScripts_AgentTokenForbidden(t *testing.T) { recorder := getCodeScripts(t, srv, ctrl.apiKey) require.Equal(t, http.StatusOK, recorder.Code, "body: %s", recorder.Body.String()) assert.Contains(t, recorder.Body.String(), sentinel) - assert.Contains(t, recorder.Body.String(), scriptsDir) + // Compare the decoded field, not the raw body: on Windows the JSON + // encoder escapes the path's backslashes, so a raw substring match on + // the OS path fails there. + var listing struct { + Data struct { + Dir string `json:"dir"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &listing)) + assert.Equal(t, scriptsDir, listing.Data.Dir, "the administrator listing keeps the scripts directory") }) } diff --git a/internal/server/mcp_code_scripts_test.go b/internal/server/mcp_code_scripts_test.go index 437f1c672..0e21fb75c 100644 --- a/internal/server/mcp_code_scripts_test.go +++ b/internal/server/mcp_code_scripts_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -500,6 +501,9 @@ func TestCodeExecution_StoredScriptSiblingRefusals_AgentTokenNonDisclosing(t *te } t.Run("unreadable directory", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod 0 does not make a directory unreadable on Windows") + } if os.Geteuid() == 0 { t.Skip("root ignores directory permissions") } From 2d0d0271aa3af5b5f6671b6f43c8f6118dcf27ae Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 11:11:52 +0300 Subject: [PATCH 06/23] =?UTF-8?q?fix(scope):=20PR=20H0=20codex=20round=202?= =?UTF-8?q?=20=E2=80=94=20administrator=20Resolve=20keeps=20its=20director?= =?UTF-8?q?y-based=20candidate=20decision=20(SC-005)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 routed both Resolve and ResolveScoped through the constant-cost Lstat probe, which changed administrator execution: a scripts directory that is searchable but not listable (0111) refused every administrator run before Spec 105 (os.ReadDir → permission denied) and executed the script after round 1. candidatesFor is restored to the origin/main directory-read implementation and remains the administrator resolver's decision; the path probe becomes probeCandidates and is used by ResolveScoped alone (FR-012 timing class). Control test TestResolve_SearchableUnreadableDirectoryIsStillUnreadableForAdmins (skipped on Windows and as root) pins the pre-105 refusal; the two case-exactness tests now run over both resolvers so the scoped probe's fold guard stays covered. Co-Authored-By: Claude Opus 5 --- internal/codescripts/codescripts.go | 102 ++++++++++++++++------- internal/codescripts/codescripts_test.go | 101 +++++++++++++++++----- 2 files changed, 154 insertions(+), 49 deletions(-) diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index 3558e57cb..97679dd9d 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -285,23 +285,28 @@ func DeriveLanguage(name, ext, explicitLanguage string) (string, error) { // (FR-004) and every other refusal names the host path it is about. // // Order matters: the name is validated BEFORE any filesystem call (SC-003), -// then the two candidate paths are probed directly (never a directory -// listing), then the surviving candidate is opened with the platform's -// no-follow idiom and read through a bounded reader. Exactly one open and one -// read per call — no cache, no re-read. +// then the directory decides which candidates exist (pre-105 behaviour, kept +// verbatim: a directory the administrator cannot list is a refusal, SC-005), +// then the surviving candidate is opened with the platform's no-follow idiom +// and read through a bounded reader. Exactly one open and one read per call — +// no cache, no re-read. func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { return resolve(scriptsDir, name, explicitLanguage, true) } // ResolveScoped is Resolve for a scoped (agent-token) caller — Spec 105 -// FR-012. It reads the script exactly as Resolve does, but every refusal it -// returns is already the non-disclosing form: a not-found error is built -// WITHOUT listing the directory — neither the discovery listing nor a -// directory read on the way to the miss; the two candidate paths are probed -// and nothing else, so the refusal's cost does not grow with what is stored — -// and the ambiguous / invalid forms carry the caller's own name and the -// reason but no host path and no raw OS error. Typed identities are the -// same, so the REST classifier does not tell the two callers apart. +// FR-012. It opens and reads the script exactly as Resolve does, but decides +// its candidates by a constant-cost path probe instead of the directory +// listing, and every refusal it returns is already the non-disclosing form: a +// not-found error is built WITHOUT listing the directory — neither the +// discovery listing nor a directory read on the way to the miss; the two +// candidate paths are probed and nothing else, so the refusal's cost does not +// grow with what is stored — and the ambiguous / invalid forms carry the +// caller's own name and the reason but no host path and no raw OS error. +// Typed identities are the same, so the REST classifier does not tell the two +// callers apart. The probe is the scoped resolver's alone: the administrator +// path keeps its directory-based decision (SC-005), so a directory that is +// searchable but not listable still refuses administrators as it always did. func ResolveScoped(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { return resolve(scriptsDir, name, explicitLanguage, false) } @@ -329,7 +334,11 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ return nil, "", notFound() } - found, err := candidatesFor(scriptsDir, name) + candidates := candidatesFor + if !disclose { + candidates = probeCandidates + } + found, err := candidates(scriptsDir, name) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, "", notFound() @@ -397,23 +406,60 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ } // candidatesFor returns the paths of the script files backing `name`, in -// extension order (.js then .ts), by probing the two constructed paths -// directly. The cost is a fixed number of single-path calls whatever the -// directory holds: a scoped caller's refusal must not grow with the number of -// stored scripts (Spec 105 FR-012 — timing class is part of a non-disclosing -// refusal), so the directory is never listed here. +// extension order (.js then .ts), by reading the directory and comparing entry +// names BYTE FOR BYTE — the same rule List applies. This is the ADMINISTRATOR +// resolver's decision, unchanged from before Spec 105 (SC-005): a directory +// the process cannot list is a refusal, whatever the constructed paths would +// have answered. // -// The probe alone would delegate the name→file decision to the filesystem, and -// on the default macOS and Windows volumes that decision is case-insensitive: -// `backdoor.JS` satisfied a probe for `backdoor.js` and executed, while every -// discovery surface — the listing, GET /api/v1/code/scripts, the not-found -// error — skipped it as an unknown extension; conversely `foo.js` plus -// `FOO.ts` were two ok listing entries that both refused to run as ambiguous. -// So a path that exists is accepted only when the entry's stored spelling -// (entryName, a single-entry platform call) is byte-for-byte the requested -// one; a case-folded match is not a stored script, exactly as List decides. -// Resolve's no-follow open remains the authoritative check. +// The obvious implementation, stat-ing the two constructed paths, delegates the +// name→file decision to the filesystem, and on the default macOS and Windows +// volumes that decision is case-insensitive. `backdoor.JS` then satisfied a +// probe for `backdoor.js` and executed, while every discovery surface — the +// listing, GET /api/v1/code/scripts, the not-found error — skipped it as an +// unknown extension; conversely `foo.js` plus `FOO.ts` were two ok listing +// entries that both refused to run as ambiguous. Reading the directory removes +// the filesystem's matching from the loop entirely, so the two agree on every +// platform. Resolve's no-follow open remains the authoritative check. func candidatesFor(scriptsDir, name string) ([]string, error) { + dirEntries, err := readDir(scriptsDir) + if err != nil { + return nil, err + } + + present := make(map[string]bool, 2) + for _, d := range dirEntries { + switch d.Name() { + case name + extJS: + present[extJS] = true + case name + extTS: + present[extTS] = true + } + } + + found := make([]string, 0, 2) + for _, ext := range []string{extJS, extTS} { + if present[ext] { + found = append(found, filepath.Join(scriptsDir, name+ext)) + } + } + return found, nil +} + +// probeCandidates is candidatesFor for the SCOPED resolver: the same two +// candidate paths, decided by probing each constructed path directly instead +// of listing the directory. The cost is a fixed number of single-path calls +// whatever the directory holds: a scoped caller's refusal must not grow with +// the number of stored scripts (Spec 105 FR-012 — timing class is part of a +// non-disclosing refusal), so the directory is never listed here. +// +// The probe alone would delegate the name→file decision to the filesystem, and +// on the default macOS and Windows volumes that decision is case-insensitive +// (see candidatesFor). So a path that exists is accepted only when the entry's +// stored spelling (entryName, a single-entry platform call) is byte-for-byte +// the requested one; a case-folded match is not a stored script, exactly as +// List decides. The no-follow open remains the authoritative check. +func probeCandidates(scriptsDir, name string) ([]string, error) { found := make([]string, 0, 2) for _, ext := range []string{extJS, extTS} { want := name + ext diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index 588deb692..2f793c96c 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -199,19 +199,36 @@ func TestResolve_ValidatesNameBeforeReadingTheDirectory(t *testing.T) { // not-found error all omit, because they compare extensions exactly. A name // that executes but no discovery surface reports is worse than no listing at // all, so the resolver has to agree with the listing on every platform. +// bothResolvers runs a case through the administrator and the scoped +// resolver: since codex r2 #1 they decide their candidates differently +// (directory read vs. constant-cost path probe), so a rule about which entry +// backs a name has to hold on each. +var bothResolvers = []struct { + name string + resolve func(scriptsDir, name, explicitLanguage string) ([]byte, string, error) +}{ + {"Resolve", Resolve}, + {"ResolveScoped", ResolveScoped}, +} + func TestResolve_ExtensionCaseIsExact(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "backdoor.JS", "({pwned: true})") writeScript(t, dir, "shouty.TS", "({pwned: true})") - for _, name := range []string{"backdoor", "shouty"} { - t.Run(name, func(t *testing.T) { - src, _, err := Resolve(dir, name, "") - require.Error(t, err, "an uppercase extension is not a stored script") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - assert.NotContains(t, string(src), "pwned") - }) + // Both resolvers decide their candidates differently (the administrator + // reads the directory, the scoped caller probes the paths), so each is + // pinned on its own. + for _, r := range bothResolvers { + for _, name := range []string{"backdoor", "shouty"} { + t.Run(r.name+"/"+name, func(t *testing.T) { + src, _, err := r.resolve(dir, name, "") + require.Error(t, err, "an uppercase extension is not a stored script") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.NotContains(t, string(src), "pwned") + }) + } } entries, err := List(dir) @@ -228,15 +245,19 @@ func TestResolve_CaseDistinctNamesAreDistinctScripts(t *testing.T) { writeScript(t, dir, "foo.js", "({from: 'js'})") writeScript(t, dir, "FOO.ts", "({from: 'ts'})") - src, lang, err := Resolve(dir, "foo", "") - require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") - assert.Equal(t, "({from: 'js'})", string(src)) - assert.Equal(t, LanguageJavaScript, lang) - - src, lang, err = Resolve(dir, "FOO", "") - require.NoError(t, err, "FOO.ts is the only exact-cased match for \"FOO\"") - assert.Equal(t, "({from: 'ts'})", string(src)) - assert.Equal(t, LanguageTypeScript, lang) + for _, r := range bothResolvers { + t.Run(r.name, func(t *testing.T) { + src, lang, err := r.resolve(dir, "foo", "") + require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") + assert.Equal(t, "({from: 'js'})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + + src, lang, err = r.resolve(dir, "FOO", "") + require.NoError(t, err, "FOO.ts is the only exact-cased match for \"FOO\"") + assert.Equal(t, "({from: 'ts'})", string(src)) + assert.Equal(t, LanguageTypeScript, lang) + }) + } entries, err := List(dir) require.NoError(t, err) @@ -536,6 +557,43 @@ func TestResolve_EmptyAndOversized(t *testing.T) { }) } +// TestResolve_SearchableUnreadableDirectoryIsStillUnreadableForAdmins (Spec +// 105 SC-005, codex r2 #1) is the administrator-parity control: a scripts +// directory that is searchable but not listable (0111) refused every +// administrator run before Spec 105 — the directory read that decided the +// candidates returned permission denied, and that was the verdict. The scoped +// resolver's constant-cost path probe must not leak into the administrator +// path and turn that refusal into an execution, so Resolve keeps deciding its +// candidates from the directory listing exactly as it did on origin/main. +func TestResolve_SearchableUnreadableDirectoryIsStillUnreadableForAdmins(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions are not enforced") + } + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not enforced on Windows") + } + + scriptsDir := filepath.Join(t.TempDir(), "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + writeScript(t, scriptsDir, "known.js", "1") + require.NoError(t, os.Chmod(scriptsDir, 0o111)) + t.Cleanup(func() { _ = os.Chmod(scriptsDir, 0o755) }) + + // Control for the control: the file itself IS reachable through the + // searchable directory, so a refusal below is the directory's doing. + direct, err := os.ReadFile(filepath.Join(scriptsDir, "known.js")) + require.NoError(t, err) + require.Equal(t, "1", string(direct)) + + src, _, err := Resolve(scriptsDir, "known", "") + require.Nil(t, src, "an administrator must not execute out of a directory it cannot list") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonUnreadable, invalid.Reason) + assert.Equal(t, scriptsDir, invalid.Path, "the administrator's refusal names the directory, as before") + assert.Contains(t, err.Error(), "permission denied", "the administrator keeps the OS error") +} + func TestResolve_Unreadable(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: permissions are not enforced") @@ -798,8 +856,9 @@ func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { // Skipping the not-found LISTING is not enough: an os.ReadDir on the way to // the refusal still costs time and allocation proportional to what is stored, // and the spec's non-disclosing refusal is indistinguishable in timing class, -// not only in body. The administrator's miss is the one place a listing is -// paid for, and exactly once. +// not only in body. The administrator keeps the pre-105 directory-based +// decision (SC-005, codex r2 #1): one directory read decides the candidates +// on every call, and a miss pays for the discovery listing on top. func TestResolveScoped_NeverReadsTheDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha-SENTINEL.js", "1") @@ -820,12 +879,12 @@ func TestResolveScoped_NeverReadsTheDirectory(t *testing.T) { _, _, err = Resolve(dir, "beta", "") require.NoError(t, err) - assert.Equal(t, 0, *readDirs, "an administrator hit has no listing to pay for") + assert.Equal(t, 1, *readDirs, "the administrator's candidates are decided by one directory read, as before Spec 105") _, _, err = Resolve(dir, "missing", "") require.True(t, errors.As(err, ¬Found)) assert.Equal(t, 2, notFound.Total) - assert.Equal(t, 1, *readDirs, "the administrator's miss is built from exactly one listing") + assert.Equal(t, 3, *readDirs, "the administrator's miss adds exactly one listing to its candidate read") } // TestResolveScoped_MissCostIsIndependentOfDirectorySize pins the timing From 39cd1d050646db47f94992e51afdcb84061cad05 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 15:07:44 +0300 Subject: [PATCH 07/23] =?UTF-8?q?fix(scope):=20PR=20H0=20codex=20round=203?= =?UTF-8?q?=20=E2=80=94=20scoped=20resolution=20fails=20closed=20on=20a=20?= =?UTF-8?q?Linux=20case-folding=20mount=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux/BSD entryName returned the caller-constructed basename, so the scoped probe's exact-spelling check could never fire there: on ext4 casefold, vfat, or a bind mount from a case-insensitive host (a Docker Desktop mount of ~/.mcpproxy into mcpproxy-server), Lstat("backdoor.js") finds a stored `backdoor.JS` that List and the administrator's Resolve reject, and an agent-token `script:"backdoor"` executed it. Reproduced on real Linux in Docker against an APFS bind mount. Linux has no single-entry call that reports how an entry is spelled on disk — readlink of /proc/self/fd/N echoes the spelling that was looked up — and the only exact answer is the directory listing the scoped resolver must not perform. So the fold is proven instead: foldsCase Lstats the same name with every ASCII letter's case swapped (one extra constant-cost probe; names and extensions are ASCII), and the same entry answering under both spellings means the lookup folds. The Linux/BSD entryName then reports errSpellingUnverifiable and probeCandidates refuses the candidate with the ordinary non-disclosing not-found — fail closed rather than execute a file no discovery surface reports. darwin (F_GETPATH) and Windows (FindFirstFile) keep their exact-spelling checks; the administrator's directory-based decision is untouched (SC-005). Tests: TestFoldsCase (every platform) and the Linux-tagged TestResolveScoped_FailsClosedOnAFoldingDirectory / TestEntryName_ExactOnACaseSensitiveLookup, simulating the folding lookup through the lstat seam; the case-distinct test gains the fail-closed scoped branch and three scoped tests skip on such a mount. Verified in Docker natively with -race and with TMPDIR on the folding bind mount. Operator note in docs/code_execution/troubleshooting.md. Co-Authored-By: Claude Opus 5 --- docs/code_execution/troubleshooting.md | 11 ++ internal/codescripts/codescripts.go | 81 +++++++++- internal/codescripts/codescripts_test.go | 161 +++++++++++++++++++ internal/codescripts/entryname_darwin.go | 7 +- internal/codescripts/entryname_other.go | 33 +++- internal/codescripts/entryname_other_test.go | 96 +++++++++++ internal/codescripts/entryname_windows.go | 12 +- 7 files changed, 381 insertions(+), 20 deletions(-) create mode 100644 internal/codescripts/entryname_other_test.go diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index 21fcb98ae..cd9e57dc3 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -638,6 +638,17 @@ mcpproxy code scripts list --config /etc/mcpproxy/mcp_config.json # a non-defa If the directory in the message is not the one you authored in, start the daemon with the config file you meant (`mcpproxy serve --config …`) — with `~/.mcpproxy/mcp_config.json` the scripts live in `~/.mcpproxy/scripts/`. + +**Agent tokens only, on Linux, with the scripts directory on a case-folding +mount** (a Docker Desktop bind mount from a macOS or Windows host, vfat, an +ext4 `casefold` directory): the administrator runs the script but every +agent-token call reports it not found. An agent-token resolution never lists +the directory (that is what keeps a failed call from serving as an oracle), +and Linux has no single-entry call that reports how a name is spelled on disk, +so on a mount where `fetch-pr.js` and `FETCH-PR.JS` are the same entry the +daemon cannot prove it is running the file the listing reports — it refuses +rather than guess. Keep the scripts directory on a native, case-sensitive +filesystem (in Docker, a named volume rather than a host bind mount). mcpproxy never creates the directory itself; `mkdir -p` it. --- diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index 97679dd9d..4289c8112 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -458,24 +458,41 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { // (see candidatesFor). So a path that exists is accepted only when the entry's // stored spelling (entryName, a single-entry platform call) is byte-for-byte // the requested one; a case-folded match is not a stored script, exactly as -// List decides. The no-follow open remains the authoritative check. +// List decides. Where the platform can only prove that the directory folds +// case but not what the entry is called (Linux and the BSDs on ext4 casefold, +// vfat, or a bind mount from a case-insensitive host — codex r3 #1), the +// candidate is refused: fail closed rather than execute a file the listing +// does not report. The no-follow open remains the authoritative check. func probeCandidates(scriptsDir, name string) ([]string, error) { found := make([]string, 0, 2) for _, ext := range []string{extJS, extTS} { want := name + ext path := filepath.Join(scriptsDir, want) - if _, err := lstat(path); err != nil { + probed, err := lstat(path) + if err != nil { if errors.Is(err, fs.ErrNotExist) { continue } return nil, err } - if stored, err := entryName(path); err == nil && stored != want && strings.EqualFold(stored, want) { + stored, err := entryName(path, probed) + switch { + case errors.Is(err, errSpellingUnverifiable): + // The directory folds case and this platform has no single-entry + // call that reports the stored spelling (Linux, the BSDs): the + // probe cannot tell `backdoor.js` from `backdoor.JS`, so the + // candidate is not a stored script to a scoped caller — fail + // closed. The administrator's directory read still decides + // exactly, so the script keeps working for administrators. + continue + case err != nil: + // The platform call failed for another reason: the Lstat verdict + // stays in force and the no-follow open below decides usability. + case stored != want && strings.EqualFold(stored, want): // The filesystem folded the case: the entry is spelled differently // and no discovery surface reports it under this name. Only a // case-only difference is a fold; any other answer (a hard link's - // other name, or no answer at all) leaves the Lstat verdict in - // force, and the no-follow open below still decides usability. + // other name) leaves the Lstat verdict in force. continue } found = append(found, path) @@ -497,6 +514,60 @@ var ( lstat = os.Lstat ) +// errSpellingUnverifiable is entryName's answer on a platform that has no +// single-entry call reporting an entry's stored spelling (Linux, the BSDs) +// when the directory demonstrably folds case: the entry the probe found may be +// spelled `backdoor.JS`, which no discovery surface reports as a stored script, +// and the only way to find out is the directory listing the scoped resolver +// must not perform (Spec 105 FR-012). The scoped resolver fails closed on it. +var errSpellingUnverifiable = errors.New("the directory folds case and the entry's stored spelling cannot be verified without listing it") + +// foldsCase reports whether the directory entry at path, whose Lstat result is +// probed, is also reachable under a different spelling of its own name — that +// is, whether the filesystem folds case for lookups in that directory. It costs +// exactly one extra Lstat (the constant-cost class Spec 105 FR-012 requires): +// the same name with every ASCII letter's case swapped either does not exist +// (the lookup is case-sensitive, so the exact-name Lstat found the exact name), +// names a different entry (likewise), or is the same entry, which only a +// case-folding lookup — or a hard link under the swapped spelling, which the +// scoped resolver may equally refuse — can produce. Script names and +// extensions are ASCII (ValidateName), so ASCII case is the whole fold set. +// +// It cannot be replaced by a readlink of /proc/self/fd/N: the Linux dentry is +// named as looked up, not as stored (ext4 casefold, vfat and bind mounts from +// case-insensitive hosts all echo the caller's spelling back). +func foldsCase(path string, probed fs.FileInfo) (bool, error) { + base := filepath.Base(path) + variant := swapASCIICase(base) + if variant == base { + // Nothing to fold: no other spelling of this name exists. + return false, nil + } + other, err := lstat(filepath.Join(filepath.Dir(path), variant)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return os.SameFile(probed, other), nil +} + +// swapASCIICase flips the case of every ASCII letter in s and leaves every +// other byte alone. +func swapASCIICase(s string) string { + b := []byte(s) + for i, c := range b { + switch { + case 'a' <= c && c <= 'z': + b[i] = c - 'a' + 'A' + case 'A' <= c && c <= 'Z': + b[i] = c - 'A' + 'a' + } + } + return string(b) +} + // notFoundErrorFor builds the not-found error for one caller kind: the // discovery-carrying administrator form (FR-004), or the scoped form that is // constructed without touching the directory at all (Spec 105 FR-012 — the diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index 2f793c96c..cc49cf988 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -3,6 +3,7 @@ package codescripts import ( "errors" "fmt" + "io/fs" "os" "path/filepath" "runtime" @@ -247,6 +248,22 @@ func TestResolve_CaseDistinctNamesAreDistinctScripts(t *testing.T) { for _, r := range bothResolvers { t.Run(r.name, func(t *testing.T) { + if r.name == "ResolveScoped" && scopedSpellingUnverifiable(t, filepath.Join(dir, "foo.js")) { + // This directory folds case and the platform has no + // stored-spelling call (Linux on ext4 casefold / vfat / a + // case-insensitive bind mount): the scoped resolver cannot + // prove which of the two entries backs either name, so it + // refuses both — fail closed (codex r3 #1) — with the ordinary + // non-disclosing not-found; the administrator branch below + // still resolves each from the directory read. + for _, name := range []string{"foo", "FOO"} { + _, _, err := r.resolve(dir, name, "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + } + return + } src, lang, err := r.resolve(dir, "foo", "") require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") assert.Equal(t, "({from: 'js'})", string(src)) @@ -455,6 +472,7 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "dup.js", "1") writeScript(t, dir, "dup.ts", "1") + requireScopedSpellingVerifiable(t, filepath.Join(dir, "dup.js")) _, _, err := ResolveScoped(dir, "dup", "") var ambiguous *AmbiguousError @@ -481,6 +499,7 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { t.Run(cell.name, func(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "bad.js", cell.content) + requireScopedSpellingVerifiable(t, filepath.Join(dir, "bad.js")) _, _, err := ResolveScoped(dir, "bad", "") var invalid *InvalidError @@ -851,6 +870,147 @@ func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { return &rd, &ls } +// simulateCaseFoldingLstat makes the package's lstat seam behave like a +// case-insensitive, case-preserving directory lookup (APFS, NTFS, ext4 +// casefold, vfat): a path that does not exist as spelled resolves to the +// entry whose name matches it case-insensitively. The listing it consults is +// the simulation's own, invisible to the readDir seam. Installed BEFORE +// countDirectoryPrimitives when both are used, so the counters see the +// resolver's calls and not the simulation's. +func simulateCaseFoldingLstat(t *testing.T) { + t.Helper() + orig := lstat + lstat = func(name string) (os.FileInfo, error) { + info, err := orig(name) + if err == nil || !errors.Is(err, fs.ErrNotExist) { + return info, err + } + entries, readErr := os.ReadDir(filepath.Dir(name)) + if readErr != nil { + return nil, err + } + for _, e := range entries { + if strings.EqualFold(e.Name(), filepath.Base(name)) { + return orig(filepath.Join(filepath.Dir(name), e.Name())) + } + } + return nil, err + } + t.Cleanup(func() { lstat = orig }) +} + +// requireScopedSpellingVerifiable skips a test whose scoped-resolver +// expectations need a resolvable candidate when this directory folds case on a +// platform without a stored-spelling call: there every scoped candidate is +// refused, fail closed (codex r3 #1), which TestResolveScoped_FailsClosedOn +// AFoldingDirectory pins on its own. +func requireScopedSpellingVerifiable(t *testing.T, path string) { + t.Helper() + if scopedSpellingUnverifiable(t, path) { + t.Skipf("%s: the directory folds case and this platform cannot verify the stored spelling; scoped resolution fails closed here", filepath.Dir(path)) + } +} + +// scopedSpellingUnverifiable reports whether, on this platform and for this +// existing entry, the scoped resolver cannot verify the stored spelling and +// therefore refuses the candidate (Linux on a case-folding mount). +func scopedSpellingUnverifiable(t *testing.T, path string) bool { + t.Helper() + info, err := os.Lstat(path) + require.NoError(t, err) + _, err = entryName(path, info) + return errors.Is(err, errSpellingUnverifiable) +} + +// TestFoldsCase pins the constant-cost fold proof the Linux entryName relies +// on (codex r3 #1): one extra Lstat of the case-swapped spelling, and only the +// SAME entry answering under both spellings counts as a fold. +func TestFoldsCase(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "exact.js", "1") + writeScript(t, dir, "digits.js", "1") + path := filepath.Join(dir, "exact.js") + info, err := os.Lstat(path) + require.NoError(t, err) + + t.Run("real directory", func(t *testing.T) { + // Independent oracle: is a differently spelled sibling name reachable? + _, err := os.Lstat(filepath.Join(dir, "DIGITS.js")) + dirFolds := err == nil + + folds, err := foldsCase(path, info) + require.NoError(t, err) + assert.Equal(t, dirFolds, folds) + }) + + t.Run("simulated folding lookup", func(t *testing.T) { + simulateCaseFoldingLstat(t) + _, lstats := countDirectoryPrimitives(t) + folds, err := foldsCase(path, info) + require.NoError(t, err) + assert.True(t, folds, "the swapped spelling reaches the same entry") + assert.Equal(t, 1, *lstats, "exactly one extra probe") + }) + + t.Run("case-sensitive lookup, variant absent", func(t *testing.T) { + orig := lstat + lstat = func(name string) (os.FileInfo, error) { + if filepath.Base(name) == "EXACT.JS" { + return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist} + } + return orig(name) + } + t.Cleanup(func() { lstat = orig }) + folds, err := foldsCase(path, info) + require.NoError(t, err) + assert.False(t, folds) + }) + + t.Run("case-sensitive lookup, variant is a different entry", func(t *testing.T) { + other := filepath.Join(dir, "digits.js") + orig := lstat + lstat = func(name string) (os.FileInfo, error) { + if filepath.Base(name) == "EXACT.JS" { + return orig(other) + } + return orig(name) + } + t.Cleanup(func() { lstat = orig }) + folds, err := foldsCase(path, info) + require.NoError(t, err) + assert.False(t, folds, "two distinct entries under two spellings is a case-sensitive directory") + }) + + t.Run("a name with no letters has no other spelling", func(t *testing.T) { + p := filepath.Join(dir, "123") + writeScript(t, dir, "123", "1") + i, err := os.Lstat(p) + require.NoError(t, err) + _, lstats := countDirectoryPrimitives(t) + folds, err := foldsCase(p, i) + require.NoError(t, err) + assert.False(t, folds) + assert.Equal(t, 0, *lstats, "nothing to probe") + }) + + t.Run("a failing variant probe is reported, not swallowed", func(t *testing.T) { + orig := lstat + boom := errors.New("boom") + lstat = func(name string) (os.FileInfo, error) { + if filepath.Base(name) == "EXACT.JS" { + return nil, boom + } + return orig(name) + } + t.Cleanup(func() { lstat = orig }) + _, err := foldsCase(path, info) + assert.ErrorIs(t, err, boom) + }) + + assert.Equal(t, "BACKDOOR.js", swapASCIICase("backdoor.JS")) + assert.Equal(t, "fetch-PRS_2.TS", swapASCIICase("FETCH-prs_2.ts")) +} + // TestResolveScoped_NeverReadsTheDirectory (Spec 105 FR-012, codex r1 #1): // a scoped resolution — hit or miss — never enumerates the scripts directory. // Skipping the not-found LISTING is not enough: an os.ReadDir on the way to @@ -863,6 +1023,7 @@ func TestResolveScoped_NeverReadsTheDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha-SENTINEL.js", "1") writeScript(t, dir, "beta.ts", "1") + requireScopedSpellingVerifiable(t, filepath.Join(dir, "beta.ts")) readDirs, _ := countDirectoryPrimitives(t) diff --git a/internal/codescripts/entryname_darwin.go b/internal/codescripts/entryname_darwin.go index 9c95b36ff..f0ff20c11 100644 --- a/internal/codescripts/entryname_darwin.go +++ b/internal/codescripts/entryname_darwin.go @@ -4,6 +4,7 @@ package codescripts import ( "bytes" + "io/fs" "os" "path/filepath" "syscall" @@ -11,15 +12,15 @@ import ( ) // entryName returns the name the filesystem actually stores for the directory -// entry at path, without following a symlink and without listing the -// directory. The default APFS/HFS+ volumes are case-insensitive but +// entry at path (probed is its Lstat result, unused here), without following +// a symlink and without listing the directory. The default APFS/HFS+ volumes are case-insensitive but // case-PRESERVING: a probe for `backdoor.js` opens `backdoor.JS`, and the // on-disk spelling is what F_GETPATH on the descriptor reports. // // O_SYMLINK opens a symlink itself rather than its target (the no-follow // counterpart to Lstat), so a link's own entry name is the one verified; // O_NONBLOCK keeps a FIFO from parking the open, as in openScriptFile. -func entryName(path string) (string, error) { +func entryName(path string, _ fs.FileInfo) (string, error) { f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_SYMLINK|syscall.O_NONBLOCK, 0) if err != nil { return "", err diff --git a/internal/codescripts/entryname_other.go b/internal/codescripts/entryname_other.go index df22e3d4a..ebc9994e3 100644 --- a/internal/codescripts/entryname_other.go +++ b/internal/codescripts/entryname_other.go @@ -2,15 +2,32 @@ package codescripts -import "path/filepath" +import ( + "io/fs" + "path/filepath" +) // entryName returns the name the filesystem stores for the directory entry at -// path. Linux and the BSDs resolve names case-sensitively on their native -// filesystems, so an entry found by an exact-name Lstat IS that name; there -// is no portable single-entry "what is this spelled" call to consult, and a -// listing is exactly what the resolver must not perform (Spec 105 FR-012). -// A case-folding mount (vfat, an ext4 casefold directory, a bind mount from -// a case-insensitive host) is outside what this probe can tell apart. -func entryName(path string) (string, error) { +// path, whose Lstat result is probed. Linux and the BSDs resolve names +// case-sensitively on their native filesystems, so an entry found by an +// exact-name Lstat IS that name — but a case-folding mount (vfat, an ext4 +// casefold directory, a bind mount from a case-insensitive host) finds +// `backdoor.JS` for `backdoor.js` just as APFS and NTFS do, and unlike those it +// offers no single-entry "what is this spelled" call: F_GETPATH does not exist, +// and a readlink of /proc/self/fd/N echoes the spelling that was looked up, +// not the one on disk. The only exact answer is the directory listing the +// scoped resolver must not perform (Spec 105 FR-012), so the fold is proven +// with one constant-cost probe (foldsCase) and reported as unverifiable; the +// scoped resolver then refuses the candidate — fail closed. +func entryName(path string, probed fs.FileInfo) (string, error) { + folds, err := foldsCase(path, probed) + if err != nil { + // The variant probe failed for a reason other than absence: nothing + // proves the spelling, so the answer is the same fail-closed one. + return "", errSpellingUnverifiable + } + if folds { + return "", errSpellingUnverifiable + } return filepath.Base(path), nil } diff --git a/internal/codescripts/entryname_other_test.go b/internal/codescripts/entryname_other_test.go new file mode 100644 index 000000000..ae410da44 --- /dev/null +++ b/internal/codescripts/entryname_other_test.go @@ -0,0 +1,96 @@ +//go:build !darwin && !windows + +package codescripts + +import ( + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolveScoped_FailsClosedOnAFoldingDirectory (Spec 105 FR-012, codex r3 +// #1): Linux has no single-entry call that reports an entry's stored spelling, +// so on a case-folding mount (ext4 casefold, vfat, a bind mount from a +// case-insensitive host) the scoped probe for `backdoor.js` finds +// `backdoor.JS` — a file the listing and the administrator's Resolve reject — +// and, before this fix, executed it. The fold is now proven with one extra +// constant-cost probe and the candidate refused: a scoped caller cannot +// execute what no discovery surface reports, and the refusal is the ordinary +// non-disclosing not-found. The folding lookup is simulated through the +// package's lstat seam so the rule is pinned on the case-sensitive filesystems +// CI runs on; the same test against a real folding mount (TMPDIR on a Docker +// Desktop bind mount of an APFS directory) exercises the kernel's own fold. +func TestResolveScoped_FailsClosedOnAFoldingDirectory(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "backdoor.JS", "({pwned: true})") + writeScript(t, dir, "exact.js", "({exact: true})") + simulateCaseFoldingLstat(t) + + // On a case-sensitive filesystem this first case is refused even without + // the fold proof (the real open of `backdoor.js` misses); it bites on a + // real folding mount. The second case is the one the simulation pins on + // every host: neutering the Linux entryName fails it. + t.Run("folded spelling is not a stored script", func(t *testing.T) { + readDirs, lstats := countDirectoryPrimitives(t) + src, _, err := ResolveScoped(dir, "backdoor", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") + assert.NotContains(t, string(src), "pwned") + assert.Equal(t, 0, *readDirs, "proving the fold must not list the directory") + assert.LessOrEqual(t, *lstats, 4, "at most one extra probe per candidate: constant cost") + + // The administrator's directory read agrees: byte-for-byte, .JS is + // not an extension of a stored script. + _, _, err = Resolve(dir, "backdoor", "") + require.True(t, errors.As(err, ¬Found)) + assert.False(t, notFound.Undisclosed) + }) + + t.Run("an exactly spelled script on a folding mount is refused to scoped callers, fail closed", func(t *testing.T) { + // Without a stored-spelling call the probe cannot tell this case from + // the one above, so it must refuse both; the administrator, whose + // candidates come from the directory read, still runs it. + _, _, err := ResolveScoped(dir, "exact", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + + src, lang, err := Resolve(dir, "exact", "") + require.NoError(t, err) + assert.Equal(t, "({exact: true})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + }) + + t.Run("entryName reports the fold as unverifiable", func(t *testing.T) { + path := dir + "/exact.js" + info, err := lstat(path) + require.NoError(t, err) + _, err = entryName(path, info) + assert.ErrorIs(t, err, errSpellingUnverifiable) + }) +} + +// TestEntryName_ExactOnACaseSensitiveLookup is the other half: where the +// lookup does not fold (every native Linux filesystem), an entry found by its +// exact name IS that name and the scoped resolver keeps working. +func TestEntryName_ExactOnACaseSensitiveLookup(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "exact.js", "1") + path := dir + "/exact.js" + info, err := os.Lstat(path) + require.NoError(t, err) + if folds, _ := foldsCase(path, info); folds { + t.Skip("this temp directory folds case; the fail-closed rule is pinned by TestResolveScoped_FailsClosedOnAFoldingDirectory") + } + stored, err := entryName(path, info) + require.NoError(t, err) + assert.Equal(t, "exact.js", stored) + + src, _, err := ResolveScoped(dir, "exact", "") + require.NoError(t, err) + assert.Equal(t, "1", string(src)) +} diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go index f771ad8b7..2642eb59a 100644 --- a/internal/codescripts/entryname_windows.go +++ b/internal/codescripts/entryname_windows.go @@ -2,15 +2,19 @@ package codescripts -import "golang.org/x/sys/windows" +import ( + "io/fs" + + "golang.org/x/sys/windows" +) // entryName returns the name the filesystem actually stores for the directory -// entry at path, without following a reparse point and without listing the -// directory. NTFS is case-insensitive but case-PRESERVING: a probe for +// entry at path (probed is its Lstat result, unused here), without following +// a reparse point and without listing the directory. NTFS is case-insensitive but case-PRESERVING: a probe for // `backdoor.js` finds `backdoor.JS`, and FindFirstFile on the exact path is // the single-entry lookup that reports the stored spelling (the same call the // standard library's filepath.EvalSymlinks uses to normalise case). -func entryName(path string) (string, error) { +func entryName(path string, _ fs.FileInfo) (string, error) { p, err := windows.UTF16PtrFromString(path) if err != nil { return "", err From fbe5cc791020ca876d98aa7b7e512c5bfedf0b8d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 16:40:03 +0300 Subject: [PATCH 08/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?4=20=E2=80=94=20an=20exactly=20named=20script=20runs=20on=20a?= =?UTF-8?q?=20Linux=20case-folding=20mount;=20the=20fold=20pays=20one=20li?= =?UTF-8?q?sting=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 made the Linux/BSD entryName report the spelling as unverifiable whenever the case-swapped name resolved to the same file, and the scoped resolver refused the candidate. That refused correctly named scripts too: on a Docker Desktop bind mount, vfat, or an ext4 casefold directory an agent-token `script:"daily"` got SCRIPT_NOT_FOUND while the administrator ran it — a compatibility regression FR-012 does not permit (codex r4 #1). Maintainer's decision applied: on a case-insensitive mount the exact spelling cannot be verified without listing, so the ONE directory listing happens only in that branch — after the constant-cost probe has proven the fold — and the stored basename is matched byte for byte (a case-folded entry is reported as stored and probeCandidates refuses it, as before). Case-sensitive lookups, which every native Linux volume is, keep the O(1) probe and never list. A listing the process cannot perform, or an entry gone between probe and listing, still reports unverifiable and fails closed. darwin (F_GETPATH) and Windows (FindFirstFile) are untouched, as is the administrator's directory-based Resolve (SC-005). Retained effect, documented in agent-tokens.md (retained-effects list), overview.md and troubleshooting.md: a Linux case-folding mount pays one listing per existing candidate; a missing name pays none and the refusal shape is unchanged. Tests: TestResolveScoped_OnAFoldingDirectory (renamed; the exact-name cell is inverted — runs for scoped callers and administrators alike with exactly one ReadDir; backdoor.JS still refused non-disclosing; entryName reports the on-disk spelling; a failed listing fails closed) and TestEntryName_ExactOnACaseSensitiveLookup now counts 0 ReadDir. The round-3 skips and the scoped fail-closed branch of the case-distinct test are removed — both resolvers agree everywhere; the no-listing count test skips only where entryName itself must list. Verified in Docker natively with -race and with GOTMPDIR/TMPDIR on a folding bind mount; mutation check (entryName never lists) fails 4 subtests natively and the ExtensionCaseIsExact/CaseDistinct scoped cells on the mount. Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 7 +- docs/code_execution/troubleshooting.md | 20 ++-- docs/features/agent-tokens.md | 10 +- internal/codescripts/codescripts.go | 37 ++++--- internal/codescripts/codescripts_test.go | 65 +++++------ internal/codescripts/entryname_other.go | 54 +++++++--- internal/codescripts/entryname_other_test.go | 107 +++++++++++++------ 7 files changed, 188 insertions(+), 112 deletions(-) diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index b9a56a4e5..72adbe82a 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -402,7 +402,12 @@ nor the directory, and it is byte-for-byte the same whether the directory is empty or full, so a failed call cannot be used to probe what is stored — and the proxy does not read the directory on its behalf at all — it probes the requested name's two candidate files and nothing else — so the refusal's cost -does not grow with the number of stored scripts: +does not grow with the number of stored scripts. One retained exception: on a +Linux case-folding mount (vfat, an ext4 `casefold` directory, a Docker Desktop +bind mount from a macOS or Windows host) a candidate that *exists* is verified +against one directory listing, because Linux has no single-entry call that +reports how a name is spelled on disk; a missing name still pays no listing, +and the refusal itself is the same: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index cd9e57dc3..aff120c49 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -639,16 +639,16 @@ If the directory in the message is not the one you authored in, start the daemon with the config file you meant (`mcpproxy serve --config …`) — with `~/.mcpproxy/mcp_config.json` the scripts live in `~/.mcpproxy/scripts/`. -**Agent tokens only, on Linux, with the scripts directory on a case-folding -mount** (a Docker Desktop bind mount from a macOS or Windows host, vfat, an -ext4 `casefold` directory): the administrator runs the script but every -agent-token call reports it not found. An agent-token resolution never lists -the directory (that is what keeps a failed call from serving as an oracle), -and Linux has no single-entry call that reports how a name is spelled on disk, -so on a mount where `fetch-pr.js` and `FETCH-PR.JS` are the same entry the -daemon cannot prove it is running the file the listing reports — it refuses -rather than guess. Keep the scripts directory on a native, case-sensitive -filesystem (in Docker, a named volume rather than a host bind mount). +**Case-insensitive filesystems** (the default macOS and Windows volumes; on +Linux a Docker Desktop bind mount from a macOS or Windows host, vfat, an ext4 +`casefold` directory): the on-disk spelling still decides, for every caller. +`FETCH-PR.JS` or `Fetch-pr.js` is not the script `fetch-pr` even where the +filesystem would open it under that name — the daemon verifies the stored +spelling before running anything, so the administrator's listing, the +administrator's call and an agent-token call all agree. On a Linux case-folding +mount that verification costs one directory listing per existing candidate +(Linux has no single-entry call that reports how a name is spelled on disk); a +name that does not exist pays no listing and the refusal body is unchanged. mcpproxy never creates the directory itself; `mkdir -p` it. --- diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 6c0246f22..f299f6e4e 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -346,6 +346,13 @@ construction and this invariant does not change them; a hidden server can still authorized server's prompts are collected. - **Shared log rotation and retention** — attribution filters what a token can read back, not what history survives rotation. +- **Stored scripts on a Linux case-folding mount** — a scoped script + resolution never lists the scripts directory, except that on a mount where + the kernel folds case (vfat, ext4 `casefold`, a Docker Desktop bind mount + from macOS or Windows) an existing candidate is verified against one + directory listing, Linux having no single-entry call that reports the + on-disk spelling. A missing name pays no listing and the refusal body is + unchanged; correctly named scripts run for every caller there. **Operator-published content — keep secrets out.** Two kinds of operator-authored content are published to every caller by design and sit outside the invariant: @@ -359,7 +366,8 @@ content are published to every caller by design and sit outside the invariant: cover: a missing-script error never enumerates the other script names, the script count or the scripts directory to an agent-token caller (the refusal is identical for an empty and a populated directory, and the directory is - not even read on the caller's behalf); an ambiguous or unusable script is + not even read on the caller's behalf — the one retained exception is listed + above); an ambiguous or unusable script is reported by name and reason only, without its host path or a raw OS error; the REST listing `GET /api/v1/code/scripts` answers an agent token with `403`; administrators keep today's listing and paths; and every diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index 4289c8112..28bc4e911 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -458,9 +458,13 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { // (see candidatesFor). So a path that exists is accepted only when the entry's // stored spelling (entryName, a single-entry platform call) is byte-for-byte // the requested one; a case-folded match is not a stored script, exactly as -// List decides. Where the platform can only prove that the directory folds -// case but not what the entry is called (Linux and the BSDs on ext4 casefold, -// vfat, or a bind mount from a case-insensitive host — codex r3 #1), the +// List decides. On Linux and the BSDs that call is the probe itself on the +// case-sensitive filesystems every native volume is, and one directory +// listing only where a constant-cost probe has proven that the mount folds +// case (ext4 casefold, vfat, a bind mount from a case-insensitive host — +// codex r3 #1, r4 #1): there an exactly spelled script still runs for every +// caller, and the listing is the retained, documented cost of such a mount. +// Where even that cannot answer (the listing fails, the entry vanished), the // candidate is refused: fail closed rather than execute a file the listing // does not report. The no-follow open remains the authoritative check. func probeCandidates(scriptsDir, name string) ([]string, error) { @@ -478,12 +482,11 @@ func probeCandidates(scriptsDir, name string) ([]string, error) { stored, err := entryName(path, probed) switch { case errors.Is(err, errSpellingUnverifiable): - // The directory folds case and this platform has no single-entry - // call that reports the stored spelling (Linux, the BSDs): the - // probe cannot tell `backdoor.js` from `backdoor.JS`, so the - // candidate is not a stored script to a scoped caller — fail - // closed. The administrator's directory read still decides - // exactly, so the script keeps working for administrators. + // The directory folds case and the platform's one listing could + // not read the stored spelling (Linux, the BSDs): the probe cannot + // tell `backdoor.js` from `backdoor.JS`, so the candidate is not a + // stored script to a scoped caller — fail closed. The + // administrator's directory read still decides exactly. continue case err != nil: // The platform call failed for another reason: the Lstat verdict @@ -516,11 +519,12 @@ var ( // errSpellingUnverifiable is entryName's answer on a platform that has no // single-entry call reporting an entry's stored spelling (Linux, the BSDs) -// when the directory demonstrably folds case: the entry the probe found may be -// spelled `backdoor.JS`, which no discovery surface reports as a stored script, -// and the only way to find out is the directory listing the scoped resolver -// must not perform (Spec 105 FR-012). The scoped resolver fails closed on it. -var errSpellingUnverifiable = errors.New("the directory folds case and the entry's stored spelling cannot be verified without listing it") +// when the directory demonstrably folds case and the one listing that could +// read the spelling cannot be performed (or the fold probe itself failed): the +// entry the probe found may be spelled `backdoor.JS`, which no discovery +// surface reports as a stored script, so the scoped resolver fails closed on +// it (Spec 105 FR-012). +var errSpellingUnverifiable = errors.New("the directory folds case and the entry's stored spelling could not be read from its listing") // foldsCase reports whether the directory entry at path, whose Lstat result is // probed, is also reachable under a different spelling of its own name — that @@ -530,8 +534,9 @@ var errSpellingUnverifiable = errors.New("the directory folds case and the entry // (the lookup is case-sensitive, so the exact-name Lstat found the exact name), // names a different entry (likewise), or is the same entry, which only a // case-folding lookup — or a hard link under the swapped spelling, which the -// scoped resolver may equally refuse — can produce. Script names and -// extensions are ASCII (ValidateName), so ASCII case is the whole fold set. +// listing that follows then settles by the exact stored name — can produce. +// Script names and extensions are ASCII (ValidateName), so ASCII case is the +// whole fold set. // // It cannot be replaced by a readlink of /proc/self/fd/N: the Linux dentry is // named as looked up, not as stored (ext4 casefold, vfat and bind mounts from diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index cc49cf988..d579ca307 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -248,22 +248,9 @@ func TestResolve_CaseDistinctNamesAreDistinctScripts(t *testing.T) { for _, r := range bothResolvers { t.Run(r.name, func(t *testing.T) { - if r.name == "ResolveScoped" && scopedSpellingUnverifiable(t, filepath.Join(dir, "foo.js")) { - // This directory folds case and the platform has no - // stored-spelling call (Linux on ext4 casefold / vfat / a - // case-insensitive bind mount): the scoped resolver cannot - // prove which of the two entries backs either name, so it - // refuses both — fail closed (codex r3 #1) — with the ordinary - // non-disclosing not-found; the administrator branch below - // still resolves each from the directory read. - for _, name := range []string{"foo", "FOO"} { - _, _, err := r.resolve(dir, name, "") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - assert.True(t, notFound.Undisclosed) - } - return - } + // On a Linux case-folding mount the scoped resolver settles each + // name by one listing (codex r4 #1), so both resolvers agree + // everywhere. src, lang, err := r.resolve(dir, "foo", "") require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") assert.Equal(t, "({from: 'js'})", string(src)) @@ -472,7 +459,6 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "dup.js", "1") writeScript(t, dir, "dup.ts", "1") - requireScopedSpellingVerifiable(t, filepath.Join(dir, "dup.js")) _, _, err := ResolveScoped(dir, "dup", "") var ambiguous *AmbiguousError @@ -499,7 +485,6 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { t.Run(cell.name, func(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "bad.js", cell.content) - requireScopedSpellingVerifiable(t, filepath.Join(dir, "bad.js")) _, _, err := ResolveScoped(dir, "bad", "") var invalid *InvalidError @@ -874,9 +859,9 @@ func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { // case-insensitive, case-preserving directory lookup (APFS, NTFS, ext4 // casefold, vfat): a path that does not exist as spelled resolves to the // entry whose name matches it case-insensitively. The listing it consults is -// the simulation's own, invisible to the readDir seam. Installed BEFORE -// countDirectoryPrimitives when both are used, so the counters see the -// resolver's calls and not the simulation's. +// the simulation's own (os.ReadDir directly), invisible to the readDir seam. +// Installed BEFORE countDirectoryPrimitives when both are used, so the +// counters see the resolver's calls and not the simulation's. func simulateCaseFoldingLstat(t *testing.T) { t.Helper() orig := lstat @@ -899,27 +884,29 @@ func simulateCaseFoldingLstat(t *testing.T) { t.Cleanup(func() { lstat = orig }) } -// requireScopedSpellingVerifiable skips a test whose scoped-resolver -// expectations need a resolvable candidate when this directory folds case on a -// platform without a stored-spelling call: there every scoped candidate is -// refused, fail closed (codex r3 #1), which TestResolveScoped_FailsClosedOn -// AFoldingDirectory pins on its own. -func requireScopedSpellingVerifiable(t *testing.T, path string) { - t.Helper() - if scopedSpellingUnverifiable(t, path) { - t.Skipf("%s: the directory folds case and this platform cannot verify the stored spelling; scoped resolution fails closed here", filepath.Dir(path)) - } -} - -// scopedSpellingUnverifiable reports whether, on this platform and for this -// existing entry, the scoped resolver cannot verify the stored spelling and -// therefore refuses the candidate (Linux on a case-folding mount). -func scopedSpellingUnverifiable(t *testing.T, path string) bool { +// requireListingFreeEntryName skips a test that asserts a scoped resolution +// reads no directory when, on this platform and for this existing entry, the +// platform's entryName itself must list once: Linux on a case-folding mount, +// where the listing is the retained cost of such a mount (codex r4 #1) and +// TestResolveScoped_OnAFoldingDirectory pins the count. darwin (F_GETPATH) +// and Windows (FindFirstFile) never list, and neither does any platform on a +// case-sensitive lookup. +func requireListingFreeEntryName(t *testing.T, path string) { t.Helper() info, err := os.Lstat(path) require.NoError(t, err) + listed := false + orig := readDir + readDir = func(name string) ([]os.DirEntry, error) { + listed = true + return orig(name) + } _, err = entryName(path, info) - return errors.Is(err, errSpellingUnverifiable) + readDir = orig + require.NoError(t, err) + if listed { + t.Skipf("%s: the directory folds case and this platform verifies the stored spelling by one listing; that count is pinned by TestResolveScoped_OnAFoldingDirectory", filepath.Dir(path)) + } } // TestFoldsCase pins the constant-cost fold proof the Linux entryName relies @@ -1023,7 +1010,7 @@ func TestResolveScoped_NeverReadsTheDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha-SENTINEL.js", "1") writeScript(t, dir, "beta.ts", "1") - requireScopedSpellingVerifiable(t, filepath.Join(dir, "beta.ts")) + requireListingFreeEntryName(t, filepath.Join(dir, "beta.ts")) readDirs, _ := countDirectoryPrimitives(t) diff --git a/internal/codescripts/entryname_other.go b/internal/codescripts/entryname_other.go index ebc9994e3..8c371e2bc 100644 --- a/internal/codescripts/entryname_other.go +++ b/internal/codescripts/entryname_other.go @@ -5,29 +5,59 @@ package codescripts import ( "io/fs" "path/filepath" + "strings" ) // entryName returns the name the filesystem stores for the directory entry at // path, whose Lstat result is probed. Linux and the BSDs resolve names // case-sensitively on their native filesystems, so an entry found by an -// exact-name Lstat IS that name — but a case-folding mount (vfat, an ext4 -// casefold directory, a bind mount from a case-insensitive host) finds -// `backdoor.JS` for `backdoor.js` just as APFS and NTFS do, and unlike those it -// offers no single-entry "what is this spelled" call: F_GETPATH does not exist, -// and a readlink of /proc/self/fd/N echoes the spelling that was looked up, -// not the one on disk. The only exact answer is the directory listing the -// scoped resolver must not perform (Spec 105 FR-012), so the fold is proven -// with one constant-cost probe (foldsCase) and reported as unverifiable; the -// scoped resolver then refuses the candidate — fail closed. +// exact-name Lstat IS that name — the common case, answered by the probe alone +// (foldsCase, one extra constant-cost Lstat) without touching the directory. +// But a case-folding mount (vfat, an ext4 casefold directory, a bind mount +// from a case-insensitive host) finds `backdoor.JS` for `backdoor.js` just as +// APFS and NTFS do, and unlike those it offers no single-entry "what is this +// spelled" call: F_GETPATH does not exist, and a readlink of /proc/self/fd/N +// echoes the spelling that was looked up, not the one on disk. There the only +// exact answer is the directory listing, so it is read ONCE, in that branch +// alone, and the stored basename is the one that matches the requested +// spelling — byte for byte when the exact name is stored, case-folded when it +// is not (which probeCandidates then refuses). A folding mount thus pays one +// listing per existing candidate; the refusal shape is unchanged (Spec 105 +// FR-012, codex r4 #1). When neither the fold probe nor the listing can +// answer, the spelling is reported unverifiable and the scoped resolver fails +// closed. func entryName(path string, probed fs.FileInfo) (string, error) { folds, err := foldsCase(path, probed) if err != nil { // The variant probe failed for a reason other than absence: nothing - // proves the spelling, so the answer is the same fail-closed one. + // proves the spelling, so the answer is the fail-closed one. return "", errSpellingUnverifiable } - if folds { + base := filepath.Base(path) + if !folds { + return base, nil + } + + entries, err := readDir(filepath.Dir(path)) + if err != nil { + // Searchable but not listable (or gone): the fold is proven and the + // spelling cannot be read, so the scoped resolver fails closed. + return "", errSpellingUnverifiable + } + stored := "" + for _, e := range entries { + name := e.Name() + if name == base { + return base, nil + } + if stored == "" && strings.EqualFold(name, base) { + stored = name + } + } + if stored == "" { + // The probe found an entry the listing does not hold (removed in + // between): nothing to verify against. return "", errSpellingUnverifiable } - return filepath.Base(path), nil + return stored, nil } diff --git a/internal/codescripts/entryname_other_test.go b/internal/codescripts/entryname_other_test.go index ae410da44..594944b99 100644 --- a/internal/codescripts/entryname_other_test.go +++ b/internal/codescripts/entryname_other_test.go @@ -5,42 +5,48 @@ package codescripts import ( "errors" "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestResolveScoped_FailsClosedOnAFoldingDirectory (Spec 105 FR-012, codex r3 -// #1): Linux has no single-entry call that reports an entry's stored spelling, -// so on a case-folding mount (ext4 casefold, vfat, a bind mount from a -// case-insensitive host) the scoped probe for `backdoor.js` finds -// `backdoor.JS` — a file the listing and the administrator's Resolve reject — -// and, before this fix, executed it. The fold is now proven with one extra -// constant-cost probe and the candidate refused: a scoped caller cannot -// execute what no discovery surface reports, and the refusal is the ordinary -// non-disclosing not-found. The folding lookup is simulated through the -// package's lstat seam so the rule is pinned on the case-sensitive filesystems -// CI runs on; the same test against a real folding mount (TMPDIR on a Docker -// Desktop bind mount of an APFS directory) exercises the kernel's own fold. -func TestResolveScoped_FailsClosedOnAFoldingDirectory(t *testing.T) { +// TestResolveScoped_OnAFoldingDirectory (Spec 105 FR-012, codex r3 #1 and +// r4 #1): Linux has no single-entry call that reports an entry's stored +// spelling, so on a case-folding mount (ext4 casefold, vfat, a bind mount from +// a case-insensitive host) the scoped probe for `backdoor.js` finds +// `backdoor.JS` — a file the listing and the administrator's Resolve reject. +// Round 3 refused every candidate on such a mount, which also refused a +// correctly named `daily.js` to agent tokens while the administrator ran it +// (round 4). The contract now: the fold is proven with one constant-cost probe +// (no listing on the case-sensitive filesystems every native Linux volume +// is), and only where it is proven does entryName list the directory ONCE and +// match the exact on-disk basename — so an exact name runs for every caller, +// a folded spelling is still refused with the ordinary non-disclosing +// not-found, and a mount that folds case pays one listing per existing +// candidate (a retained, documented effect). The folding lookup is simulated +// through the package's lstat seam so the rule is pinned on the case-sensitive +// filesystems CI runs on; the same test against a real folding mount (TMPDIR +// on a Docker Desktop bind mount of an APFS directory) exercises the kernel's +// own fold. +func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "backdoor.JS", "({pwned: true})") writeScript(t, dir, "exact.js", "({exact: true})") simulateCaseFoldingLstat(t) // On a case-sensitive filesystem this first case is refused even without - // the fold proof (the real open of `backdoor.js` misses); it bites on a - // real folding mount. The second case is the one the simulation pins on - // every host: neutering the Linux entryName fails it. - t.Run("folded spelling is not a stored script", func(t *testing.T) { + // the listing (the real open of `backdoor.js` misses); it bites on a real + // folding mount. The listing count is what the simulation pins here. + t.Run("a folded spelling is not a stored script", func(t *testing.T) { readDirs, lstats := countDirectoryPrimitives(t) src, _, err := ResolveScoped(dir, "backdoor", "") var notFound *NotFoundError require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") assert.NotContains(t, string(src), "pwned") - assert.Equal(t, 0, *readDirs, "proving the fold must not list the directory") + assert.Equal(t, 1, *readDirs, "the proven fold is resolved by exactly one listing: the .js probe hit, the .ts probe missed") assert.LessOrEqual(t, *lstats, 4, "at most one extra probe per candidate: constant cost") // The administrator's directory read agrees: byte-for-byte, .JS is @@ -50,33 +56,64 @@ func TestResolveScoped_FailsClosedOnAFoldingDirectory(t *testing.T) { assert.False(t, notFound.Undisclosed) }) - t.Run("an exactly spelled script on a folding mount is refused to scoped callers, fail closed", func(t *testing.T) { - // Without a stored-spelling call the probe cannot tell this case from - // the one above, so it must refuse both; the administrator, whose - // candidates come from the directory read, still runs it. - _, _, err := ResolveScoped(dir, "exact", "") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - assert.True(t, notFound.Undisclosed) + t.Run("an exactly spelled script on a folding mount runs for scoped callers and administrators alike", func(t *testing.T) { + readDirs, lstats := countDirectoryPrimitives(t) + src, lang, err := ResolveScoped(dir, "exact", "") + require.NoError(t, err, "a correctly named script must not be refused to an agent token (codex r4 #1)") + assert.Equal(t, "({exact: true})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + assert.Equal(t, 1, *readDirs, "the proven fold costs one listing, and only the existing candidate pays it") + assert.LessOrEqual(t, *lstats, 4) - src, lang, err := Resolve(dir, "exact", "") + src, lang, err = Resolve(dir, "exact", "") require.NoError(t, err) assert.Equal(t, "({exact: true})", string(src)) assert.Equal(t, LanguageJavaScript, lang) }) - t.Run("entryName reports the fold as unverifiable", func(t *testing.T) { - path := dir + "/exact.js" - info, err := lstat(path) + t.Run("entryName reports the stored spelling from one listing", func(t *testing.T) { + exact := filepath.Join(dir, "exact.js") + info, err := lstat(exact) + require.NoError(t, err) + readDirs, _ := countDirectoryPrimitives(t) + stored, err := entryName(exact, info) + require.NoError(t, err) + assert.Equal(t, "exact.js", stored) + assert.Equal(t, 1, *readDirs) + + folded := filepath.Join(dir, "backdoor.js") + info, err = lstat(folded) + require.NoError(t, err, "the simulated fold finds backdoor.JS") + stored, err = entryName(folded, info) require.NoError(t, err) - _, err = entryName(path, info) + assert.Equal(t, "backdoor.JS", stored, "the on-disk spelling, which probeCandidates then rejects as a fold") + assert.Equal(t, 2, *readDirs) + }) + + t.Run("a listing the process cannot perform leaves the spelling unverifiable, fail closed", func(t *testing.T) { + orig := readDir + readDir = func(string) ([]os.DirEntry, error) { + return nil, &os.PathError{Op: "open", Path: dir, Err: os.ErrPermission} + } + t.Cleanup(func() { readDir = orig }) + + exact := filepath.Join(dir, "exact.js") + info, err := lstat(exact) + require.NoError(t, err) + _, err = entryName(exact, info) assert.ErrorIs(t, err, errSpellingUnverifiable) + + _, _, err = ResolveScoped(dir, "exact", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed, "an unverifiable spelling is the ordinary non-disclosing not-found, never the OS error") }) } // TestEntryName_ExactOnACaseSensitiveLookup is the other half: where the // lookup does not fold (every native Linux filesystem), an entry found by its -// exact name IS that name and the scoped resolver keeps working. +// exact name IS that name, no directory is listed, and the scoped resolver +// keeps its O(1) probe. func TestEntryName_ExactOnACaseSensitiveLookup(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "exact.js", "1") @@ -84,13 +121,17 @@ func TestEntryName_ExactOnACaseSensitiveLookup(t *testing.T) { info, err := os.Lstat(path) require.NoError(t, err) if folds, _ := foldsCase(path, info); folds { - t.Skip("this temp directory folds case; the fail-closed rule is pinned by TestResolveScoped_FailsClosedOnAFoldingDirectory") + t.Skip("this temp directory folds case; that branch is pinned by TestResolveScoped_OnAFoldingDirectory") } + readDirs, lstats := countDirectoryPrimitives(t) stored, err := entryName(path, info) require.NoError(t, err) assert.Equal(t, "exact.js", stored) + assert.Equal(t, 0, *readDirs, "a case-sensitive lookup is verified by the probe alone, never by a listing") + assert.Equal(t, 1, *lstats, "exactly the one fold probe") src, _, err := ResolveScoped(dir, "exact", "") require.NoError(t, err) assert.Equal(t, "1", string(src)) + assert.Equal(t, 0, *readDirs, "a scoped hit on a case-sensitive filesystem never reads the directory") } From 91e901a60e09d119c3650b18b8e1ef4519b9b9b1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 18:06:26 +0300 Subject: [PATCH 09/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?5=20=E2=80=94=20the=20scoped=20resolver=20answers=20from=20a=20?= =?UTF-8?q?directory-generation-validated=20exact-name=20index=20on=20Linu?= =?UTF-8?q?x/BSD=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-4 fallback listing was paid only when the case-folded probe hit, so on a Linux/BSD case-folding mount the presence of `backdoor.JS` cost O(directory) while absence cost O(1): a timing oracle on the stored names, and the "retained exception" that documented it is not a Spec 105 retained effect. The scoped resolver now keeps a per-scripts-directory index of the exact spellings, listed once per directory generation (mtime+ctime+size+ino, one Lstat of the directory per request, a 2 s settle window for coarse timestamps) and answered by an O(1) set lookup; only exact hits are probed. The listing is paid when the directory changes, never per request, whatever name is asked for. darwin (F_GETPATH), Windows (FindFirstFile) and the administrator Resolve are unchanged; the fold-probe code is deleted. Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 12 +- docs/code_execution/troubleshooting.md | 10 +- docs/features/agent-tokens.md | 15 +- internal/codescripts/codescripts.go | 136 ++------- internal/codescripts/codescripts_test.go | 158 +--------- internal/codescripts/dirgeneration_ctim.go | 21 ++ .../codescripts/dirgeneration_ctimespec.go | 21 ++ internal/codescripts/entryname_darwin.go | 7 +- internal/codescripts/entryname_other.go | 63 ---- internal/codescripts/entryname_other_test.go | 137 --------- internal/codescripts/entryname_windows.go | 12 +- internal/codescripts/storednames_other.go | 146 +++++++++ .../codescripts/storednames_other_test.go | 280 ++++++++++++++++++ internal/codescripts/storedspellings_probe.go | 44 +++ .../codescripts/storedspellings_probe_test.go | 12 + 15 files changed, 588 insertions(+), 486 deletions(-) create mode 100644 internal/codescripts/dirgeneration_ctim.go create mode 100644 internal/codescripts/dirgeneration_ctimespec.go delete mode 100644 internal/codescripts/entryname_other.go delete mode 100644 internal/codescripts/entryname_other_test.go create mode 100644 internal/codescripts/storednames_other.go create mode 100644 internal/codescripts/storednames_other_test.go create mode 100644 internal/codescripts/storedspellings_probe.go create mode 100644 internal/codescripts/storedspellings_probe_test.go diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index 72adbe82a..6170521af 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -402,12 +402,12 @@ nor the directory, and it is byte-for-byte the same whether the directory is empty or full, so a failed call cannot be used to probe what is stored — and the proxy does not read the directory on its behalf at all — it probes the requested name's two candidate files and nothing else — so the refusal's cost -does not grow with the number of stored scripts. One retained exception: on a -Linux case-folding mount (vfat, an ext4 `casefold` directory, a Docker Desktop -bind mount from a macOS or Windows host) a candidate that *exists* is verified -against one directory listing, because Linux has no single-entry call that -reports how a name is spelled on disk; a missing name still pays no listing, -and the refusal itself is the same: +does not grow with the number of stored scripts. (On Linux and the BSDs, which +have no single-entry call reporting how a name is spelled on disk, the scoped +resolver answers from an exact-name index of the directory that is validated +by one stat of the directory per request; a listing is paid when the directory +changes, never per request, and never depends on the name asked for.) The +refusal itself: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index aff120c49..07af49b0f 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -645,10 +645,12 @@ Linux a Docker Desktop bind mount from a macOS or Windows host, vfat, an ext4 `FETCH-PR.JS` or `Fetch-pr.js` is not the script `fetch-pr` even where the filesystem would open it under that name — the daemon verifies the stored spelling before running anything, so the administrator's listing, the -administrator's call and an agent-token call all agree. On a Linux case-folding -mount that verification costs one directory listing per existing candidate -(Linux has no single-entry call that reports how a name is spelled on disk); a -name that does not exist pays no listing and the refusal body is unchanged. +administrator's call and an agent-token call all agree. On Linux and the BSDs +(which have no single-entry call that reports how a name is spelled on disk) +an agent-token call is answered from an exact-name index of the directory, +validated by one stat of the directory per call: a listing is paid when the +directory changes, never per call, whatever name is asked for, and the +refusal body is unchanged. mcpproxy never creates the directory itself; `mkdir -p` it. --- diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index f299f6e4e..3d8925153 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -346,13 +346,6 @@ construction and this invariant does not change them; a hidden server can still authorized server's prompts are collected. - **Shared log rotation and retention** — attribution filters what a token can read back, not what history survives rotation. -- **Stored scripts on a Linux case-folding mount** — a scoped script - resolution never lists the scripts directory, except that on a mount where - the kernel folds case (vfat, ext4 `casefold`, a Docker Desktop bind mount - from macOS or Windows) an existing candidate is verified against one - directory listing, Linux having no single-entry call that reports the - on-disk spelling. A missing name pays no listing and the refusal body is - unchanged; correctly named scripts run for every caller there. **Operator-published content — keep secrets out.** Two kinds of operator-authored content are published to every caller by design and sit outside the invariant: @@ -366,9 +359,11 @@ content are published to every caller by design and sit outside the invariant: cover: a missing-script error never enumerates the other script names, the script count or the scripts directory to an agent-token caller (the refusal is identical for an empty and a populated directory, and the directory is - not even read on the caller's behalf — the one retained exception is listed - above); an ambiguous or unusable script is - reported by name and reason only, without its host path or a raw OS error; + not read on the caller's behalf: on Linux and the BSDs the scoped resolver + answers from an exact-name index of the directory validated by one stat + per call, so a listing is paid when the directory changes, never per call, + whatever name is asked for); an ambiguous or unusable script is reported by + name and reason only, without its host path or a raw OS error; the REST listing `GET /api/v1/code/scripts` answers an agent token with `403`; administrators keep today's listing and paths; and every `call_tool()` a script makes is checked against the caller's server scope diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index 28bc4e911..c80f4df45 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -300,10 +300,11 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language // listing, and every refusal it returns is already the non-disclosing form: a // not-found error is built WITHOUT listing the directory — neither the // discovery listing nor a directory read on the way to the miss; the two -// candidate paths are probed and nothing else, so the refusal's cost does not -// grow with what is stored — and the ambiguous / invalid forms carry the -// caller's own name and the reason but no host path and no raw OS error. -// Typed identities are the same, so the REST classifier does not tell the two +// candidate names are probed and nothing else, so the refusal's cost does not +// grow with what is stored and does not depend on the name asked for +// (probeCandidates) — and the ambiguous / invalid forms carry the caller's +// own name and the reason but no host path and no raw OS error. Typed +// identities are the same, so the REST classifier does not tell the two // callers apart. The probe is the scoped resolver's alone: the administrator // path keeps its directory-based decision (SC-005), so a directory that is // searchable but not listable still refuses administrators as it always did. @@ -447,58 +448,36 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { } // probeCandidates is candidatesFor for the SCOPED resolver: the same two -// candidate paths, decided by probing each constructed path directly instead -// of listing the directory. The cost is a fixed number of single-path calls -// whatever the directory holds: a scoped caller's refusal must not grow with -// the number of stored scripts (Spec 105 FR-012 — timing class is part of a -// non-disclosing refusal), so the directory is never listed here. -// -// The probe alone would delegate the name→file decision to the filesystem, and -// on the default macOS and Windows volumes that decision is case-insensitive -// (see candidatesFor). So a path that exists is accepted only when the entry's -// stored spelling (entryName, a single-entry platform call) is byte-for-byte -// the requested one; a case-folded match is not a stored script, exactly as -// List decides. On Linux and the BSDs that call is the probe itself on the -// case-sensitive filesystems every native volume is, and one directory -// listing only where a constant-cost probe has proven that the mount folds -// case (ext4 casefold, vfat, a bind mount from a case-insensitive host — -// codex r3 #1, r4 #1): there an exactly spelled script still runs for every -// caller, and the listing is the retained, documented cost of such a mount. -// Where even that cannot answer (the listing fails, the entry vanished), the -// candidate is refused: fail closed rather than execute a file the listing -// does not report. The no-follow open remains the authoritative check. +// candidate names, each decided by the platform's constant-cost answer to +// "does the directory hold an entry spelled exactly so" (storedSpellingsOf) +// instead of by a listing. A scoped caller's refusal must cost the same +// whatever the directory holds and whatever it asks for (Spec 105 FR-012 — +// timing class is part of a non-disclosing refusal), so nothing here lists +// the directory on a request's behalf. Exactness matters because the +// filesystem's own name→file decision is case-insensitive on the default +// macOS and Windows volumes and on a Linux case-folding mount (see +// candidatesFor): a `backdoor.JS` that a probe for `backdoor.js` would open +// is not a stored script, exactly as List decides. On darwin and Windows a +// single-entry platform call reports the stored spelling of a probed path; on +// Linux and the BSDs, which have no such call, the answer comes from a +// per-directory index of exact names that is listed once per directory +// change, never per request (storednames_other.go, codex r5 #1). The +// no-follow open remains the authoritative check. func probeCandidates(scriptsDir, name string) ([]string, error) { + storedExactly, err := storedSpellingsOf(scriptsDir) + if err != nil { + return nil, err + } found := make([]string, 0, 2) for _, ext := range []string{extJS, extTS} { want := name + ext - path := filepath.Join(scriptsDir, want) - probed, err := lstat(path) + stored, err := storedExactly(want) if err != nil { - if errors.Is(err, fs.ErrNotExist) { - continue - } return nil, err } - stored, err := entryName(path, probed) - switch { - case errors.Is(err, errSpellingUnverifiable): - // The directory folds case and the platform's one listing could - // not read the stored spelling (Linux, the BSDs): the probe cannot - // tell `backdoor.js` from `backdoor.JS`, so the candidate is not a - // stored script to a scoped caller — fail closed. The - // administrator's directory read still decides exactly. - continue - case err != nil: - // The platform call failed for another reason: the Lstat verdict - // stays in force and the no-follow open below decides usability. - case stored != want && strings.EqualFold(stored, want): - // The filesystem folded the case: the entry is spelled differently - // and no discovery surface reports it under this name. Only a - // case-only difference is a fold; any other answer (a hard link's - // other name) leaves the Lstat verdict in force. - continue + if stored { + found = append(found, filepath.Join(scriptsDir, want)) } - found = append(found, path) } return found, nil } @@ -510,69 +489,14 @@ var listForNotFound = List // readDir and lstat are the package's two directory-touching primitives, // variables so the tests can count them: a scoped resolution must never -// enumerate the directory (readDir) and must probe a fixed number of paths -// (lstat) whatever the directory holds (Spec 105 FR-012 timing class). +// enumerate the directory on a request's behalf (readDir) and must probe a +// fixed number of paths (lstat) whatever the directory holds and whatever it +// asks for (Spec 105 FR-012 timing class). var ( readDir = os.ReadDir lstat = os.Lstat ) -// errSpellingUnverifiable is entryName's answer on a platform that has no -// single-entry call reporting an entry's stored spelling (Linux, the BSDs) -// when the directory demonstrably folds case and the one listing that could -// read the spelling cannot be performed (or the fold probe itself failed): the -// entry the probe found may be spelled `backdoor.JS`, which no discovery -// surface reports as a stored script, so the scoped resolver fails closed on -// it (Spec 105 FR-012). -var errSpellingUnverifiable = errors.New("the directory folds case and the entry's stored spelling could not be read from its listing") - -// foldsCase reports whether the directory entry at path, whose Lstat result is -// probed, is also reachable under a different spelling of its own name — that -// is, whether the filesystem folds case for lookups in that directory. It costs -// exactly one extra Lstat (the constant-cost class Spec 105 FR-012 requires): -// the same name with every ASCII letter's case swapped either does not exist -// (the lookup is case-sensitive, so the exact-name Lstat found the exact name), -// names a different entry (likewise), or is the same entry, which only a -// case-folding lookup — or a hard link under the swapped spelling, which the -// listing that follows then settles by the exact stored name — can produce. -// Script names and extensions are ASCII (ValidateName), so ASCII case is the -// whole fold set. -// -// It cannot be replaced by a readlink of /proc/self/fd/N: the Linux dentry is -// named as looked up, not as stored (ext4 casefold, vfat and bind mounts from -// case-insensitive hosts all echo the caller's spelling back). -func foldsCase(path string, probed fs.FileInfo) (bool, error) { - base := filepath.Base(path) - variant := swapASCIICase(base) - if variant == base { - // Nothing to fold: no other spelling of this name exists. - return false, nil - } - other, err := lstat(filepath.Join(filepath.Dir(path), variant)) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return false, nil - } - return false, err - } - return os.SameFile(probed, other), nil -} - -// swapASCIICase flips the case of every ASCII letter in s and leaves every -// other byte alone. -func swapASCIICase(s string) string { - b := []byte(s) - for i, c := range b { - switch { - case 'a' <= c && c <= 'z': - b[i] = c - 'a' + 'A' - case 'A' <= c && c <= 'Z': - b[i] = c - 'A' + 'a' - } - } - return string(b) -} - // notFoundErrorFor builds the not-found error for one caller kind: the // discovery-carrying administrator form (FR-004), or the scoped form that is // constructed without touching the directory at all (Spec 105 FR-012 — the diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index d579ca307..6c19142a0 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -3,7 +3,6 @@ package codescripts import ( "errors" "fmt" - "io/fs" "os" "path/filepath" "runtime" @@ -248,9 +247,9 @@ func TestResolve_CaseDistinctNamesAreDistinctScripts(t *testing.T) { for _, r := range bothResolvers { t.Run(r.name, func(t *testing.T) { - // On a Linux case-folding mount the scoped resolver settles each - // name by one listing (codex r4 #1), so both resolvers agree - // everywhere. + // On Linux and the BSDs the scoped resolver settles each name + // from the directory's exact-name index (codex r5 #1), so both + // resolvers agree everywhere. src, lang, err := r.resolve(dir, "foo", "") require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") assert.Equal(t, "({from: 'js'})", string(src)) @@ -855,149 +854,6 @@ func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { return &rd, &ls } -// simulateCaseFoldingLstat makes the package's lstat seam behave like a -// case-insensitive, case-preserving directory lookup (APFS, NTFS, ext4 -// casefold, vfat): a path that does not exist as spelled resolves to the -// entry whose name matches it case-insensitively. The listing it consults is -// the simulation's own (os.ReadDir directly), invisible to the readDir seam. -// Installed BEFORE countDirectoryPrimitives when both are used, so the -// counters see the resolver's calls and not the simulation's. -func simulateCaseFoldingLstat(t *testing.T) { - t.Helper() - orig := lstat - lstat = func(name string) (os.FileInfo, error) { - info, err := orig(name) - if err == nil || !errors.Is(err, fs.ErrNotExist) { - return info, err - } - entries, readErr := os.ReadDir(filepath.Dir(name)) - if readErr != nil { - return nil, err - } - for _, e := range entries { - if strings.EqualFold(e.Name(), filepath.Base(name)) { - return orig(filepath.Join(filepath.Dir(name), e.Name())) - } - } - return nil, err - } - t.Cleanup(func() { lstat = orig }) -} - -// requireListingFreeEntryName skips a test that asserts a scoped resolution -// reads no directory when, on this platform and for this existing entry, the -// platform's entryName itself must list once: Linux on a case-folding mount, -// where the listing is the retained cost of such a mount (codex r4 #1) and -// TestResolveScoped_OnAFoldingDirectory pins the count. darwin (F_GETPATH) -// and Windows (FindFirstFile) never list, and neither does any platform on a -// case-sensitive lookup. -func requireListingFreeEntryName(t *testing.T, path string) { - t.Helper() - info, err := os.Lstat(path) - require.NoError(t, err) - listed := false - orig := readDir - readDir = func(name string) ([]os.DirEntry, error) { - listed = true - return orig(name) - } - _, err = entryName(path, info) - readDir = orig - require.NoError(t, err) - if listed { - t.Skipf("%s: the directory folds case and this platform verifies the stored spelling by one listing; that count is pinned by TestResolveScoped_OnAFoldingDirectory", filepath.Dir(path)) - } -} - -// TestFoldsCase pins the constant-cost fold proof the Linux entryName relies -// on (codex r3 #1): one extra Lstat of the case-swapped spelling, and only the -// SAME entry answering under both spellings counts as a fold. -func TestFoldsCase(t *testing.T) { - dir := t.TempDir() - writeScript(t, dir, "exact.js", "1") - writeScript(t, dir, "digits.js", "1") - path := filepath.Join(dir, "exact.js") - info, err := os.Lstat(path) - require.NoError(t, err) - - t.Run("real directory", func(t *testing.T) { - // Independent oracle: is a differently spelled sibling name reachable? - _, err := os.Lstat(filepath.Join(dir, "DIGITS.js")) - dirFolds := err == nil - - folds, err := foldsCase(path, info) - require.NoError(t, err) - assert.Equal(t, dirFolds, folds) - }) - - t.Run("simulated folding lookup", func(t *testing.T) { - simulateCaseFoldingLstat(t) - _, lstats := countDirectoryPrimitives(t) - folds, err := foldsCase(path, info) - require.NoError(t, err) - assert.True(t, folds, "the swapped spelling reaches the same entry") - assert.Equal(t, 1, *lstats, "exactly one extra probe") - }) - - t.Run("case-sensitive lookup, variant absent", func(t *testing.T) { - orig := lstat - lstat = func(name string) (os.FileInfo, error) { - if filepath.Base(name) == "EXACT.JS" { - return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist} - } - return orig(name) - } - t.Cleanup(func() { lstat = orig }) - folds, err := foldsCase(path, info) - require.NoError(t, err) - assert.False(t, folds) - }) - - t.Run("case-sensitive lookup, variant is a different entry", func(t *testing.T) { - other := filepath.Join(dir, "digits.js") - orig := lstat - lstat = func(name string) (os.FileInfo, error) { - if filepath.Base(name) == "EXACT.JS" { - return orig(other) - } - return orig(name) - } - t.Cleanup(func() { lstat = orig }) - folds, err := foldsCase(path, info) - require.NoError(t, err) - assert.False(t, folds, "two distinct entries under two spellings is a case-sensitive directory") - }) - - t.Run("a name with no letters has no other spelling", func(t *testing.T) { - p := filepath.Join(dir, "123") - writeScript(t, dir, "123", "1") - i, err := os.Lstat(p) - require.NoError(t, err) - _, lstats := countDirectoryPrimitives(t) - folds, err := foldsCase(p, i) - require.NoError(t, err) - assert.False(t, folds) - assert.Equal(t, 0, *lstats, "nothing to probe") - }) - - t.Run("a failing variant probe is reported, not swallowed", func(t *testing.T) { - orig := lstat - boom := errors.New("boom") - lstat = func(name string) (os.FileInfo, error) { - if filepath.Base(name) == "EXACT.JS" { - return nil, boom - } - return orig(name) - } - t.Cleanup(func() { lstat = orig }) - _, err := foldsCase(path, info) - assert.ErrorIs(t, err, boom) - }) - - assert.Equal(t, "BACKDOOR.js", swapASCIICase("backdoor.JS")) - assert.Equal(t, "fetch-PRS_2.TS", swapASCIICase("FETCH-prs_2.ts")) -} - // TestResolveScoped_NeverReadsTheDirectory (Spec 105 FR-012, codex r1 #1): // a scoped resolution — hit or miss — never enumerates the scripts directory. // Skipping the not-found LISTING is not enough: an os.ReadDir on the way to @@ -1006,11 +862,16 @@ func TestFoldsCase(t *testing.T) { // not only in body. The administrator keeps the pre-105 directory-based // decision (SC-005, codex r2 #1): one directory read decides the candidates // on every call, and a miss pays for the discovery listing on top. +// +// On Linux and the BSDs the scoped resolver answers from the directory's +// stored-name index (codex r5 #1), whose one listing is paid when the +// directory changes, never per request: the index is warmed first here, and +// storednames_other_test.go pins its cost rule. func TestResolveScoped_NeverReadsTheDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha-SENTINEL.js", "1") writeScript(t, dir, "beta.ts", "1") - requireListingFreeEntryName(t, filepath.Join(dir, "beta.ts")) + warmStoredNames(t, dir) readDirs, _ := countDirectoryPrimitives(t) @@ -1050,6 +911,7 @@ func TestResolveScoped_MissCostIsIndependentOfDirectorySize(t *testing.T) { } probe := func(dir string) (readDirs, lstats int) { + warmStoredNames(t, dir) rd, ls := countDirectoryPrimitives(t) _, _, err := ResolveScoped(dir, "gamma", "") var notFound *NotFoundError diff --git a/internal/codescripts/dirgeneration_ctim.go b/internal/codescripts/dirgeneration_ctim.go new file mode 100644 index 000000000..e1be5bff6 --- /dev/null +++ b/internal/codescripts/dirgeneration_ctim.go @@ -0,0 +1,21 @@ +//go:build linux || openbsd || dragonfly || solaris || aix + +package codescripts + +import ( + "io/fs" + "syscall" + "time" +) + +// dirGenerationOf reads a directory's generation stamp from its Lstat result. +// The inode and ctime come from the platform stat structure, whose ctime +// field is spelled Ctim here. +func dirGenerationOf(info fs.FileInfo) dirGeneration { + gen := dirGeneration{modTime: info.ModTime(), size: info.Size()} + if st, ok := info.Sys().(*syscall.Stat_t); ok { + gen.ino = uint64(st.Ino) + gen.changeTime = time.Unix(st.Ctim.Unix()) + } + return gen +} diff --git a/internal/codescripts/dirgeneration_ctimespec.go b/internal/codescripts/dirgeneration_ctimespec.go new file mode 100644 index 000000000..7b7a39647 --- /dev/null +++ b/internal/codescripts/dirgeneration_ctimespec.go @@ -0,0 +1,21 @@ +//go:build freebsd || netbsd + +package codescripts + +import ( + "io/fs" + "syscall" + "time" +) + +// dirGenerationOf reads a directory's generation stamp from its Lstat result. +// The inode and ctime come from the platform stat structure, whose ctime +// field is spelled Ctimespec here. +func dirGenerationOf(info fs.FileInfo) dirGeneration { + gen := dirGeneration{modTime: info.ModTime(), size: info.Size()} + if st, ok := info.Sys().(*syscall.Stat_t); ok { + gen.ino = uint64(st.Ino) + gen.changeTime = time.Unix(st.Ctimespec.Unix()) + } + return gen +} diff --git a/internal/codescripts/entryname_darwin.go b/internal/codescripts/entryname_darwin.go index f0ff20c11..9c95b36ff 100644 --- a/internal/codescripts/entryname_darwin.go +++ b/internal/codescripts/entryname_darwin.go @@ -4,7 +4,6 @@ package codescripts import ( "bytes" - "io/fs" "os" "path/filepath" "syscall" @@ -12,15 +11,15 @@ import ( ) // entryName returns the name the filesystem actually stores for the directory -// entry at path (probed is its Lstat result, unused here), without following -// a symlink and without listing the directory. The default APFS/HFS+ volumes are case-insensitive but +// entry at path, without following a symlink and without listing the +// directory. The default APFS/HFS+ volumes are case-insensitive but // case-PRESERVING: a probe for `backdoor.js` opens `backdoor.JS`, and the // on-disk spelling is what F_GETPATH on the descriptor reports. // // O_SYMLINK opens a symlink itself rather than its target (the no-follow // counterpart to Lstat), so a link's own entry name is the one verified; // O_NONBLOCK keeps a FIFO from parking the open, as in openScriptFile. -func entryName(path string, _ fs.FileInfo) (string, error) { +func entryName(path string) (string, error) { f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_SYMLINK|syscall.O_NONBLOCK, 0) if err != nil { return "", err diff --git a/internal/codescripts/entryname_other.go b/internal/codescripts/entryname_other.go deleted file mode 100644 index 8c371e2bc..000000000 --- a/internal/codescripts/entryname_other.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build !darwin && !windows - -package codescripts - -import ( - "io/fs" - "path/filepath" - "strings" -) - -// entryName returns the name the filesystem stores for the directory entry at -// path, whose Lstat result is probed. Linux and the BSDs resolve names -// case-sensitively on their native filesystems, so an entry found by an -// exact-name Lstat IS that name — the common case, answered by the probe alone -// (foldsCase, one extra constant-cost Lstat) without touching the directory. -// But a case-folding mount (vfat, an ext4 casefold directory, a bind mount -// from a case-insensitive host) finds `backdoor.JS` for `backdoor.js` just as -// APFS and NTFS do, and unlike those it offers no single-entry "what is this -// spelled" call: F_GETPATH does not exist, and a readlink of /proc/self/fd/N -// echoes the spelling that was looked up, not the one on disk. There the only -// exact answer is the directory listing, so it is read ONCE, in that branch -// alone, and the stored basename is the one that matches the requested -// spelling — byte for byte when the exact name is stored, case-folded when it -// is not (which probeCandidates then refuses). A folding mount thus pays one -// listing per existing candidate; the refusal shape is unchanged (Spec 105 -// FR-012, codex r4 #1). When neither the fold probe nor the listing can -// answer, the spelling is reported unverifiable and the scoped resolver fails -// closed. -func entryName(path string, probed fs.FileInfo) (string, error) { - folds, err := foldsCase(path, probed) - if err != nil { - // The variant probe failed for a reason other than absence: nothing - // proves the spelling, so the answer is the fail-closed one. - return "", errSpellingUnverifiable - } - base := filepath.Base(path) - if !folds { - return base, nil - } - - entries, err := readDir(filepath.Dir(path)) - if err != nil { - // Searchable but not listable (or gone): the fold is proven and the - // spelling cannot be read, so the scoped resolver fails closed. - return "", errSpellingUnverifiable - } - stored := "" - for _, e := range entries { - name := e.Name() - if name == base { - return base, nil - } - if stored == "" && strings.EqualFold(name, base) { - stored = name - } - } - if stored == "" { - // The probe found an entry the listing does not hold (removed in - // between): nothing to verify against. - return "", errSpellingUnverifiable - } - return stored, nil -} diff --git a/internal/codescripts/entryname_other_test.go b/internal/codescripts/entryname_other_test.go deleted file mode 100644 index 594944b99..000000000 --- a/internal/codescripts/entryname_other_test.go +++ /dev/null @@ -1,137 +0,0 @@ -//go:build !darwin && !windows - -package codescripts - -import ( - "errors" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestResolveScoped_OnAFoldingDirectory (Spec 105 FR-012, codex r3 #1 and -// r4 #1): Linux has no single-entry call that reports an entry's stored -// spelling, so on a case-folding mount (ext4 casefold, vfat, a bind mount from -// a case-insensitive host) the scoped probe for `backdoor.js` finds -// `backdoor.JS` — a file the listing and the administrator's Resolve reject. -// Round 3 refused every candidate on such a mount, which also refused a -// correctly named `daily.js` to agent tokens while the administrator ran it -// (round 4). The contract now: the fold is proven with one constant-cost probe -// (no listing on the case-sensitive filesystems every native Linux volume -// is), and only where it is proven does entryName list the directory ONCE and -// match the exact on-disk basename — so an exact name runs for every caller, -// a folded spelling is still refused with the ordinary non-disclosing -// not-found, and a mount that folds case pays one listing per existing -// candidate (a retained, documented effect). The folding lookup is simulated -// through the package's lstat seam so the rule is pinned on the case-sensitive -// filesystems CI runs on; the same test against a real folding mount (TMPDIR -// on a Docker Desktop bind mount of an APFS directory) exercises the kernel's -// own fold. -func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { - dir := t.TempDir() - writeScript(t, dir, "backdoor.JS", "({pwned: true})") - writeScript(t, dir, "exact.js", "({exact: true})") - simulateCaseFoldingLstat(t) - - // On a case-sensitive filesystem this first case is refused even without - // the listing (the real open of `backdoor.js` misses); it bites on a real - // folding mount. The listing count is what the simulation pins here. - t.Run("a folded spelling is not a stored script", func(t *testing.T) { - readDirs, lstats := countDirectoryPrimitives(t) - src, _, err := ResolveScoped(dir, "backdoor", "") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") - assert.NotContains(t, string(src), "pwned") - assert.Equal(t, 1, *readDirs, "the proven fold is resolved by exactly one listing: the .js probe hit, the .ts probe missed") - assert.LessOrEqual(t, *lstats, 4, "at most one extra probe per candidate: constant cost") - - // The administrator's directory read agrees: byte-for-byte, .JS is - // not an extension of a stored script. - _, _, err = Resolve(dir, "backdoor", "") - require.True(t, errors.As(err, ¬Found)) - assert.False(t, notFound.Undisclosed) - }) - - t.Run("an exactly spelled script on a folding mount runs for scoped callers and administrators alike", func(t *testing.T) { - readDirs, lstats := countDirectoryPrimitives(t) - src, lang, err := ResolveScoped(dir, "exact", "") - require.NoError(t, err, "a correctly named script must not be refused to an agent token (codex r4 #1)") - assert.Equal(t, "({exact: true})", string(src)) - assert.Equal(t, LanguageJavaScript, lang) - assert.Equal(t, 1, *readDirs, "the proven fold costs one listing, and only the existing candidate pays it") - assert.LessOrEqual(t, *lstats, 4) - - src, lang, err = Resolve(dir, "exact", "") - require.NoError(t, err) - assert.Equal(t, "({exact: true})", string(src)) - assert.Equal(t, LanguageJavaScript, lang) - }) - - t.Run("entryName reports the stored spelling from one listing", func(t *testing.T) { - exact := filepath.Join(dir, "exact.js") - info, err := lstat(exact) - require.NoError(t, err) - readDirs, _ := countDirectoryPrimitives(t) - stored, err := entryName(exact, info) - require.NoError(t, err) - assert.Equal(t, "exact.js", stored) - assert.Equal(t, 1, *readDirs) - - folded := filepath.Join(dir, "backdoor.js") - info, err = lstat(folded) - require.NoError(t, err, "the simulated fold finds backdoor.JS") - stored, err = entryName(folded, info) - require.NoError(t, err) - assert.Equal(t, "backdoor.JS", stored, "the on-disk spelling, which probeCandidates then rejects as a fold") - assert.Equal(t, 2, *readDirs) - }) - - t.Run("a listing the process cannot perform leaves the spelling unverifiable, fail closed", func(t *testing.T) { - orig := readDir - readDir = func(string) ([]os.DirEntry, error) { - return nil, &os.PathError{Op: "open", Path: dir, Err: os.ErrPermission} - } - t.Cleanup(func() { readDir = orig }) - - exact := filepath.Join(dir, "exact.js") - info, err := lstat(exact) - require.NoError(t, err) - _, err = entryName(exact, info) - assert.ErrorIs(t, err, errSpellingUnverifiable) - - _, _, err = ResolveScoped(dir, "exact", "") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - assert.True(t, notFound.Undisclosed, "an unverifiable spelling is the ordinary non-disclosing not-found, never the OS error") - }) -} - -// TestEntryName_ExactOnACaseSensitiveLookup is the other half: where the -// lookup does not fold (every native Linux filesystem), an entry found by its -// exact name IS that name, no directory is listed, and the scoped resolver -// keeps its O(1) probe. -func TestEntryName_ExactOnACaseSensitiveLookup(t *testing.T) { - dir := t.TempDir() - writeScript(t, dir, "exact.js", "1") - path := dir + "/exact.js" - info, err := os.Lstat(path) - require.NoError(t, err) - if folds, _ := foldsCase(path, info); folds { - t.Skip("this temp directory folds case; that branch is pinned by TestResolveScoped_OnAFoldingDirectory") - } - readDirs, lstats := countDirectoryPrimitives(t) - stored, err := entryName(path, info) - require.NoError(t, err) - assert.Equal(t, "exact.js", stored) - assert.Equal(t, 0, *readDirs, "a case-sensitive lookup is verified by the probe alone, never by a listing") - assert.Equal(t, 1, *lstats, "exactly the one fold probe") - - src, _, err := ResolveScoped(dir, "exact", "") - require.NoError(t, err) - assert.Equal(t, "1", string(src)) - assert.Equal(t, 0, *readDirs, "a scoped hit on a case-sensitive filesystem never reads the directory") -} diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go index 2642eb59a..f771ad8b7 100644 --- a/internal/codescripts/entryname_windows.go +++ b/internal/codescripts/entryname_windows.go @@ -2,19 +2,15 @@ package codescripts -import ( - "io/fs" - - "golang.org/x/sys/windows" -) +import "golang.org/x/sys/windows" // entryName returns the name the filesystem actually stores for the directory -// entry at path (probed is its Lstat result, unused here), without following -// a reparse point and without listing the directory. NTFS is case-insensitive but case-PRESERVING: a probe for +// entry at path, without following a reparse point and without listing the +// directory. NTFS is case-insensitive but case-PRESERVING: a probe for // `backdoor.js` finds `backdoor.JS`, and FindFirstFile on the exact path is // the single-entry lookup that reports the stored spelling (the same call the // standard library's filepath.EvalSymlinks uses to normalise case). -func entryName(path string, _ fs.FileInfo) (string, error) { +func entryName(path string) (string, error) { p, err := windows.UTF16PtrFromString(path) if err != nil { return "", err diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go new file mode 100644 index 000000000..5dc2a5862 --- /dev/null +++ b/internal/codescripts/storednames_other.go @@ -0,0 +1,146 @@ +//go:build !darwin && !windows + +package codescripts + +import ( + "errors" + "io/fs" + "path/filepath" + "sync" + "time" +) + +// Linux and the BSDs resolve names case-sensitively on their native +// filesystems, but a case-folding mount (vfat, an ext4 casefold directory, a +// bind mount from a case-insensitive host) finds `backdoor.JS` for +// `backdoor.js` just as APFS and NTFS do — and unlike those it offers no +// single-entry call that reports how an entry is spelled on disk: F_GETPATH +// does not exist, and a readlink of /proc/self/fd/N echoes the spelling that +// was looked up, not the one stored. The only exact answer is the directory +// listing, and a listing paid on a scoped caller's request is what Spec 105 +// FR-012 forbids: its cost grows with the directory, and paying it only when +// a probe hits (codex r5 #1) made the presence of a differently cased entry +// cost O(directory) while absence cost O(1) — a timing oracle on the stored +// names. +// +// So the scoped resolver answers from a stored-name INDEX instead: the exact +// spellings a scripts directory holds, listed once per directory GENERATION +// and validated on every request by one Lstat of the directory itself. Every +// request — hit, miss or case-variant alike — then costs the same: a +// directory Lstat and an O(1) set lookup while the index is current, one +// listing when the directory has changed since it was taken. The cost follows +// the administrator's writes, never the requested name. + +// storedNames is the exact-spelling index of one scripts directory. names is +// replaced, never mutated, so a set handed out under the lock stays valid +// after it is released. +type storedNames struct { + mu sync.Mutex + gen dirGeneration // the directory's stamp when names was listed + settled bool // gen predates the listing by more than any timestamp tick + names map[string]struct{} +} + +// storedNameIndexes holds one *storedNames per cleaned scripts directory. +var storedNameIndexes sync.Map + +// dirGeneration is the Lstat tuple that moves whenever a directory's entry +// set can have changed: adding, removing or renaming an entry updates its +// mtime and ctime (ctime cannot be set from user space, so a restored mtime — +// tar, rsync -a — does not hide a change), a replaced directory has another +// inode, and size is the cheap extra. dirGenerationOf reads it per platform. +type dirGeneration struct { + modTime, changeTime time.Time + size int64 + ino uint64 +} + +func (g dirGeneration) equal(o dirGeneration) bool { + return g.modTime.Equal(o.modTime) && g.changeTime.Equal(o.changeTime) && g.size == o.size && g.ino == o.ino +} + +// latest is the later of the two timestamps. +func (g dirGeneration) latest() time.Time { + if g.changeTime.After(g.modTime) { + return g.changeTime + } + return g.modTime +} + +// generationSettleTime is how far a directory's stamp must predate a listing +// for the index to be trusted without re-listing. Timestamps can be coarse +// (vfat: two seconds), so a write landing in the same tick as the recorded +// stamp would leave it unchanged; until the stamp is older than the coarsest +// tick, every request re-lists. The bound depends on the clock alone, never +// on the requested name. +const generationSettleTime = 2 * time.Second + +// indexClock is time.Now, a variable so the tests can settle an index +// without waiting. +var indexClock = time.Now + +// storedSpellingsOf answers, for one scoped request, whether scriptsDir holds +// an entry spelled exactly `want`: an index hit, confirmed by the candidate's +// own Lstat (the entry may have gone since the listing; the no-follow open +// remains the authoritative check). The index is validated once per request, +// and only an index hit is probed, so an absent name and a differently cased +// one cost the same. A directory that cannot be stat-ed or listed is an error +// the scoped resolver reports as unreadable, as the administrator's directory +// read always has (SC-005). +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), err error) { + names, err := storedNamesFor(scriptsDir) + if err != nil { + return nil, err + } + return func(want string) (bool, error) { + if _, ok := names[want]; !ok { + return false, nil + } + if _, err := lstat(filepath.Join(scriptsDir, want)); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil + }, nil +} + +// storedNamesFor returns the current exact-name set of scriptsDir, re-listing +// it under the directory's lock when its generation has moved or the last +// listing was taken too soon after a write to be trusted. The lock covers +// the validation and the rebuild only; callers never hold it across an open. +func storedNamesFor(scriptsDir string) (map[string]struct{}, error) { + key := filepath.Clean(scriptsDir) + v, ok := storedNameIndexes.Load(key) + if !ok { + v, _ = storedNameIndexes.LoadOrStore(key, &storedNames{}) + } + idx := v.(*storedNames) + + // Stamped before the listing, so a write that lands during it moves the + // stamp the next validation compares against. + info, err := lstat(key) + if err != nil { + return nil, err + } + gen := dirGenerationOf(info) + now := indexClock() + + idx.mu.Lock() + defer idx.mu.Unlock() + if idx.names != nil && idx.settled && idx.gen.equal(gen) { + return idx.names, nil + } + entries, err := readDir(key) + if err != nil { + return nil, err + } + names := make(map[string]struct{}, len(entries)) + for _, e := range entries { + names[e.Name()] = struct{}{} + } + idx.gen, idx.names = gen, names + idx.settled = now.Sub(gen.latest()) >= generationSettleTime + return names, nil +} diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go new file mode 100644 index 000000000..61548d1e7 --- /dev/null +++ b/internal/codescripts/storednames_other_test.go @@ -0,0 +1,280 @@ +//go:build !darwin && !windows + +package codescripts + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// simulateCaseFoldingLstat makes the package's lstat seam behave like a +// case-insensitive, case-preserving directory lookup (APFS, NTFS, ext4 +// casefold, vfat): a path that does not exist as spelled resolves to the +// entry whose name matches it case-insensitively. The listing it consults is +// the simulation's own (os.ReadDir directly), invisible to the readDir seam. +// Installed BEFORE countDirectoryPrimitives when both are used, so the +// counters see the resolver's calls and not the simulation's. +func simulateCaseFoldingLstat(t *testing.T) { + t.Helper() + orig := lstat + lstat = func(name string) (os.FileInfo, error) { + info, err := orig(name) + if err == nil || !errors.Is(err, fs.ErrNotExist) { + return info, err + } + entries, readErr := os.ReadDir(filepath.Dir(name)) + if readErr != nil { + return nil, err + } + for _, e := range entries { + if strings.EqualFold(e.Name(), filepath.Base(name)) { + return orig(filepath.Join(filepath.Dir(name), e.Name())) + } + } + return nil, err + } + t.Cleanup(func() { lstat = orig }) +} + +// settleStoredNamesClock moves the index clock far past any directory the +// test writes, so an index taken now counts as settled (a coarse-timestamp +// write can no longer share the recorded stamp) and is trusted until the +// directory's generation moves. Restored on cleanup. +func settleStoredNamesClock(t *testing.T) { + t.Helper() + orig := indexClock + indexClock = func() time.Time { return orig().Add(time.Hour) } + t.Cleanup(func() { indexClock = orig }) +} + +// warmStoredNames takes the stored-name index of dir once, with the clock +// settled, so the shared tests that count a scoped resolution's directory +// reads start from a warm index: the one listing is paid per directory +// change (pinned below), never per request. +func warmStoredNames(t *testing.T, dir string) { + t.Helper() + settleStoredNamesClock(t) + _, err := storedNamesFor(dir) + require.NoError(t, err) +} + +// latestStamp is the later of a directory generation's two timestamps. +func latestStamp(gen dirGeneration) time.Time { + if gen.changeTime.After(gen.modTime) { + return gen.changeTime + } + return gen.modTime +} + +// TestResolveScoped_OnAFoldingDirectory (Spec 105 FR-012, codex r3 #1, r4 #1 +// and r5 #1): Linux has no single-entry call that reports an entry's stored +// spelling, so on a case-folding mount (ext4 casefold, vfat, a bind mount +// from a case-insensitive host) a probe for `backdoor.js` finds `backdoor.JS` +// — a file the listing and the administrator's Resolve reject. Round 4 +// settled the spelling by a listing paid when the probe hit, which made the +// PRESENCE of a differently cased entry cost O(directory) while absence cost +// O(1): a timing oracle on the stored names (round 5). The contract now: the +// scoped resolver answers from the directory's stored-name index, so a +// folded spelling is refused with the ordinary non-disclosing not-found, an +// exact name runs for every caller, and no request lists the directory while +// the index is current. The folding lookup is simulated through the lstat +// seam so the rule is pinned on the case-sensitive filesystems CI runs on; +// the same test on a real folding mount (TMPDIR and GOTMPDIR on a Docker +// Desktop bind mount of an APFS directory) exercises the kernel's own fold. +func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "backdoor.JS", "({pwned: true})") + writeScript(t, dir, "exact.js", "({exact: true})") + simulateCaseFoldingLstat(t) + warmStoredNames(t, dir) + + t.Run("a folded spelling is not a stored script, and settling it lists nothing", func(t *testing.T) { + readDirs, lstats := countDirectoryPrimitives(t) + src, _, err := ResolveScoped(dir, "backdoor", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") + assert.NotContains(t, string(src), "pwned") + assert.Equal(t, 0, *readDirs, "a warm index answers the fold without a listing (codex r5 #1)") + assert.Equal(t, 1, *lstats, "one directory Lstat validates the index; the candidate itself is never probed") + + // The administrator's directory read agrees: byte-for-byte, .JS is + // not an extension of a stored script. + _, _, err = Resolve(dir, "backdoor", "") + require.True(t, errors.As(err, ¬Found)) + assert.False(t, notFound.Undisclosed) + }) + + t.Run("an exactly spelled script runs for scoped callers and administrators alike", func(t *testing.T) { + readDirs, lstats := countDirectoryPrimitives(t) + src, lang, err := ResolveScoped(dir, "exact", "") + require.NoError(t, err, "a correctly named script must not be refused to an agent token (codex r4 #1)") + assert.Equal(t, "({exact: true})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + assert.Equal(t, 0, *readDirs) + assert.Equal(t, 2, *lstats, "the directory Lstat plus the one hit's own probe") + + src, lang, err = Resolve(dir, "exact", "") + require.NoError(t, err) + assert.Equal(t, "({exact: true})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + }) + + t.Run("an absent name and a present case-variant cost the same, cold and warm", func(t *testing.T) { + cost := func(name string) (readDirs, lstats int) { + storedNameIndexes.Delete(filepath.Clean(dir)) // cold + rd, ls := countDirectoryPrimitives(t) + _, _, err := ResolveScoped(dir, name, "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + cold := *rd + assert.Equal(t, 1, cold, "%s: a cold index is one listing, whatever the name", name) + _, _, _ = ResolveScoped(dir, name, "") + assert.Equal(t, 1, *rd, "%s: the second request finds the index warm", name) + return cold, *ls + } + absentReadDirs, absentLstats := cost("missing") + variantReadDirs, variantLstats := cost("backdoor") + assert.Equal(t, absentReadDirs, variantReadDirs, "the listing count does not depend on the requested name") + assert.Equal(t, absentLstats, variantLstats, "nor does the probe count") + }) + + t.Run("the index holds the stored spelling, so the fold is settled by an exact lookup", func(t *testing.T) { + names, err := storedNamesFor(dir) + require.NoError(t, err) + assert.Contains(t, names, "backdoor.JS") + assert.NotContains(t, names, "backdoor.js") + assert.Contains(t, names, "exact.js") + }) +} + +// TestStoredNames_ListedOncePerDirectoryGeneration pins the cost rule of the +// index: a directory that does not change is listed once, however many +// requests are answered from it and whatever they ask for; a change (an +// entry added) is one more listing, and the next requests are warm again. +func TestStoredNames_ListedOncePerDirectoryGeneration(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + settleStoredNamesClock(t) + readDirs, _ := countDirectoryPrimitives(t) + + requests := func(names ...string) { + for i := 0; i < 20; i++ { + _, _, _ = ResolveScoped(dir, names[i%len(names)], "") + } + } + requests("alpha", "missing", "ALPHA") + assert.Equal(t, 1, *readDirs, "an unchanged directory is listed exactly once") + + before, err := lstat(dir) + require.NoError(t, err) + // The write must land on a later stamp than the one recorded, whatever + // the filesystem's timestamp granularity: past generationSettleTime is + // the guarantee the index itself relies on. + time.Sleep(time.Until(latestStamp(dirGenerationOf(before)).Add(generationSettleTime))) + writeScript(t, dir, "beta.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + src, lang, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err, "a script added after the listing is found on the next request") + assert.Equal(t, "1", string(src)) + assert.Equal(t, LanguageTypeScript, lang) + assert.Equal(t, 2, *readDirs, "the change is one more listing") + + requests("alpha", "beta", "missing") + assert.Equal(t, 2, *readDirs, "and the directory is warm again") + + require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) + _, _, err = ResolveScoped(dir, "alpha", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "a removed script is not found on the next request (%T: %v)", err, err) + assert.True(t, notFound.Undisclosed) +} + +// waitForGenerationChange confirms the directory's stamp moved with the +// write (it returns at once on every filesystem this test has met); a mount +// whose stamp never moves cannot pin the change count and is skipped. +func waitForGenerationChange(t *testing.T, dir string, was dirGeneration) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + info, err := lstat(dir) + require.NoError(t, err) + if !dirGenerationOf(info).equal(was) { + return + } + if time.Now().After(deadline) { + t.Skipf("%s: the directory's stamp did not move after a write", dir) + } + time.Sleep(20 * time.Millisecond) + } +} + +// TestStoredNames_RelistsUntilTheStampSettles pins the coarse-timestamp +// guard: an index taken within generationSettleTime of the directory's stamp +// is retaken on every request (a write in the same tick would not move the +// stamp) — for every name alike, the bound depends on the clock only — and +// once the stamp is old enough the next listing is the last. +func TestStoredNames_RelistsUntilTheStampSettles(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + info, err := lstat(dir) + require.NoError(t, err) + stamp := latestStamp(dirGenerationOf(info)) + + orig := indexClock + t.Cleanup(func() { indexClock = orig }) + indexClock = func() time.Time { return stamp.Add(generationSettleTime / 2) } + readDirs, _ := countDirectoryPrimitives(t) + for i, name := range []string{"alpha", "missing", "alpha"} { + _, _, _ = ResolveScoped(dir, name, "") + assert.Equal(t, i+1, *readDirs, "within the settle window every request re-lists") + } + + indexClock = func() time.Time { return stamp.Add(generationSettleTime) } + _, _, _ = ResolveScoped(dir, "missing", "") + assert.Equal(t, 4, *readDirs, "the first request past the window lists once more") + _, _, _ = ResolveScoped(dir, "alpha", "") + _, _, _ = ResolveScoped(dir, "missing", "") + assert.Equal(t, 4, *readDirs, "and the settled index is trusted") +} + +// TestStoredNames_UnlistableDirectoryRefusesScopedCallers: a scripts +// directory the process cannot list has no index, so the scoped resolver +// refuses — with the non-disclosing unreadable form, no path and no OS error +// — exactly where the administrator's directory read refuses (SC-005), +// rather than executing out of a directory the listing cannot vouch for. +func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions are not enforced") + } + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not enforced on Windows") + } + scriptsDir := filepath.Join(t.TempDir(), "scripts") + writeScript(t, scriptsDir, "known.js", "1") + require.NoError(t, os.Chmod(scriptsDir, 0o111)) + t.Cleanup(func() { _ = os.Chmod(scriptsDir, 0o755) }) + + src, _, err := ResolveScoped(scriptsDir, "known", "") + require.Nil(t, src) + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.True(t, invalid.Undisclosed) + assert.Equal(t, ReasonUnreadable, invalid.Reason) + assert.NotContains(t, err.Error(), scriptsDir) + assert.NotContains(t, err.Error(), "permission denied") + + _, _, err = Resolve(scriptsDir, "known", "") + require.True(t, errors.As(err, &invalid)) + assert.Equal(t, ReasonUnreadable, invalid.Reason, "the administrator is refused for the same reason") +} diff --git a/internal/codescripts/storedspellings_probe.go b/internal/codescripts/storedspellings_probe.go new file mode 100644 index 000000000..91b404237 --- /dev/null +++ b/internal/codescripts/storedspellings_probe.go @@ -0,0 +1,44 @@ +//go:build darwin || windows + +package codescripts + +import ( + "errors" + "io/fs" + "path/filepath" + "strings" +) + +// storedSpellingsOf answers, for one scoped request, whether scriptsDir holds +// an entry spelled exactly `want`, by a fixed number of single-path calls and +// never a listing (Spec 105 FR-012). The default APFS/HFS+ and NTFS volumes +// are case-insensitive but case-PRESERVING, so the probe alone would accept +// `backdoor.JS` for `backdoor.js`; a hit is accepted only when the entry's +// stored spelling (entryName, one single-entry platform call) is +// byte-for-byte the requested one, exactly as List decides. The no-follow +// open remains the authoritative check. +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), err error) { + return func(want string) (bool, error) { + path := filepath.Join(scriptsDir, want) + if _, err := lstat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + stored, err := entryName(path) + switch { + case err != nil: + // The platform call failed: the Lstat verdict stays in force and + // the no-follow open decides usability. + return true, nil + case stored != want && strings.EqualFold(stored, want): + // The filesystem folded the case: the entry is spelled differently + // and no discovery surface reports it under this name. Only a + // case-only difference is a fold; any other answer (a hard link's + // other name) leaves the Lstat verdict in force. + return false, nil + } + return true, nil + }, nil +} diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go new file mode 100644 index 000000000..b674052a9 --- /dev/null +++ b/internal/codescripts/storedspellings_probe_test.go @@ -0,0 +1,12 @@ +//go:build darwin || windows + +package codescripts + +import "testing" + +// warmStoredNames is a no-op where storedSpellingsOf is a single-entry platform +// call (darwin F_GETPATH, Windows FindFirstFile) and there is no index to +// warm; the Linux/BSD counterpart lists the directory once. +func warmStoredNames(t *testing.T, _ string) { + t.Helper() +} From 4a879bb2343e5c841eff3dc1a043c92389288f90 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 18:35:11 +0300 Subject: [PATCH 10/23] test(codescripts): drop the dead Windows skip from the Linux-tagged index test staticcheck SA4032: the file is built with !darwin && !windows, so the runtime.GOOS == "windows" guard could never fire. Co-Authored-By: Claude Opus 5 --- internal/codescripts/storednames_other_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index 61548d1e7..57e30af88 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -7,7 +7,6 @@ import ( "io/fs" "os" "path/filepath" - "runtime" "strings" "testing" "time" @@ -257,9 +256,6 @@ func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: directory permissions are not enforced") } - if runtime.GOOS == "windows" { - t.Skip("POSIX permission bits are not enforced on Windows") - } scriptsDir := filepath.Join(t.TempDir(), "scripts") writeScript(t, scriptsDir, "known.js", "1") require.NoError(t, os.Chmod(scriptsDir, 0o111)) From f3b265de4e7e914c9e5420f2659a8cea3ca3eb40 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 19:21:26 +0300 Subject: [PATCH 11/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?6=20=E2=80=94=20the=20scoped=20stored-script=20index=20is=20mai?= =?UTF-8?q?ntained=20off=20the=20request=20path;=20no=20scoped=20request?= =?UTF-8?q?=20ever=20lists=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 6 #1 (confirmed by probe: a cold scoped miss on Linux cost 24 µs in an empty scripts directory and 4.5 ms in one holding 10 000 entries, one inline listing on the request goroutine): the first scoped request, the first after a directory-generation change, and every request inside the 2 s settle window rebuilt the stored-name index INLINE, so a refusal's timing class followed the directory population. Structural answer (Linux/BSD only; the darwin/Windows probe path and the administrator resolver are untouched): - codescripts.Warm(dir) builds the index synchronously on the caller's goroutine (a rebuild already in flight is waited for, then Warm lists again). NewMCPProxyServer warms once the scripts directory is known and scriptsDir re-warms, single-flight and on its own goroutine, when the active config file path moves. - The request path is exactly one Lstat of the directory plus the O(1) set lookup. A moved generation, a never-built index or an unsettled one past its refresh window schedules ONE asynchronous single-flight rebuild (in-flight flag, landed channel, no ticker) and the request is answered from the index that exists: an exact hit is re-probed by Lstat and opened no-follow (removed/replaced fails closed), a script added since the listing is refused until the rebuild lands, a never-built index misses every name, a failed build refuses as unreadable — where the administrator's directory read refuses too. - The rebuild holds no lock across readDir, stamps before listing, installs the map atomically and re-checks the generation under the lock after listing (list-then-stamp race), listing again if it moved. The settle window changes from "re-list every request" to "schedule one async refresh per window". Tests (Linux-tagged; each proven to bite by mutation in Docker): cold request with 10 000 entries vs none — zero listings on the request goroutine, equal Lstat counts, one rebuild scheduled, executes once it lands; generation change — the request lists nothing, the async rebuild lands (held seam and the live goroutine), the next request sees the script; removed script fails closed before the rebuild lands; unsettled index refreshes at most once per window at unchanged request cost; Warm lists after an in-flight rebuild; unlistable directory — cold not-found, then non-disclosing unreadable, administrator same reason. Seam-changing helpers quiesce in-flight rebuilds first. Shared scoped tests warm the index as the server does; the server fixture lands the refresh after each write. Docs state the off-request-path index and the milliseconds window for scoped callers. Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 14 +- docs/code_execution/troubleshooting.md | 11 +- docs/features/agent-tokens.md | 13 +- internal/codescripts/codescripts_test.go | 15 +- internal/codescripts/storednames_other.go | 196 ++++++-- .../codescripts/storednames_other_test.go | 422 ++++++++++++++---- internal/codescripts/storedspellings_probe.go | 5 + .../codescripts/storedspellings_probe_test.go | 5 +- internal/server/mcp.go | 10 + internal/server/mcp_code_execution.go | 21 +- internal/server/mcp_code_scripts_test.go | 7 + 11 files changed, 591 insertions(+), 128 deletions(-) diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index 6170521af..9cc5d72e5 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -361,7 +361,9 @@ JS mv "$tmp" ~/.mcpproxy/scripts/fetch-prs.js # atomic within the same filesystem ``` -Adding or deleting a file is reflected on the next invocation or listing. +Adding or deleting a file is reflected on the next invocation or listing +(for an agent token on Linux, after the next index refresh — see +[Discovering script names](#discovering-script-names)). Editing a script **in place** while it is being invoked is the one unsupported case: the run gets whatever the read returned (validated, but unspecified). @@ -404,9 +406,13 @@ the proxy does not read the directory on its behalf at all — it probes the requested name's two candidate files and nothing else — so the refusal's cost does not grow with the number of stored scripts. (On Linux and the BSDs, which have no single-entry call reporting how a name is spelled on disk, the scoped -resolver answers from an exact-name index of the directory that is validated -by one stat of the directory per request; a listing is paid when the directory -changes, never per request, and never depends on the name asked for.) The +resolver answers from an exact-name index of the directory maintained off the +request path: built when the daemon starts, validated by one stat of the +directory per request, and refreshed by a background rebuild when that stat +finds the directory changed. No request lists the directory, cold or warm. +A script added to the directory becomes callable by agent tokens after the +next index refresh — milliseconds later; a call in that window is refused +like a missing script — while administrators see it immediately.) The refusal itself: ```text diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index 07af49b0f..c5c2b5f05 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -647,10 +647,13 @@ filesystem would open it under that name — the daemon verifies the stored spelling before running anything, so the administrator's listing, the administrator's call and an agent-token call all agree. On Linux and the BSDs (which have no single-entry call that reports how a name is spelled on disk) -an agent-token call is answered from an exact-name index of the directory, -validated by one stat of the directory per call: a listing is paid when the -directory changes, never per call, whatever name is asked for, and the -refusal body is unchanged. +an agent-token call is answered from an exact-name index of the directory +maintained off the request path — built at daemon start, validated by one +stat of the directory per call, refreshed in the background when the +directory changes — so no call lists the directory, whatever name is asked +for, and the refusal body is unchanged. A script you have just added is +callable by agent tokens after the next refresh (milliseconds; a call in that +window gets the ordinary not-found refusal) and by administrators at once. mcpproxy never creates the directory itself; `mkdir -p` it. --- diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 3d8925153..247293fd9 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -359,10 +359,15 @@ content are published to every caller by design and sit outside the invariant: cover: a missing-script error never enumerates the other script names, the script count or the scripts directory to an agent-token caller (the refusal is identical for an empty and a populated directory, and the directory is - not read on the caller's behalf: on Linux and the BSDs the scoped resolver - answers from an exact-name index of the directory validated by one stat - per call, so a listing is paid when the directory changes, never per call, - whatever name is asked for); an ambiguous or unusable script is reported by + never read on the caller's behalf: on Linux and the BSDs the scoped + resolver answers from an exact-name index of the directory that is + maintained off the request path — built when the daemon starts and + refreshed by a background rebuild whenever a call finds the directory + changed — so no call ever lists it, whatever name is asked for and however + many scripts are stored; a script added to the directory becomes callable + by agent tokens after the next index refresh, milliseconds later, while + administrators see it immediately); an ambiguous or unusable script is + reported by name and reason only, without its host path or a raw OS error; the REST listing `GET /api/v1/code/scripts` answers an agent token with `403`; administrators keep today's listing and paths; and every diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index 6c19142a0..f5bfea7f6 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -215,6 +215,7 @@ func TestResolve_ExtensionCaseIsExact(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "backdoor.JS", "({pwned: true})") writeScript(t, dir, "shouty.TS", "({pwned: true})") + warmStoredNames(t, dir) // the scoped verdict must come from a built index, not from its absence // Both resolvers decide their candidates differently (the administrator // reads the directory, the scoped caller probes the paths), so each is @@ -244,6 +245,7 @@ func TestResolve_CaseDistinctNamesAreDistinctScripts(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "foo.js", "({from: 'js'})") writeScript(t, dir, "FOO.ts", "({from: 'ts'})") + warmStoredNames(t, dir) for _, r := range bothResolvers { t.Run(r.name, func(t *testing.T) { @@ -421,6 +423,7 @@ func TestResolveScoped_NeverListsTheDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha-SENTINEL.js", "1") writeScript(t, dir, "beta.ts", "1") + warmStoredNames(t, dir) var listings int original := listForNotFound @@ -458,6 +461,7 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "dup.js", "1") writeScript(t, dir, "dup.ts", "1") + warmStoredNames(t, dir) _, _, err := ResolveScoped(dir, "dup", "") var ambiguous *AmbiguousError @@ -484,6 +488,7 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { t.Run(cell.name, func(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "bad.js", cell.content) + warmStoredNames(t, dir) _, _, err := ResolveScoped(dir, "bad", "") var invalid *InvalidError @@ -505,6 +510,7 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { } dir := t.TempDir() writeScript(t, dir, "x.js", "1") + warmStoredNames(t, dir) require.NoError(t, os.Chmod(dir, 0o000)) t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) @@ -837,10 +843,12 @@ func TestResolveEmptyScriptsDirNeverTouchesCWD(t *testing.T) { } // countDirectoryPrimitives routes the package's two directory-touching -// primitives through counters for the duration of the test. +// primitives through counters for the duration of the test. Any index +// rebuild still in flight lands before the seams change hands. func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { t.Helper() var rd, ls int + quiesceIndexRebuilds() origReadDir, origLstat := readDir, lstat readDir = func(name string) ([]os.DirEntry, error) { rd++ @@ -850,7 +858,10 @@ func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { ls++ return origLstat(name) } - t.Cleanup(func() { readDir, lstat = origReadDir, origLstat }) + t.Cleanup(func() { + quiesceIndexRebuilds() + readDir, lstat = origReadDir, origLstat + }) return &rd, &ls } diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go index 5dc2a5862..3ca1a67f0 100644 --- a/internal/codescripts/storednames_other.go +++ b/internal/codescripts/storednames_other.go @@ -24,26 +24,53 @@ import ( // names. // // So the scoped resolver answers from a stored-name INDEX instead: the exact -// spellings a scripts directory holds, listed once per directory GENERATION -// and validated on every request by one Lstat of the directory itself. Every -// request — hit, miss or case-variant alike — then costs the same: a -// directory Lstat and an O(1) set lookup while the index is current, one -// listing when the directory has changed since it was taken. The cost follows -// the administrator's writes, never the requested name. +// spellings a scripts directory holds, maintained OFF the request path. The +// index is built when the server learns its scripts directory (Warm) and +// rebuilt by a single-flight goroutine whenever a request finds it behind the +// directory's GENERATION (one Lstat of the directory itself). No request ever +// lists: it answers from the index that exists — an exact hit is re-probed by +// the candidate's own Lstat and opened no-follow, so a removed or replaced +// file fails closed; a script added since the listing is refused until the +// rebuild lands, milliseconds later (the administrator's directory read sees +// it at once). Every request — hit, miss or case-variant, cold or warm, in a +// directory of ten thousand entries or none — costs a directory Lstat and an +// O(1) set lookup (codex r6 #1). Listing cost follows the administrator's +// writes, never the requested name, and never lands on a caller's goroutine. // storedNames is the exact-spelling index of one scripts directory. names is // replaced, never mutated, so a set handed out under the lock stays valid // after it is released. type storedNames struct { mu sync.Mutex - gen dirGeneration // the directory's stamp when names was listed - settled bool // gen predates the listing by more than any timestamp tick - names map[string]struct{} + names map[string]struct{} // nil until a build has landed, or when it failed + err error // the last build's failure; nil when names is valid + gen dirGeneration // the directory's stamp when names was listed + settled bool // gen predates the listing by more than any timestamp tick + + // building is the single-flight flag: at most one rebuild goroutine per + // directory. landed is closed when that rebuild has finished, so Warm + // and the tests can wait for it without polling. + building bool + landed chan struct{} + + // refreshAfter bounds how often an UNSETTLED index schedules a refresh: + // at most once per generationSettleTime, whatever the request rate. + refreshAfter time.Time } // storedNameIndexes holds one *storedNames per cleaned scripts directory. var storedNameIndexes sync.Map +// storedNamesIndex returns the index of one cleaned scripts directory, +// creating an empty (never built) one on first use. +func storedNamesIndex(key string) *storedNames { + v, ok := storedNameIndexes.Load(key) + if !ok { + v, _ = storedNameIndexes.LoadOrStore(key, &storedNames{}) + } + return v.(*storedNames) +} + // dirGeneration is the Lstat tuple that moves whenever a directory's entry // set can have changed: adding, removing or renaming an entry updates its // mtime and ctime (ctime cannot be set from user space, so a restored mtime — @@ -68,17 +95,52 @@ func (g dirGeneration) latest() time.Time { } // generationSettleTime is how far a directory's stamp must predate a listing -// for the index to be trusted without re-listing. Timestamps can be coarse +// for the index to be trusted until the stamp moves. Timestamps can be coarse // (vfat: two seconds), so a write landing in the same tick as the recorded // stamp would leave it unchanged; until the stamp is older than the coarsest -// tick, every request re-lists. The bound depends on the clock alone, never -// on the requested name. +// tick, requests keep scheduling a refresh — at most one per window, and off +// the request path. The bound depends on the clock alone, never on the +// requested name. const generationSettleTime = 2 * time.Second // indexClock is time.Now, a variable so the tests can settle an index // without waiting. var indexClock = time.Now +// spawnIndexRebuild runs one index rebuild on its own goroutine. A variable +// so the tests can hold a rebuild back and prove what a request does on its +// own goroutine, then land it deliberately. +var spawnIndexRebuild = func(rebuild func()) { go rebuild() } + +// Warm builds the stored-name index of scriptsDir on the caller's goroutine, +// so the first scoped request finds it ready. The server calls it when it +// learns its scripts directory; it is never called on a request's behalf. +// The listing is taken after Warm was called (a rebuild already in flight is +// waited for, then Warm lists again), so the index reflects the directory as +// it was at the call. A directory that cannot be stat-ed or listed leaves a +// failed index (scoped callers are refused as unreadable until the directory +// changes) and the failure is returned for logging. On darwin and Windows +// there is no index and Warm is a no-op. +func Warm(scriptsDir string) error { + key := filepath.Clean(scriptsDir) + idx := storedNamesIndex(key) + for { + idx.mu.Lock() + if !idx.building { + idx.beginRebuildLocked() + idx.mu.Unlock() + break + } + landed := idx.landed + idx.mu.Unlock() + <-landed + } + idx.rebuild(key) + idx.mu.Lock() + defer idx.mu.Unlock() + return idx.err +} + // storedSpellingsOf answers, for one scoped request, whether scriptsDir holds // an entry spelled exactly `want`: an index hit, confirmed by the candidate's // own Lstat (the entry may have gone since the listing; the no-follow open @@ -106,20 +168,17 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool }, nil } -// storedNamesFor returns the current exact-name set of scriptsDir, re-listing -// it under the directory's lock when its generation has moved or the last -// listing was taken too soon after a write to be trusted. The lock covers -// the validation and the rebuild only; callers never hold it across an open. +// storedNamesFor returns the exact-name set of scriptsDir as the index holds +// it — never listing on the caller's behalf. One Lstat of the directory reads +// its generation; when the index is behind it (or was never built, or is +// still inside the settle window) a single-flight ASYNCHRONOUS rebuild is +// scheduled and the request is answered from the index that exists: nil for +// a directory never listed, which every name misses (fail closed), the last +// build's error for one that could not be listed. func storedNamesFor(scriptsDir string) (map[string]struct{}, error) { key := filepath.Clean(scriptsDir) - v, ok := storedNameIndexes.Load(key) - if !ok { - v, _ = storedNameIndexes.LoadOrStore(key, &storedNames{}) - } - idx := v.(*storedNames) + idx := storedNamesIndex(key) - // Stamped before the listing, so a write that lands during it moves the - // stamp the next validation compares against. info, err := lstat(key) if err != nil { return nil, err @@ -129,18 +188,87 @@ func storedNamesFor(scriptsDir string) (map[string]struct{}, error) { idx.mu.Lock() defer idx.mu.Unlock() - if idx.names != nil && idx.settled && idx.gen.equal(gen) { - return idx.names, nil + switch { + case idx.names == nil && idx.err == nil: // never built + idx.scheduleRebuildLocked(key, now) + case !idx.gen.equal(gen): + idx.scheduleRebuildLocked(key, now) + case !idx.settled && !now.Before(idx.refreshAfter): + idx.scheduleRebuildLocked(key, now) } - entries, err := readDir(key) - if err != nil { - return nil, err + if idx.names == nil { + return nil, idx.err + } + return idx.names, nil +} + +// scheduleRebuildLocked starts the directory's rebuild goroutine unless one +// is already in flight, and opens the next refresh window either way. +func (idx *storedNames) scheduleRebuildLocked(key string, now time.Time) { + idx.refreshAfter = now.Add(generationSettleTime) + if idx.building { + return + } + idx.beginRebuildLocked() + spawnIndexRebuild(func() { idx.rebuild(key) }) +} + +// beginRebuildLocked claims the single-flight slot. +func (idx *storedNames) beginRebuildLocked() { + idx.building = true + idx.landed = make(chan struct{}) +} + +// rebuild lists the directory and installs the result, holding no lock across +// the listing. The stamp is read BEFORE the listing and re-read after it +// under the lock: a write that lands during the listing moves the stamp, and +// the listing is taken again rather than trusted (list-then-stamp race). +// Requests that arrive while a rebuild is in flight see building set and +// schedule nothing; their generation read precedes this re-check, so the +// re-check covers whatever they saw. Ends by releasing the slot and closing +// landed. +func (idx *storedNames) rebuild(key string) { + for { + gen, err := idx.build(key) + idx.mu.Lock() + if err == nil { + if info, statErr := lstat(key); statErr == nil && !dirGenerationOf(info).equal(gen) { + idx.mu.Unlock() + continue + } + } + idx.building = false + close(idx.landed) + idx.mu.Unlock() + return } - names := make(map[string]struct{}, len(entries)) - for _, e := range entries { - names[e.Name()] = struct{}{} +} + +// build takes one listing of key and installs it — or the failure — as the +// index, replacing names atomically under the lock. +func (idx *storedNames) build(key string) (dirGeneration, error) { + var ( + gen dirGeneration + names map[string]struct{} + ) + info, err := lstat(key) + now := indexClock() + if err == nil { + gen = dirGenerationOf(info) + var entries []fs.DirEntry + if entries, err = readDir(key); err == nil { + names = make(map[string]struct{}, len(entries)) + for _, e := range entries { + names[e.Name()] = struct{}{} + } + } } - idx.gen, idx.names = gen, names - idx.settled = now.Sub(gen.latest()) >= generationSettleTime - return names, nil + + idx.mu.Lock() + defer idx.mu.Unlock() + idx.names, idx.err, idx.gen = names, err, gen + // Settled means no write can still land on this stamp: the tick was over + // before the listing began, so nothing the listing missed shares it. + idx.settled = err == nil && now.Sub(gen.latest()) >= generationSettleTime + return gen, err } diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index 57e30af88..cbed78f1a 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -4,10 +4,12 @@ package codescripts import ( "errors" + "fmt" "io/fs" "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -24,6 +26,7 @@ import ( // counters see the resolver's calls and not the simulation's. func simulateCaseFoldingLstat(t *testing.T) { t.Helper() + quiesceIndexRebuilds() orig := lstat lstat = func(name string) (os.FileInfo, error) { info, err := orig(name) @@ -41,7 +44,27 @@ func simulateCaseFoldingLstat(t *testing.T) { } return nil, err } - t.Cleanup(func() { lstat = orig }) + t.Cleanup(func() { + quiesceIndexRebuilds() + lstat = orig + }) +} + +// quiesceIndexRebuilds waits for every rebuild goroutine the tests so far +// have left in flight. The package's seams (readDir, lstat, indexClock, +// spawnIndexRebuild) are process-wide, so a helper that installs or restores +// one must first let any rebuild still reading them land. +func quiesceIndexRebuilds() { + storedNameIndexes.Range(func(_, v any) bool { + idx := v.(*storedNames) + idx.mu.Lock() + building, landed := idx.building, idx.landed + idx.mu.Unlock() + if building { + <-landed + } + return true + }) } // settleStoredNamesClock moves the index clock far past any directory the @@ -50,28 +73,90 @@ func simulateCaseFoldingLstat(t *testing.T) { // directory's generation moves. Restored on cleanup. func settleStoredNamesClock(t *testing.T) { t.Helper() + quiesceIndexRebuilds() orig := indexClock indexClock = func() time.Time { return orig().Add(time.Hour) } - t.Cleanup(func() { indexClock = orig }) + t.Cleanup(func() { + quiesceIndexRebuilds() + indexClock = orig + }) } -// warmStoredNames takes the stored-name index of dir once, with the clock +// warmStoredNames builds the stored-name index of dir once, with the clock // settled, so the shared tests that count a scoped resolution's directory -// reads start from a warm index: the one listing is paid per directory -// change (pinned below), never per request. +// reads start from a warm index — as the server does at construction. The +// one listing is paid off the request path (pinned below), never per request. func warmStoredNames(t *testing.T, dir string) { t.Helper() settleStoredNamesClock(t) - _, err := storedNamesFor(dir) - require.NoError(t, err) + require.NoError(t, Warm(dir)) +} + +// heldRebuilds is the test's grip on the rebuild goroutines: while installed, +// a request that schedules a rebuild hands it here instead of spawning it, so +// what the request does on its OWN goroutine is exactly what the counters +// see, and the rebuild lands only when the test says so. +type heldRebuilds struct { + mu sync.Mutex + held []func() } -// latestStamp is the later of a directory generation's two timestamps. -func latestStamp(gen dirGeneration) time.Time { - if gen.changeTime.After(gen.modTime) { - return gen.changeTime +// holdIndexRebuilds installs the grip for the test's duration; whatever is +// still held at cleanup is landed so no index is left claimed. +func holdIndexRebuilds(t *testing.T) *heldRebuilds { + t.Helper() + h := &heldRebuilds{} + quiesceIndexRebuilds() + orig := spawnIndexRebuild + spawnIndexRebuild = func(rebuild func()) { + h.mu.Lock() + defer h.mu.Unlock() + h.held = append(h.held, rebuild) } - return gen.modTime + t.Cleanup(func() { + spawnIndexRebuild = orig + h.land() + }) + return h +} + +// land runs every held rebuild on the test goroutine and reports how many +// there were — how many the requests since the last land scheduled. +func (h *heldRebuilds) land() int { + h.mu.Lock() + held := h.held + h.held = nil + h.mu.Unlock() + for _, rebuild := range held { + rebuild() + } + return len(held) +} + +// waitForIndexRebuild blocks until the rebuild goroutine of dir, if one is in +// flight, has landed: the seam a test waits on instead of sleeping. +func waitForIndexRebuild(t *testing.T, dir string) { + t.Helper() + idx := storedNamesIndex(filepath.Clean(dir)) + idx.mu.Lock() + building, landed := idx.building, idx.landed + idx.mu.Unlock() + if !building { + return + } + select { + case <-landed: + case <-time.After(10 * time.Second): + t.Fatalf("%s: the index rebuild did not land", dir) + } +} + +// requireScopedNotFound asserts the ordinary non-disclosing not-found refusal. +func requireScopedNotFound(t *testing.T, err error) { + t.Helper() + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") } // TestResolveScoped_OnAFoldingDirectory (Spec 105 FR-012, codex r3 #1, r4 #1 @@ -84,11 +169,11 @@ func latestStamp(gen dirGeneration) time.Time { // O(1): a timing oracle on the stored names (round 5). The contract now: the // scoped resolver answers from the directory's stored-name index, so a // folded spelling is refused with the ordinary non-disclosing not-found, an -// exact name runs for every caller, and no request lists the directory while -// the index is current. The folding lookup is simulated through the lstat -// seam so the rule is pinned on the case-sensitive filesystems CI runs on; -// the same test on a real folding mount (TMPDIR and GOTMPDIR on a Docker -// Desktop bind mount of an APFS directory) exercises the kernel's own fold. +// exact name runs for every caller, and no request lists the directory — +// warm or cold. The folding lookup is simulated through the lstat seam so +// the rule is pinned on the case-sensitive filesystems CI runs on; the same +// test on a real folding mount (TMPDIR and GOTMPDIR on a Docker Desktop bind +// mount of an APFS directory) exercises the kernel's own fold. func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "backdoor.JS", "({pwned: true})") @@ -99,15 +184,14 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { t.Run("a folded spelling is not a stored script, and settling it lists nothing", func(t *testing.T) { readDirs, lstats := countDirectoryPrimitives(t) src, _, err := ResolveScoped(dir, "backdoor", "") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") + requireScopedNotFound(t, err) assert.NotContains(t, string(src), "pwned") assert.Equal(t, 0, *readDirs, "a warm index answers the fold without a listing (codex r5 #1)") assert.Equal(t, 1, *lstats, "one directory Lstat validates the index; the candidate itself is never probed") // The administrator's directory read agrees: byte-for-byte, .JS is // not an extension of a stored script. + var notFound *NotFoundError _, _, err = Resolve(dir, "backdoor", "") require.True(t, errors.As(err, ¬Found)) assert.False(t, notFound.Undisclosed) @@ -129,17 +213,21 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { }) t.Run("an absent name and a present case-variant cost the same, cold and warm", func(t *testing.T) { + held := holdIndexRebuilds(t) cost := func(name string) (readDirs, lstats int) { storedNameIndexes.Delete(filepath.Clean(dir)) // cold rd, ls := countDirectoryPrimitives(t) _, _, err := ResolveScoped(dir, name, "") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - cold := *rd - assert.Equal(t, 1, cold, "%s: a cold index is one listing, whatever the name", name) - _, _, _ = ResolveScoped(dir, name, "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, *rd, "%s: a cold request lists nothing itself (codex r6 #1)", name) + assert.Equal(t, 1, held.land(), "%s: it schedules the one rebuild", name) + assert.Equal(t, 1, *rd, "%s: which is the one listing, off the request path", name) + cold := *ls + _, _, err = ResolveScoped(dir, name, "") + requireScopedNotFound(t, err) assert.Equal(t, 1, *rd, "%s: the second request finds the index warm", name) - return cold, *ls + assert.Equal(t, 0, held.land(), "%s: and schedules nothing", name) + return *rd, cold } absentReadDirs, absentLstats := cost("missing") variantReadDirs, variantLstats := cost("backdoor") @@ -156,47 +244,133 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { }) } -// TestStoredNames_ListedOncePerDirectoryGeneration pins the cost rule of the -// index: a directory that does not change is listed once, however many -// requests are answered from it and whatever they ask for; a change (an -// entry added) is one more listing, and the next requests are warm again. -func TestStoredNames_ListedOncePerDirectoryGeneration(t *testing.T) { - dir := t.TempDir() - writeScript(t, dir, "alpha.js", "1") +// TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize (codex r6 +// #1): the FIRST scoped request against a directory — before any index +// exists — must cost the same for an empty directory and for one holding ten +// thousand scripts. It lists nothing on its own goroutine, performs the same +// one directory Lstat, schedules the one rebuild and is refused fail-closed; +// the listing happens when the rebuild lands, and the next request is +// answered from it. +func TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize(t *testing.T) { + empty := t.TempDir() + crowded := t.TempDir() + for i := 0; i < 10_000; i++ { + writeScript(t, crowded, fmt.Sprintf("script-%05d.js", i), "1") + } settleStoredNamesClock(t) - readDirs, _ := countDirectoryPrimitives(t) + held := holdIndexRebuilds(t) - requests := func(names ...string) { - for i := 0; i < 20; i++ { - _, _, _ = ResolveScoped(dir, names[i%len(names)], "") - } + probe := func(dir string) (readDirs, lstats int) { + storedNameIndexes.Delete(filepath.Clean(dir)) // cold: never warmed + rd, ls := countDirectoryPrimitives(t) + _, _, err := ResolveScoped(dir, "script-00042", "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, *rd, "%s: a cold request must not list on its own goroutine", dir) + lstats = *ls + assert.Equal(t, 1, held.land(), "%s: the cold request schedules exactly one rebuild", dir) + return *rd, lstats } - requests("alpha", "missing", "ALPHA") - assert.Equal(t, 1, *readDirs, "an unchanged directory is listed exactly once") - before, err := lstat(dir) - require.NoError(t, err) - // The write must land on a later stamp than the one recorded, whatever - // the filesystem's timestamp granularity: past generationSettleTime is - // the guarantee the index itself relies on. - time.Sleep(time.Until(latestStamp(dirGenerationOf(before)).Add(generationSettleTime))) - writeScript(t, dir, "beta.ts", "1") - waitForGenerationChange(t, dir, dirGenerationOf(before)) + emptyReadDirs, emptyLstats := probe(empty) + crowdedReadDirs, crowdedLstats := probe(crowded) + assert.Equal(t, 1, emptyReadDirs, "the rebuild is the one listing") + assert.Equal(t, 1, crowdedReadDirs, "ten thousand entries are listed once, off the request path") + assert.Equal(t, emptyLstats, crowdedLstats, "the number of Lstats is independent of the directory's contents") + assert.Equal(t, 1, emptyLstats, "the request's own directory Lstat") - src, lang, err := ResolveScoped(dir, "beta", "") - require.NoError(t, err, "a script added after the listing is found on the next request") + // Landed: the script that was refused a moment ago now runs, with no + // listing on the request goroutine and none scheduled. + rd, ls := countDirectoryPrimitives(t) + src, _, err := ResolveScoped(crowded, "script-00042", "") + require.NoError(t, err, "after the rebuild lands the same request executes") assert.Equal(t, "1", string(src)) - assert.Equal(t, LanguageTypeScript, lang) - assert.Equal(t, 2, *readDirs, "the change is one more listing") + assert.Equal(t, 0, *rd) + assert.Equal(t, 2, *ls, "the directory Lstat plus the hit's own probe") + assert.Equal(t, 0, held.land()) +} - requests("alpha", "beta", "missing") - assert.Equal(t, 2, *readDirs, "and the directory is warm again") +// TestStoredNames_GenerationChangeRebuildsOffTheRequestPath pins the cost +// rule of the index: an unchanged directory is never listed again, however +// many requests are answered from it and whatever they ask for; a change (an +// entry added) is one asynchronous listing that no request performs — the +// request that notices it is refused fail-closed and the next one sees the +// new script. +func TestStoredNames_GenerationChangeRebuildsOffTheRequestPath(t *testing.T) { + t.Run("held: the request lists nothing and the landed rebuild serves the next", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + held := holdIndexRebuilds(t) + readDirs, lstats := countDirectoryPrimitives(t) - require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) - _, _, err = ResolveScoped(dir, "alpha", "") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "a removed script is not found on the next request (%T: %v)", err, err) - assert.True(t, notFound.Undisclosed) + for i, name := range []string{"alpha", "missing", "ALPHA"} { + for j := 0; j < 20; j++ { + _, _, _ = ResolveScoped(dir, name, "") + } + assert.Equal(t, 0, *readDirs, "%d: an unchanged directory is never listed", i) + assert.Equal(t, 0, held.land(), "%d: nor is a rebuild scheduled", i) + } + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "beta.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + *lstats = 0 + _, _, err = ResolveScoped(dir, "beta", "") + requireScopedNotFound(t, err) // fail closed until the rebuild lands + assert.Equal(t, 0, *readDirs, "the request that finds the generation moved lists nothing itself (codex r6 #1)") + assert.Equal(t, 1, *lstats, "one directory Lstat, no candidate probe") + assert.Equal(t, 1, held.land(), "it schedules the one rebuild") + assert.Equal(t, 1, *readDirs, "which is the one listing") + + src, lang, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err, "the script added is found once the rebuild has landed") + assert.Equal(t, "1", string(src)) + assert.Equal(t, LanguageTypeScript, lang) + assert.Equal(t, 1, *readDirs) + assert.Equal(t, 0, held.land(), "the directory is warm again") + }) + + t.Run("live: the rebuild goroutine lands and the next request sees the script", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "beta.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + readDirs, _ := countDirectoryPrimitives(t) + _, _, err = ResolveScoped(dir, "beta", "") + requireScopedNotFound(t, err) + waitForIndexRebuild(t, dir) + assert.Equal(t, 1, *readDirs, "the rebuild is the one listing") + + src, _, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err, "a script added to the directory is callable after the rebuild lands") + assert.Equal(t, "1", string(src)) + assert.Equal(t, 1, *readDirs) + + // The administrator's directory read never waited for anything. + _, _, err = Resolve(dir, "beta", "") + require.NoError(t, err) + }) +} + +// outliveStamp sleeps until the directory's latest stamp is +// generationSettleTime old, so the next write lands on a later stamp whatever +// the filesystem's timestamp granularity (Linux stamps files with the coarse +// tick clock, so a write in the same tick as the index's listing would not +// move the generation — the guarantee the index itself relies on). +func outliveStamp(t *testing.T, dir string) { + t.Helper() + info, err := lstat(dir) + require.NoError(t, err) + time.Sleep(time.Until(dirGenerationOf(info).latest().Add(generationSettleTime))) } // waitForGenerationChange confirms the directory's stamp moved with the @@ -218,40 +392,123 @@ func waitForGenerationChange(t *testing.T, dir string, was dirGeneration) { } } -// TestStoredNames_RelistsUntilTheStampSettles pins the coarse-timestamp -// guard: an index taken within generationSettleTime of the directory's stamp -// is retaken on every request (a write in the same tick would not move the -// stamp) — for every name alike, the bound depends on the clock only — and -// once the stamp is old enough the next listing is the last. -func TestStoredNames_RelistsUntilTheStampSettles(t *testing.T) { +// TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands: an index +// hit is never trusted on its own — the candidate's Lstat (and the no-follow +// open) decide — so a script removed after the listing is refused at once, +// before the rebuild that will drop it from the index has landed. +func TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + held := holdIndexRebuilds(t) + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) + waitForGenerationChange(t, dir, dirGenerationOf(before)) + readDirs, lstats := countDirectoryPrimitives(t) + _, _, err = ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, *readDirs, "the refusal lists nothing") + assert.Equal(t, 2, *lstats, "the directory Lstat and the stale hit's own probe, which misses") + + assert.Equal(t, 1, held.land(), "the removal moved the generation: one rebuild") + names, err := storedNamesFor(dir) + require.NoError(t, err) + assert.NotContains(t, names, "alpha.js") + _, _, err = ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) +} + +// TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow pins the +// coarse-timestamp guard: an index taken within generationSettleTime of the +// directory's stamp cannot rule out a write in the same tick, so requests +// keep scheduling a refresh — at most one per window, off the request path, +// for every name alike — and once a listing lands past the window the index +// is trusted until the stamp moves. The request's own cost never changes. +func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha.js", "1") info, err := lstat(dir) require.NoError(t, err) - stamp := latestStamp(dirGenerationOf(info)) + stamp := dirGenerationOf(info).latest() + quiesceIndexRebuilds() orig := indexClock t.Cleanup(func() { indexClock = orig }) indexClock = func() time.Time { return stamp.Add(generationSettleTime / 2) } + held := holdIndexRebuilds(t) + require.NoError(t, Warm(dir), "warmed inside the window: the index is not settled") + readDirs, lstats := countDirectoryPrimitives(t) + + // requests issues scoped misses and pins each one's own cost: no listing, + // one directory Lstat — the landed rebuilds' calls are counted between. + requests := func(label string, names ...string) { + for i, name := range names { + rd, ls := *readDirs, *lstats + _, _, err := ResolveScoped(dir, name, "") + requireScopedNotFound(t, err) + assert.Equal(t, rd, *readDirs, "%s %d: a request never lists", label, i) + assert.Equal(t, ls+1, *lstats, "%s %d: one directory Lstat per request", label, i) + } + } + + requests("inside the window", "missing", "gamma", "missing") + assert.Equal(t, 1, held.land(), "the unsettled index schedules ONE refresh per window, not one per request") + assert.Equal(t, 1, *readDirs) + requests("still inside", "missing", "gamma") + assert.Equal(t, 0, held.land(), "the window is open until it elapses") + + // The refresh window elapsed but the stamp is still too young: one more. + indexClock = func() time.Time { return stamp.Add(generationSettleTime/2 + generationSettleTime) } + requests("next window", "missing") + assert.Equal(t, 1, held.land(), "the next window schedules one more refresh") + assert.Equal(t, 2, *readDirs) + requests("settled", "missing", "gamma", "missing") + assert.Equal(t, 0, held.land(), "the listing landed past the stamp's settle time: the index is trusted") + assert.Equal(t, 2, *readDirs) +} + +// TestStoredNames_WarmListsAfterAnInFlightRebuild: Warm is the server's +// promise that the index reflects the directory as it was when Warm was +// called, so a rebuild already in flight — which may have listed before the +// latest write — is waited for and then Warm lists again. +func TestStoredNames_WarmListsAfterAnInFlightRebuild(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + settleStoredNamesClock(t) + held := holdIndexRebuilds(t) readDirs, _ := countDirectoryPrimitives(t) - for i, name := range []string{"alpha", "missing", "alpha"} { - _, _, _ = ResolveScoped(dir, name, "") - assert.Equal(t, i+1, *readDirs, "within the settle window every request re-lists") + + _, _, err := ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) // cold: the rebuild is scheduled and held + writeScript(t, dir, "beta.ts", "1") + + warmed := make(chan error, 1) + go func() { warmed <- Warm(dir) }() + select { + case err := <-warmed: + t.Fatalf("Warm returned %v while the rebuild it must wait for was still held", err) + case <-time.After(50 * time.Millisecond): } + assert.Equal(t, 1, held.land(), "the held rebuild lands") + require.NoError(t, <-warmed) + assert.Equal(t, 2, *readDirs, "Warm listed again after the in-flight rebuild landed") - indexClock = func() time.Time { return stamp.Add(generationSettleTime) } - _, _, _ = ResolveScoped(dir, "missing", "") - assert.Equal(t, 4, *readDirs, "the first request past the window lists once more") - _, _, _ = ResolveScoped(dir, "alpha", "") - _, _, _ = ResolveScoped(dir, "missing", "") - assert.Equal(t, 4, *readDirs, "and the settled index is trusted") + src, _, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err, "the script written before Warm is in the index Warm returned") + assert.Equal(t, "1", string(src)) + assert.Equal(t, 0, held.land()) } // TestStoredNames_UnlistableDirectoryRefusesScopedCallers: a scripts // directory the process cannot list has no index, so the scoped resolver -// refuses — with the non-disclosing unreadable form, no path and no OS error -// — exactly where the administrator's directory read refuses (SC-005), -// rather than executing out of a directory the listing cannot vouch for. +// refuses — cold, with the non-disclosing not-found before the build has +// run; then, the build having failed, with the non-disclosing unreadable +// form, no path and no OS error — exactly where the administrator's directory +// read refuses (SC-005), rather than executing out of a directory the +// listing cannot vouch for. func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: directory permissions are not enforced") @@ -263,6 +520,11 @@ func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { src, _, err := ResolveScoped(scriptsDir, "known", "") require.Nil(t, src) + requireScopedNotFound(t, err) // cold: no index yet, fail closed + waitForIndexRebuild(t, scriptsDir) + + src, _, err = ResolveScoped(scriptsDir, "known", "") + require.Nil(t, src) var invalid *InvalidError require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) assert.True(t, invalid.Undisclosed) @@ -273,4 +535,8 @@ func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { _, _, err = Resolve(scriptsDir, "known", "") require.True(t, errors.As(err, &invalid)) assert.Equal(t, ReasonUnreadable, invalid.Reason, "the administrator is refused for the same reason") + + err = Warm(scriptsDir) + require.Error(t, err, "Warm reports the failure for the server's log") + assert.True(t, errors.Is(err, fs.ErrPermission)) } diff --git a/internal/codescripts/storedspellings_probe.go b/internal/codescripts/storedspellings_probe.go index 91b404237..9ffcd9ed1 100644 --- a/internal/codescripts/storedspellings_probe.go +++ b/internal/codescripts/storedspellings_probe.go @@ -9,6 +9,11 @@ import ( "strings" ) +// Warm is a no-op where storedSpellingsOf is a single-entry platform call: +// there is no index to build. The Linux/BSD counterpart lists the directory +// once, off the request path. +func Warm(string) error { return nil } + // storedSpellingsOf answers, for one scoped request, whether scriptsDir holds // an entry spelled exactly `want`, by a fixed number of single-path calls and // never a listing (Spec 105 FR-012). The default APFS/HFS+ and NTFS volumes diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go index b674052a9..21810c03a 100644 --- a/internal/codescripts/storedspellings_probe_test.go +++ b/internal/codescripts/storedspellings_probe_test.go @@ -6,7 +6,10 @@ import "testing" // warmStoredNames is a no-op where storedSpellingsOf is a single-entry platform // call (darwin F_GETPATH, Windows FindFirstFile) and there is no index to -// warm; the Linux/BSD counterpart lists the directory once. +// warm; the Linux/BSD counterpart builds the index once, off the request path. func warmStoredNames(t *testing.T, _ string) { t.Helper() } + +// quiesceIndexRebuilds is a no-op here: nothing runs off the request path. +func quiesceIndexRebuilds() {} diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 0fc13e096..1824691d5 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -293,6 +293,10 @@ type MCPProxyServer struct { // Empty in constructions that did not declare one; see // activeConfigFilePath() for the fallback order. configFilePath string + + // warmedScriptsDir is the scripts directory whose stored-name index was + // last warmed (Spec 105 FR-012); scriptsDir re-warms when it moves. + warmedScriptsDir atomic.Pointer[string] } // MCPProxyOption customizes an MCPProxyServer at construction time. @@ -629,6 +633,12 @@ func NewMCPProxyServer( // Let the hooks (registered before the proxy existed) reach it. proxyRef.Store(proxy) + // Build the stored-script index now that the scripts directory is known, + // so no scoped request ever lists it (Spec 105 FR-012). + scriptsDir := proxy.scriptsDir() + proxy.warmedScriptsDir.Store(&scriptsDir) + proxy.warmStoredScripts(scriptsDir) + // Register proxy tools for the default (retrieve_tools) server proxy.registerTools(debugSearch) diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index 394f68c4f..d6ca30e49 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -559,7 +559,26 @@ func (p *MCPProxyServer) scriptsDir() string { if configFilePath == "" && p.config != nil { configFilePath = config.GetConfigPath(p.config.DataDir) } - return codescripts.DirFor(configFilePath) + dir := codescripts.DirFor(configFilePath) + if warmed := p.warmedScriptsDir.Load(); warmed != nil && *warmed != dir && p.warmedScriptsDir.CompareAndSwap(warmed, &dir) { + // The active config file moved: warm the new directory's index off + // this (possibly scoped) request's goroutine, once. + go p.warmStoredScripts(dir) + } + return dir +} + +// warmStoredScripts builds the stored-name index of dir the scoped resolver +// answers from (Spec 105 FR-012) — synchronously on the caller's goroutine, +// which is never a request's: construction, or a goroutine of its own when +// the directory moves. A directory that cannot be indexed (usually: not +// created yet) refuses scoped callers until it changes; the administrator's +// resolution does not depend on the index at all. +func (p *MCPProxyServer) warmStoredScripts(dir string) { + if err := codescripts.Warm(dir); err != nil { + p.logger.Debug("Stored-script index not built; scoped callers are refused until the directory changes (Spec 105 FR-012)", + zap.String("dir", dir), zap.Error(err)) + } } // codeExecRecordArguments builds the argument payload recorded for a diff --git a/internal/server/mcp_code_scripts_test.go b/internal/server/mcp_code_scripts_test.go index 0e21fb75c..96cd0fc96 100644 --- a/internal/server/mcp_code_scripts_test.go +++ b/internal/server/mcp_code_scripts_test.go @@ -81,9 +81,16 @@ func newStoredScriptProxyCfg(t *testing.T, configure func(*config.Config), opts return proxy, scriptsDir } +// writeStoredScript publishes one script and lets the scoped resolver's +// stored-name index catch up with it (Spec 105 FR-012): in production the +// index is refreshed off the request path milliseconds after the directory +// changes, and a scoped call in that window is refused fail-closed; the +// fixture lands that refresh deterministically instead of racing it. The +// administrator's resolution reads the directory itself and never waits. func writeStoredScript(t *testing.T, scriptsDir, filename, content string) { t.Helper() require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, filename), []byte(content), 0o644)) + require.NoError(t, codescripts.Warm(scriptsDir)) } // callCodeExecution runs the code_execution handler and returns the result. From 6c46ca96e4932754d900c2bd645628bc90026e63 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 20:31:20 +0300 Subject: [PATCH 12/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?8=20=E2=80=94=20the=20stored-script=20index=20answers=20only=20?= =?UTF-8?q?for=20its=20own=20generation,=20and=20a=20hit's=20directory=20g?= =?UTF-8?q?eneration=20is=20rechecked=20after=20the=20open=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex r7 found two genuine issues in the Linux/BSD stored-name index: 1. MUST-FIX: storedNamesFor scheduled a rebuild when it found the index behind the directory's generation, but still answered from the stale index while that rebuild was merely scheduled or in flight. A stale index that once listed an entry under an EARLIER spelling stayed good enough to authorize it — on a case-folding mount, warm `report.js`, rename it to `REPORT.JS` (moving the directory's generation), and the stale index's own Lstat of `report.js` still succeeds by folding onto the renamed file. The index now answers ONLY for the generation it was built against; any mismatch (never built, behind, or a rebuild merely scheduled/in flight) refuses exactly as a never-built index refuses, fail closed, until its own rebuild lands off the request path. A second race closed the same way: after an exact-set hit's no-follow open, the directory generation is read once more and compared to the one read before the lookup, closing the descriptor and refusing on a mismatch (gen-before == index.gen == gen-after proves the opened entry is the one the index vouched for). 2. SHOULD: rebuild() re-listed without bound while the directory's generation kept moving, and Warm could block on it forever. One rebuild now gives up after maxRebuildAttempts (3) listings — the next request's own generation check finds it stale and refuses fail-closed rather than this loop spinning to prove something it never can — and scheduleRebuildLocked withholds a new rebuild goroutine for rebuildBackoff (1s) after the previous one ends, so a continuously changing directory cannot resume the same unbounded listing cost one goroutine later. Warm's own explicit rebuild does not consume this backoff, so it never delays the very first real change shortly after startup. Red tests first (storednames_other_test.go): a stale index refuses a renamed entry rather than authorizing it (the round 7 finding's exact repro, via the lstat/generation seams); a generation change between the candidate probe and the open refuses and never reads the swapped content; a continuously changing directory bounds one rebuild's listings and lets Warm return; the backoff withholds a new rebuild until it elapses. Round-6 tests that asserted the old "answers from the existing index while stale" behavior are updated to the new fail-closed contract (one directory Lstat decides staleness, no candidate probe) with the round noted inline; the two successful-hit lstat counts gain the post-open recheck. Verified: go test -race -count=1 ./internal/codescripts/... (darwin, and Linux via golang:1.26 in Docker — root, non-root/uid1000, and against a real case-folding bind mount from the macOS host); go test -race -count=1 ./internal/httpapi/...; go test -race -count=1 -skip 'E2E|Binary| MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic| TestSocketInfoEndpoint' ./internal/server/...; go test -tags server ./internal/serveredition/...; both editions build; go vet clean on darwin, GOOS=linux, GOOS=windows, GOOS=freebsd; gofmt clean on touched files; internal/server/testdata goldens untouched. Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 20 +- docs/code_execution/troubleshooting.md | 18 +- docs/features/agent-tokens.md | 19 +- internal/codescripts/codescripts.go | 60 ++++- internal/codescripts/storednames_other.go | 173 +++++++++++--- .../codescripts/storednames_other_test.go | 213 +++++++++++++++++- internal/codescripts/storedspellings_probe.go | 10 +- 7 files changed, 440 insertions(+), 73 deletions(-) diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index 9cc5d72e5..a6e0f3779 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -406,14 +406,18 @@ the proxy does not read the directory on its behalf at all — it probes the requested name's two candidate files and nothing else — so the refusal's cost does not grow with the number of stored scripts. (On Linux and the BSDs, which have no single-entry call reporting how a name is spelled on disk, the scoped -resolver answers from an exact-name index of the directory maintained off the -request path: built when the daemon starts, validated by one stat of the -directory per request, and refreshed by a background rebuild when that stat -finds the directory changed. No request lists the directory, cold or warm. -A script added to the directory becomes callable by agent tokens after the -next index refresh — milliseconds later; a call in that window is refused -like a missing script — while administrators see it immediately.) The -refusal itself: +resolver answers ONLY from an exact-name index of the directory that matches +its CURRENT state: built when the daemon starts, validated by one stat of +the directory per request, and refreshed by a background rebuild when that +stat finds the directory changed. No request lists the directory, cold or +warm. A call landing while that rebuild is merely scheduled or in flight is +refused exactly like one against a directory the index has never seen — +never answered from what the index held before the change — so a rename +under a scoped caller's feet cannot have that caller's own probe fold onto +whatever now occupies the old name. A script added to, or renamed within, +the directory becomes callable by agent tokens after the next index +refresh — milliseconds later; retry a call refused in that window — while +administrators see the change immediately.) The refusal itself: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index c5c2b5f05..ceecea416 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -647,14 +647,18 @@ filesystem would open it under that name — the daemon verifies the stored spelling before running anything, so the administrator's listing, the administrator's call and an agent-token call all agree. On Linux and the BSDs (which have no single-entry call that reports how a name is spelled on disk) -an agent-token call is answered from an exact-name index of the directory -maintained off the request path — built at daemon start, validated by one -stat of the directory per call, refreshed in the background when the +an agent-token call is answered ONLY from an exact-name index of the +directory that matches its CURRENT state — built at daemon start, validated +by one stat of the directory per call, refreshed in the background when the directory changes — so no call lists the directory, whatever name is asked -for, and the refusal body is unchanged. A script you have just added is -callable by agent tokens after the next refresh (milliseconds; a call in that -window gets the ordinary not-found refusal) and by administrators at once. -mcpproxy never creates the directory itself; `mkdir -p` it. +for, and the refusal body is unchanged. A call landing while that refresh is +scheduled or in flight is refused exactly as one against a directory never +seen before, never served from what the index held a moment ago — a rename +cannot have a scoped caller's own probe fold onto whatever now occupies the +old name. A script you have just added or renamed is callable by agent +tokens after the next refresh (milliseconds; retry a call refused in that +window) and by administrators at once. mcpproxy never creates the directory +itself; `mkdir -p` it. --- diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 247293fd9..d69c9eb81 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -360,13 +360,18 @@ content are published to every caller by design and sit outside the invariant: script count or the scripts directory to an agent-token caller (the refusal is identical for an empty and a populated directory, and the directory is never read on the caller's behalf: on Linux and the BSDs the scoped - resolver answers from an exact-name index of the directory that is - maintained off the request path — built when the daemon starts and - refreshed by a background rebuild whenever a call finds the directory - changed — so no call ever lists it, whatever name is asked for and however - many scripts are stored; a script added to the directory becomes callable - by agent tokens after the next index refresh, milliseconds later, while - administrators see it immediately); an ambiguous or unusable script is + resolver answers ONLY from an exact-name index of the directory that + matches its CURRENT state — built when the daemon starts and refreshed by + a background rebuild whenever a call finds the directory changed — so no + call ever lists it, whatever name is asked for and however many scripts + are stored; a call that lands while that rebuild is scheduled or in + flight is refused once, exactly like a call against a directory it has + never seen, rather than answered from what the index held a moment ago — + an entry the index once listed under an earlier spelling must never still + authorize it after a rename. A script added to (or renamed within) the + directory becomes callable by agent tokens after the next index refresh, + milliseconds later — retry a call refused in that window — while + administrators see the change immediately); an ambiguous or unusable script is reported by name and reason only, without its host path or a raw OS error; the REST listing `GET /api/v1/code/scripts` answers an agent token with diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index c80f4df45..ae1ef083b 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -75,6 +75,14 @@ const ( // (Unix maps the kernel's no-follow rejection onto it). var errNonRegular = errors.New("not a regular file") +// errIndexGenerationChanged is what a post-open verifyUnchanged closure +// returns when the scripts directory's generation moved between the index +// lookup that produced a hit and this open (round 8 MUST-FIX, the +// lookup→open race): resolve treats it as an ordinary not-found, never as an +// unreadable-directory error, so it discloses nothing beyond the caller's +// own requested name. +var errIndexGenerationChanged = errors.New("codescripts: scripts directory changed between the index lookup and the open") + // Entry is one listed script (FR-007). Paths holds the single source file, or // both candidates when the name is ambiguous. type Entry struct { @@ -339,7 +347,7 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ if !disclose { candidates = probeCandidates } - found, err := candidates(scriptsDir, name) + found, verifyUnchanged, err := candidates(scriptsDir, name) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, "", notFound() @@ -379,6 +387,30 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ } defer f.Close() + // Round 8 MUST-FIX (the lookup→open race): an index hit is re-probed by + // the candidate's own Lstat, but neither that nor the open itself proves + // the file just opened is the one the index vouched for — a rename + // landing between the probe and this open can leave a DIFFERENT file + // occupying the exact name (a folded spelling of it, on a case-folding + // mount) for the descriptor's entire lifetime; a no-follow open cannot + // tell the difference, because it does not compare names, only symlink + // status. verifyUnchanged re-reads the directory's generation once more: + // gen-before (read for the lookup) == index.gen == gen-after is what + // proves the opened entry is the one the index vouched for. A mismatch + // closes the descriptor (via the defer above) and refuses rather than + // trusting it. Nil for the administrator, and for a scoped resolution + // that never reached an index hit (probeCandidates on darwin/Windows + // re-verifies per candidate instead and has no directory generation to + // recheck). + if verifyUnchanged != nil { + if verifyErr := verifyUnchanged(); verifyErr != nil { + if errors.Is(verifyErr, errIndexGenerationChanged) { + return nil, "", notFound() + } + return nil, "", invalid(path, ReasonUnreadable, verifyErr.Error()) + } + } + // Re-verify on the open descriptor: this is the file that will actually be // read, whatever the path pointed at a moment ago. info, err := f.Stat() @@ -422,10 +454,14 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ // entries that both refused to run as ambiguous. Reading the directory removes // the filesystem's matching from the loop entirely, so the two agree on every // platform. Resolve's no-follow open remains the authoritative check. -func candidatesFor(scriptsDir, name string) ([]string, error) { +// +// The third return is the post-open generation recheck probeCandidates +// supplies (round 8 MUST-FIX); the administrator's directory-based decision +// has nothing to recheck against, so it is always nil here. +func candidatesFor(scriptsDir, name string) ([]string, func() error, error) { dirEntries, err := readDir(scriptsDir) if err != nil { - return nil, err + return nil, nil, err } present := make(map[string]bool, 2) @@ -444,7 +480,7 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { found = append(found, filepath.Join(scriptsDir, name+ext)) } } - return found, nil + return found, nil, nil } // probeCandidates is candidatesFor for the SCOPED resolver: the same two @@ -463,23 +499,29 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { // per-directory index of exact names that is listed once per directory // change, never per request (storednames_other.go, codex r5 #1). The // no-follow open remains the authoritative check. -func probeCandidates(scriptsDir, name string) ([]string, error) { - storedExactly, err := storedSpellingsOf(scriptsDir) +// +// The second return is a post-open recheck (round 8 MUST-FIX, the +// lookup→open race): storedSpellingsOf's own verify closure, non-nil only +// where the platform backs a hit with a directory generation to recheck +// (storednames_other.go); the darwin/Windows probe re-verifies every +// candidate directly (entryName) and has none, so it returns nil. +func probeCandidates(scriptsDir, name string) ([]string, func() error, error) { + storedExactly, verifyUnchanged, err := storedSpellingsOf(scriptsDir) if err != nil { - return nil, err + return nil, nil, err } found := make([]string, 0, 2) for _, ext := range []string{extJS, extTS} { want := name + ext stored, err := storedExactly(want) if err != nil { - return nil, err + return nil, nil, err } if stored { found = append(found, filepath.Join(scriptsDir, want)) } } - return found, nil + return found, verifyUnchanged, nil } // listForNotFound is the directory listing newNotFoundError attaches to the diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go index 3ca1a67f0..d9c777eb1 100644 --- a/internal/codescripts/storednames_other.go +++ b/internal/codescripts/storednames_other.go @@ -36,6 +36,29 @@ import ( // directory of ten thousand entries or none — costs a directory Lstat and an // O(1) set lookup (codex r6 #1). Listing cost follows the administrator's // writes, never the requested name, and never lands on a caller's goroutine. +// +// The index answers ONLY for the generation it was built against (codex r7 +// #1 / round 8 MUST-FIX). Scheduling a rebuild is not the same as having one: +// earlier rounds handed back whatever the index held while a mismatched +// generation's rebuild was merely scheduled or in flight, and a stale index +// can still vouch for an entry under a spelling the directory no longer has +// it under — on a case-folding mount, a rename lets the stale hit's own +// Lstat fold onto whatever now occupies that name. So a generation mismatch +// refuses exactly as a never-built index refuses, fail closed, until its own +// rebuild lands (a call landing within milliseconds of a directory change is +// refused once — retry). The generation is checked once more after an +// exact-set hit's no-follow open (round 8 MUST-FIX, the lookup→open race): +// gen-before == index.gen == gen-after is what proves the file the open just +// read is the one the index vouched for, not a replacement that landed in +// the window between the probe and the open. +// +// A directory that never stops changing cannot be allowed to keep a rebuild +// goroutine re-listing forever, or Warm blocked forever, or a fresh rebuild +// spawning the instant the last one gave up (round 8 SHOULD): one rebuild +// re-lists at most maxRebuildAttempts times, and scheduleRebuildLocked +// withholds a new rebuild goroutine for rebuildBackoff after the previous one +// ends — a request's own cost is unaffected either way, since a stale or +// absent index answers fail-closed at the same one-Lstat cost regardless. // storedNames is the exact-spelling index of one scripts directory. names is // replaced, never mutated, so a set handed out under the lock stays valid @@ -56,6 +79,14 @@ type storedNames struct { // refreshAfter bounds how often an UNSETTLED index schedules a refresh: // at most once per generationSettleTime, whatever the request rate. refreshAfter time.Time + + // nextAttempt bounds how soon a NEW rebuild goroutine may start after + // the previous one finished (round 8 SHOULD): a continuously changing + // directory would otherwise let scheduleRebuildLocked spawn another + // rebuild the instant the last one gives up, listing back to back + // forever. Set at the end of every rebuild, win or lose; zero means + // none has ever finished. + nextAttempt time.Time } // storedNameIndexes holds one *storedNames per cleaned scripts directory. @@ -103,6 +134,25 @@ func (g dirGeneration) latest() time.Time { // requested name. const generationSettleTime = 2 * time.Second +// maxRebuildAttempts bounds how many times one rebuild re-lists when the +// directory's generation keeps moving out from under it (round 8 SHOULD): a +// directory that never stops changing must not keep this goroutine listing +// forever, nor block Warm forever. After the bound, whatever the last +// attempt installed stays as the index — the next request finds it stale +// against the directory's CURRENT generation and refuses fail-closed (the +// MUST-FIX rule above), rather than this loop trusting an unconfirmed +// listing or spinning on one that can never confirm. +const maxRebuildAttempts = 3 + +// rebuildBackoff is the minimum gap between the end of one rebuild goroutine +// and the start of the next for the same directory (round 8 SHOULD). Without +// it, a directory changing on every request would let scheduleRebuildLocked +// spawn a fresh rebuild the instant the bounded one above gives up — the +// same unbounded listing cost, just resumed one goroutine later. During the +// backoff a request's own cost is unchanged: one Lstat, answered fail-closed +// from whatever the index holds (or does not). +const rebuildBackoff = time.Second + // indexClock is time.Now, a variable so the tests can settle an index // without waiting. var indexClock = time.Now @@ -135,7 +185,12 @@ func Warm(scriptsDir string) error { idx.mu.Unlock() <-landed } - idx.rebuild(key) + // backoffAfter is false: Warm is the server's own explicit request for a + // current index (at startup, or when the active scripts directory + // moves), not a request-triggered rebuild guarding against runaway + // churn — it must not spend part of the round 8 SHOULD backoff a moment + // after startup refuses the very first real change to the directory. + idx.rebuild(key, false) idx.mu.Lock() defer idx.mu.Unlock() return idx.err @@ -149,12 +204,19 @@ func Warm(scriptsDir string) error { // one cost the same. A directory that cannot be stat-ed or listed is an error // the scoped resolver reports as unreadable, as the administrator's directory // read always has (SC-005). -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), err error) { - names, err := storedNamesFor(scriptsDir) +// +// The second return is a post-open recheck (round 8 MUST-FIX, the +// lookup→open race): the directory's generation as read for THIS lookup, +// wrapped so the caller can re-read it once more after the open and refuse +// if it moved — gen-before == index.gen == gen-after is what proves the file +// the open just read is the one the index vouched for, not a replacement +// that landed in the window between the probe and the open. +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func() error, err error) { + names, gen, err := storedNamesFor(scriptsDir) if err != nil { - return nil, err + return nil, nil, err } - return func(want string) (bool, error) { + storedExactly = func(want string) (bool, error) { if _, ok := names[want]; !ok { return false, nil } @@ -165,52 +227,88 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool return false, err } return true, nil - }, nil + } + verifyUnchanged = func() error { + info, err := lstat(scriptsDir) + if err != nil { + return err + } + if !dirGenerationOf(info).equal(gen) { + return errIndexGenerationChanged + } + return nil + } + return storedExactly, verifyUnchanged, nil } // storedNamesFor returns the exact-name set of scriptsDir as the index holds -// it — never listing on the caller's behalf. One Lstat of the directory reads -// its generation; when the index is behind it (or was never built, or is -// still inside the settle window) a single-flight ASYNCHRONOUS rebuild is -// scheduled and the request is answered from the index that exists: nil for -// a directory never listed, which every name misses (fail closed), the last -// build's error for one that could not be listed. -func storedNamesFor(scriptsDir string) (map[string]struct{}, error) { +// it — never listing on the caller's behalf — together with the directory +// generation this request read. One Lstat of the directory reads that +// generation; when it does not equal the index's OWN generation (never +// built, behind, or a rebuild merely scheduled or in flight for it), the +// request is answered as fail-closed as a never-built index: nil names, no +// error, nothing scheduled beyond the rebuild that (still) needs to run. +// +// Round 8 MUST-FIX: earlier rounds scheduled that rebuild but still handed +// back whatever the index held before — a stale index that had listed an +// entry under an EARLIER spelling stayed good enough to authorize it. On a +// case-folding mount that is exploitable: warm the index with `report.js`, +// rename it to `REPORT.JS` (which moves the directory's generation), and +// the stale index's own Lstat of `report.js` still succeeds by folding onto +// the renamed file — a stale index is not evidence about the directory's +// CURRENT contents, whatever it used to be right about. The index now +// answers ONLY for the generation it was built against; any other request +// gets the same non-disclosing not-found a directory it has never seen +// would get, until the rebuild it schedules lands. Documented consequence: +// a scoped call landing within milliseconds of a change to the directory is +// refused once — retry. +func storedNamesFor(scriptsDir string) (names map[string]struct{}, gen dirGeneration, err error) { key := filepath.Clean(scriptsDir) idx := storedNamesIndex(key) info, err := lstat(key) if err != nil { - return nil, err + return nil, dirGeneration{}, err } - gen := dirGenerationOf(info) + gen = dirGenerationOf(info) now := indexClock() idx.mu.Lock() defer idx.mu.Unlock() + + // current is whether the index is BUILT and answers for exactly this + // generation — the only condition under which it may be trusted at all. + current := (idx.names != nil || idx.err != nil) && idx.gen.equal(gen) + switch { - case idx.names == nil && idx.err == nil: // never built - idx.scheduleRebuildLocked(key, now) - case !idx.gen.equal(gen): + case !current: idx.scheduleRebuildLocked(key, now) case !idx.settled && !now.Before(idx.refreshAfter): idx.scheduleRebuildLocked(key, now) } - if idx.names == nil { - return nil, idx.err + + if !current { + return nil, dirGeneration{}, nil } - return idx.names, nil + return idx.names, gen, idx.err } // scheduleRebuildLocked starts the directory's rebuild goroutine unless one -// is already in flight, and opens the next refresh window either way. +// is already in flight or the backoff since the last one has not elapsed +// (round 8 SHOULD), and opens the next refresh window either way. During the +// backoff a request's own cost is unaffected — one Lstat, answered +// fail-closed from whatever the index holds (or does not) — only a NEW +// rebuild goroutine is withheld. func (idx *storedNames) scheduleRebuildLocked(key string, now time.Time) { idx.refreshAfter = now.Add(generationSettleTime) if idx.building { return } + if !idx.nextAttempt.IsZero() && now.Before(idx.nextAttempt) { + return + } idx.beginRebuildLocked() - spawnIndexRebuild(func() { idx.rebuild(key) }) + spawnIndexRebuild(func() { idx.rebuild(key, true) }) } // beginRebuildLocked claims the single-flight slot. @@ -222,22 +320,35 @@ func (idx *storedNames) beginRebuildLocked() { // rebuild lists the directory and installs the result, holding no lock across // the listing. The stamp is read BEFORE the listing and re-read after it // under the lock: a write that lands during the listing moves the stamp, and -// the listing is taken again rather than trusted (list-then-stamp race). -// Requests that arrive while a rebuild is in flight see building set and -// schedule nothing; their generation read precedes this re-check, so the -// re-check covers whatever they saw. Ends by releasing the slot and closing -// landed. -func (idx *storedNames) rebuild(key string) { - for { +// the listing is taken again rather than trusted (list-then-stamp race) — up +// to maxRebuildAttempts (round 8 SHOULD): a directory that never stops +// changing cannot keep this goroutine re-listing forever, nor keep Warm +// blocked forever. Giving up leaves whatever the LAST attempt installed; +// that attempt's own generation almost certainly no longer matches the +// directory's current one (it kept moving), so storedNamesFor's own check +// finds the index stale and refuses fail-closed exactly as it would a +// rebuild still in flight — this loop never leaves a lie standing, it +// simply stops asserting anything. Requests that arrive while a rebuild is +// in flight see building set and schedule nothing; their generation read +// precedes this re-check, so the re-check covers whatever they saw. Ends by +// releasing the slot and closing landed; when backoffAfter is set (every +// spawnIndexRebuild-triggered call — Warm's own direct call passes false), +// it also opens the backoff window before another rebuild of this directory +// may start. +func (idx *storedNames) rebuild(key string, backoffAfter bool) { + for attempt := 1; ; attempt++ { gen, err := idx.build(key) idx.mu.Lock() - if err == nil { + if err == nil && attempt < maxRebuildAttempts { if info, statErr := lstat(key); statErr == nil && !dirGenerationOf(info).equal(gen) { idx.mu.Unlock() continue } } idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) + } close(idx.landed) idx.mu.Unlock() return diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index cbed78f1a..7af94f01d 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -204,7 +204,7 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { assert.Equal(t, "({exact: true})", string(src)) assert.Equal(t, LanguageJavaScript, lang) assert.Equal(t, 0, *readDirs) - assert.Equal(t, 2, *lstats, "the directory Lstat plus the one hit's own probe") + assert.Equal(t, 3, *lstats, "the directory Lstat, the hit's own probe, and the post-open generation recheck (round 8 MUST-FIX)") src, lang, err = Resolve(dir, "exact", "") require.NoError(t, err) @@ -236,7 +236,7 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { }) t.Run("the index holds the stored spelling, so the fold is settled by an exact lookup", func(t *testing.T) { - names, err := storedNamesFor(dir) + names, _, err := storedNamesFor(dir) require.NoError(t, err) assert.Contains(t, names, "backdoor.JS") assert.NotContains(t, names, "backdoor.js") @@ -244,6 +244,103 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { }) } +// TestResolveScoped_StaleIndexRefusesARenamedEntry (Spec 105 FR-012, codex r7 +// #1 / round 8 MUST-FIX): earlier rounds scheduled a rebuild when a request +// found the index behind the directory's generation, but still answered +// from the index as it stood before — a stale index that once listed an +// entry under an EARLIER spelling stayed good enough to authorize it. On a +// case-folding mount that executes the wrong file: warm the index with +// `report.js`, then rename it to `REPORT.JS` (a real rename, so the +// directory's generation genuinely moves); the stale index still contains +// `report.js`, and that entry's own Lstat — simulated through the lstat seam +// so the fold is exercised on the case-sensitive filesystems CI runs on — +// folds onto the renamed file and succeeds, which round 7 trusted as a hit. +// The fix: the index answers ONLY for the generation it was built against, +// so a request landing while the rebuild is merely scheduled is refused +// exactly like a never-built index, without ever probing the candidate the +// stale index used to hold. +func TestResolveScoped_StaleIndexRefusesARenamedEntry(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "report.js", "({pwned: true})") + simulateCaseFoldingLstat(t) + warmStoredNames(t, dir) + held := holdIndexRebuilds(t) + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + require.NoError(t, os.Rename(filepath.Join(dir, "report.js"), filepath.Join(dir, "REPORT.JS"))) + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + readDirs, lstats := countDirectoryPrimitives(t) + src, _, err := ResolveScoped(dir, "report", "") + requireScopedNotFound(t, err) + assert.Nil(t, src, "the stale index must never authorize the renamed file, whatever its own Lstat folds onto") + assert.Equal(t, 0, *readDirs, "the refusal lists nothing (it is fail-closed on the generation mismatch alone)") + assert.Equal(t, 1, *lstats, "one directory Lstat decides staleness; the stale index's candidate is never probed") + assert.Equal(t, 1, held.land(), "the rename moved the generation: one rebuild is scheduled") + assert.Equal(t, 1, *readDirs, "which is the one listing, off the request path") + + // The rebuild has landed: the index now holds REPORT.JS, not report.js. + // The old spelling is still refused — never executed — for the same + // reason the administrator's byte-for-byte decision refuses it too. + src, _, err = ResolveScoped(dir, "report", "") + requireScopedNotFound(t, err) + assert.Nil(t, src) + + var notFound *NotFoundError + _, _, err = Resolve(dir, "report", "") + require.True(t, errors.As(err, ¬Found)) + assert.False(t, notFound.Undisclosed) +} + +// TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses (round 8 +// MUST-FIX, the lookup→open race): an index hit is re-probed by the +// candidate's own Lstat, but neither that nor a successful no-follow open +// proves the file just opened is the one the index vouched for — a write +// landing between the probe and the open can leave a DIFFERENT file +// occupying the exact name for the descriptor's entire lifetime, and a +// no-follow open does not compare names, only symlink status. The directory +// generation is read once more after the open and must still equal the one +// read before the lookup; a mismatch closes the descriptor and refuses. The +// race is simulated deterministically through the lstat seam: the +// directory's SECOND Lstat this request performs (the post-open recheck) is +// where a real race could land at an arbitrary point, so that is where the +// swap happens here. +func TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "({original: true})") + warmStoredNames(t, dir) + + orig := lstat + seenDirLstats := 0 + t.Cleanup(func() { lstat = orig }) + lstat = func(name string) (os.FileInfo, error) { + if name == dir { + seenDirLstats++ + if seenDirLstats == 2 { + // Races the open: a write lands after the index vouched for + // the candidate but before the descriptor is trusted. + require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "alpha.js"), []byte("({swapped: true})"), 0o644)) + // A same-name remove-then-recreate can land on the exact + // same coarse directory timestamp as the original write (a + // container filesystem observed to do this even at + // nanosecond "resolution"): force the generation forward so + // it is unambiguously the write's, not the clock's + // granularity, that the recheck must catch. + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Second))) + } + } + return orig(name) + } + + src, _, err := ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) + assert.Nil(t, src, "a file swapped in during the open's own window must never be read, original or swapped content alike") + assert.Equal(t, 2, seenDirLstats, "the directory Lstat before the lookup and the recheck after the open") +} + // TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize (codex r6 // #1): the FIRST scoped request against a directory — before any index // exists — must cost the same for an empty directory and for one holding ten @@ -285,7 +382,7 @@ func TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize(t *testing.T) require.NoError(t, err, "after the rebuild lands the same request executes") assert.Equal(t, "1", string(src)) assert.Equal(t, 0, *rd) - assert.Equal(t, 2, *ls, "the directory Lstat plus the hit's own probe") + assert.Equal(t, 3, *ls, "the directory Lstat, the hit's own probe, and the post-open generation recheck (round 8 MUST-FIX)") assert.Equal(t, 0, held.land()) } @@ -392,10 +489,16 @@ func waitForGenerationChange(t *testing.T, dir string, was dirGeneration) { } } -// TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands: an index -// hit is never trusted on its own — the candidate's Lstat (and the no-follow -// open) decide — so a script removed after the listing is refused at once, -// before the rebuild that will drop it from the index has landed. +// TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands: a stale +// index — its rebuild scheduled by a generation change but not yet landed — +// is refused exactly as a never-built index is (round 8 MUST-FIX): a script +// removed after the last listing is refused at once, before the rebuild +// that will drop it from the index has even STARTED to run, and WITHOUT +// probing the candidate the stale index used to hold (earlier rounds still +// answered from that stale index and let the candidate's own Lstat, which +// happened to miss here, catch the removal — a stale index is refused on +// the generation mismatch alone now, so there is nothing left for a +// candidate probe to catch or miss). func TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha.js", "1") @@ -411,10 +514,10 @@ func TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands(t *testing.T) _, _, err = ResolveScoped(dir, "alpha", "") requireScopedNotFound(t, err) assert.Equal(t, 0, *readDirs, "the refusal lists nothing") - assert.Equal(t, 2, *lstats, "the directory Lstat and the stale hit's own probe, which misses") + assert.Equal(t, 1, *lstats, "the directory Lstat alone decides staleness; the stale index's candidate is never probed (round 8 MUST-FIX)") assert.Equal(t, 1, held.land(), "the removal moved the generation: one rebuild") - names, err := storedNamesFor(dir) + names, _, err := storedNamesFor(dir) require.NoError(t, err) assert.NotContains(t, names, "alpha.js") _, _, err = ResolveScoped(dir, "alpha", "") @@ -470,6 +573,98 @@ func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { assert.Equal(t, 2, *readDirs) } +// TestStoredNames_RebuildAttemptsAreBounded (round 8 SHOULD): a directory +// whose generation moves on every observation — as another process +// continuously renaming an entry would leave it — must not keep a rebuild +// goroutine re-listing forever, and must not keep Warm blocked forever +// either. rebuild gives up after maxRebuildAttempts listings whatever the +// directory keeps doing next; what the last attempt installed simply goes +// stale against the directory's true current generation, and the next +// request's own check (the MUST-FIX rule above) refuses it rather than this +// loop spinning to prove something it never can. +func TestStoredNames_RebuildAttemptsAreBounded(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + quiesceIndexRebuilds() + + origLstat, origReadDir := lstat, readDir + var lstats, readDirs int + t.Cleanup(func() { lstat, readDir = origLstat, origReadDir }) + lstat = func(name string) (os.FileInfo, error) { + if name == dir { + lstats++ + // Simulate another process continuously changing the directory: + // its own generation moves on every observation, so rebuild's + // list-then-recheck can never confirm stability. + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Duration(lstats)*time.Second))) + } + return origLstat(name) + } + readDir = func(name string) ([]fs.DirEntry, error) { + readDirs++ + return origReadDir(name) + } + + done := make(chan error, 1) + go func() { done <- Warm(dir) }() + select { + case err := <-done: + require.NoError(t, err, "a continuously changing directory must not fail Warm outright") + case <-time.After(10 * time.Second): + t.Fatal("Warm did not return against a continuously changing directory (round 8 SHOULD)") + } + assert.Equal(t, maxRebuildAttempts, readDirs, "one rebuild lists at most maxRebuildAttempts times, however long the directory keeps changing") +} + +// TestStoredNames_RebuildBackoffThrottlesReschedules (round 8 SHOULD): once +// a rebuild ends — landing cleanly or giving up after maxRebuildAttempts — +// the next one for the same directory may not start until rebuildBackoff has +// passed, whatever the request rate: without this, a directory that changes +// on every request would let scheduleRebuildLocked spawn a fresh rebuild the +// instant the bounded one above gives up, resuming the same unbounded +// listing cost one goroutine later. A request inside the backoff still +// costs one Lstat and answers fail-closed from whatever the index holds (or +// does not); only the new rebuild goroutine is withheld. +func TestStoredNames_RebuildBackoffThrottlesReschedules(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + + quiesceIndexRebuilds() + orig := indexClock + t.Cleanup(func() { indexClock = orig }) + base := orig() + indexClock = func() time.Time { return base } + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "beta.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + held := holdIndexRebuilds(t) + _, _, err = ResolveScoped(dir, "beta", "") + requireScopedNotFound(t, err) + assert.Equal(t, 1, held.land(), "the generation change schedules the first rebuild") + // indexClock is still `base`: rebuild just set nextAttempt to + // base+rebuildBackoff. + + outliveStamp(t, dir) + before2, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "gamma.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before2)) + + _, _, err = ResolveScoped(dir, "gamma", "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, held.land(), "a request inside the backoff window schedules nothing, though the generation moved again") + + indexClock = func() time.Time { return base.Add(rebuildBackoff) } + _, _, err = ResolveScoped(dir, "gamma", "") + requireScopedNotFound(t, err) + assert.Equal(t, 1, held.land(), "past the backoff, the still-unresolved generation mismatch schedules again") +} + // TestStoredNames_WarmListsAfterAnInFlightRebuild: Warm is the server's // promise that the index reflects the directory as it was when Warm was // called, so a rebuild already in flight — which may have listed before the diff --git a/internal/codescripts/storedspellings_probe.go b/internal/codescripts/storedspellings_probe.go index 9ffcd9ed1..75e65787c 100644 --- a/internal/codescripts/storedspellings_probe.go +++ b/internal/codescripts/storedspellings_probe.go @@ -22,7 +22,13 @@ func Warm(string) error { return nil } // stored spelling (entryName, one single-entry platform call) is // byte-for-byte the requested one, exactly as List decides. The no-follow // open remains the authoritative check. -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), err error) { +// +// The second return is the shared signature's post-open recheck (round 8 +// MUST-FIX on Linux/BSD, the lookup→open race): here every candidate is +// already re-verified directly, per call, against the CURRENT filesystem +// (there is no directory-generation index to fall behind), so there is +// nothing further to recheck after the open and this is always nil. +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func() error, err error) { return func(want string) (bool, error) { path := filepath.Join(scriptsDir, want) if _, err := lstat(path); err != nil { @@ -45,5 +51,5 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool return false, nil } return true, nil - }, nil + }, nil, nil } From ab0542722ea1787f8cd9c78a26322b3e3eabee8c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 21:38:51 +0300 Subject: [PATCH 13/23] fix(scope): an unreadable scripts directory is refused as unreadable on the first scoped request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 8's "stale or never-built index answers not-found" rule made a directory that lost its read bit after the index was built report not-found until an async rebuild recorded the error — the refusal shape depended on index state, and the cross-platform unreadable-directory tests failed on Linux CI (non-root). The request path now opens the directory (one constant-cost syscall, no readdir) and answers the unreadable form immediately, the same reason the administrator's listing gives, on every platform. Co-Authored-By: Claude Opus 5 --- internal/codescripts/storednames_other.go | 12 ++++++++++++ internal/codescripts/storednames_other_test.go | 17 ++++++----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go index d9c777eb1..d6eff6f91 100644 --- a/internal/codescripts/storednames_other.go +++ b/internal/codescripts/storednames_other.go @@ -5,6 +5,7 @@ package codescripts import ( "errors" "io/fs" + "os" "path/filepath" "sync" "time" @@ -270,6 +271,17 @@ func storedNamesFor(scriptsDir string) (names map[string]struct{}, gen dirGenera if err != nil { return nil, dirGeneration{}, err } + // A directory the process may not READ is answered with the unreadable + // form whatever the index holds — the same reason the administrator's + // listing gives (SC-005), and the same answer on every platform. Opening + // the directory (no readdir) is one constant-cost syscall; without it a + // scripts directory that lost its read bit after the index was built + // would be reported not-found until a rebuild recorded the error. + dirFile, err := os.Open(key) + if err != nil { + return nil, dirGeneration{}, err + } + _ = dirFile.Close() gen = dirGenerationOf(info) now := indexClock() diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index 7af94f01d..fd6902fb1 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -698,12 +698,12 @@ func TestStoredNames_WarmListsAfterAnInFlightRebuild(t *testing.T) { } // TestStoredNames_UnlistableDirectoryRefusesScopedCallers: a scripts -// directory the process cannot list has no index, so the scoped resolver -// refuses — cold, with the non-disclosing not-found before the build has -// run; then, the build having failed, with the non-disclosing unreadable -// form, no path and no OS error — exactly where the administrator's directory -// read refuses (SC-005), rather than executing out of a directory the -// listing cannot vouch for. +// directory the process cannot read is refused with the non-disclosing +// unreadable form — no path, no OS error — on the very first request, cold +// or warm, whatever the index holds: the request's own constant-cost open of +// the directory decides it, exactly where the administrator's directory read +// refuses (SC-005). (Answering not-found until a rebuild had recorded the +// error made the refusal shape depend on index state.) func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: directory permissions are not enforced") @@ -715,11 +715,6 @@ func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { src, _, err := ResolveScoped(scriptsDir, "known", "") require.Nil(t, src) - requireScopedNotFound(t, err) // cold: no index yet, fail closed - waitForIndexRebuild(t, scriptsDir) - - src, _, err = ResolveScoped(scriptsDir, "known", "") - require.Nil(t, src) var invalid *InvalidError require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) assert.True(t, invalid.Undisclosed) From 71a4007451cb30f4c35e5a05c9ae4cc3cada9273 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 22:10:40 +0300 Subject: [PATCH 14/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?9=20=E2=80=94=20the=20index=20authorizes=20only=20once=20settle?= =?UTF-8?q?d,=20the=20descriptor's=20spelling=20is=20proven=20at=20open=20?= =?UTF-8?q?on=20darwin/Windows,=20the=20generation=20includes=20the=20devi?= =?UTF-8?q?ce,=20and=20the=20index=20map=20is=20bounded=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four codex round-8 findings, all MUST/SHOULD-fixed: 1. An unsettled stored-name index (Linux/BSD) could authorize a case-renamed file when the rename landed within the coarse-timestamp settle window, because a matching generation alone was trusted before the stamp was old enough to rule out a write still landing on it. The index now authorizes a hit only when settled; the rebuild-scheduling cadence is unchanged. 2. On darwin/Windows the exact-spelling probe ran only before the open, so a case-rename racing openScriptFile's own open could execute the wrong spelling, and a probe-call failure was treated as a match (fails open). The opened descriptor's own stored spelling is now proven post-open (F_GETPATH on the executed fd / GetFinalPathNameByHandle), authoritatively, on every platform; a probe failure no longer admits a candidate. 3. dirGeneration omitted the device, so a bind-mount swap to another filesystem colliding on inode/size/mtime/ctime could reuse a stale index. st_dev is now part of the generation tuple on every unix variant. 4. The stored-name index map retained an entry for every scripts directory ever used. Warm now keeps only the active directory's index, and the map caps at 4 entries (LRU) for bare (never-Warmed) use. Fixing (1) exposed a Linux-only internal/server test regression (fixtures write-then-resolve within the same test, always inside the settle window) caught only by the maintainer's own non-root-Linux Docker repro; fixed with an exported, test-only codescripts.SetIndexClockForTest seam. Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 22 ++- docs/code_execution/troubleshooting.md | 16 +- docs/features/agent-tokens.md | 17 +- internal/codescripts/codescripts.go | 53 ++++-- internal/codescripts/dirgeneration_ctim.go | 8 +- .../codescripts/dirgeneration_ctimespec.go | 8 +- internal/codescripts/entryname_darwin.go | 21 ++- internal/codescripts/entryname_windows.go | 47 ++++- internal/codescripts/storednames_other.go | 177 ++++++++++++++++-- .../codescripts/storednames_other_test.go | 121 +++++++++++- internal/codescripts/storedspellings_probe.go | 54 ++++-- .../codescripts/storedspellings_probe_test.go | 64 ++++++- internal/server/mcp_code_scripts_test.go | 11 ++ 13 files changed, 547 insertions(+), 72 deletions(-) diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index a6e0f3779..b5e29e6de 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -414,10 +414,24 @@ warm. A call landing while that rebuild is merely scheduled or in flight is refused exactly like one against a directory the index has never seen — never answered from what the index held before the change — so a rename under a scoped caller's feet cannot have that caller's own probe fold onto -whatever now occupies the old name. A script added to, or renamed within, -the directory becomes callable by agent tokens after the next index -refresh — milliseconds later; retry a call refused in that window — while -administrators see the change immediately.) The refusal itself: +whatever now occupies the old name. Beyond that, the index only ever +*authorizes* from a stamp that is provably SETTLED — old enough (about two +seconds, the coarsest directory-timestamp granularity MCPProxy has to assume) +that no filesystem write could still land on it unseen — so a matching +generation is not, by itself, enough to trust a hit; a directory whose +timestamp is younger than that refuses every scoped call, hit or miss alike, +the same fail-closed way. A script added to, or renamed within, the +directory becomes callable by agent tokens once the index has both +refreshed AND settled — typically milliseconds for the refresh, up to about +two seconds to settle; retry a call refused in that window — while +administrators see the change immediately. On darwin and Windows, where a +single-entry platform call reports a path's stored spelling directly, that +pre-open probe is only the cheap gate: the authoritative check re-reads the +stored spelling of the file descriptor MCPProxy actually opened +(`F_GETPATH` on darwin, `GetFinalPathNameByHandle` on Windows) and compares +it byte-for-byte to the requested name, so a case-rename racing the open +itself is caught on the descriptor that would have been read, not just on +an earlier probe of the same path.) The refusal itself: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index ceecea416..b9661ae43 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -655,10 +655,18 @@ for, and the refusal body is unchanged. A call landing while that refresh is scheduled or in flight is refused exactly as one against a directory never seen before, never served from what the index held a moment ago — a rename cannot have a scoped caller's own probe fold onto whatever now occupies the -old name. A script you have just added or renamed is callable by agent -tokens after the next refresh (milliseconds; retry a call refused in that -window) and by administrators at once. mcpproxy never creates the directory -itself; `mkdir -p` it. +old name. Even once refreshed, the index only authorizes a hit once its +directory timestamp is provably SETTLED (old enough — about two seconds — +that a write could not still be landing on the same coarse tick): a script +you have just added or renamed is callable by agent tokens only after the +index has both refreshed AND settled — retry a call refused in that +window, up to about two seconds — while administrators see the change at +once; mcpproxy never creates the directory itself, `mkdir -p` it. On darwin +and Windows the pre-open check is only a cheap gate — the authoritative +check re-reads the stored spelling of the actually-opened file descriptor +(`F_GETPATH` / `GetFinalPathNameByHandle`) and refuses on any mismatch, so a +rename racing the open itself is caught there too, not just by the earlier +probe. --- diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index d69c9eb81..37ed5b7e8 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -368,10 +368,19 @@ content are published to every caller by design and sit outside the invariant: flight is refused once, exactly like a call against a directory it has never seen, rather than answered from what the index held a moment ago — an entry the index once listed under an earlier spelling must never still - authorize it after a rename. A script added to (or renamed within) the - directory becomes callable by agent tokens after the next index refresh, - milliseconds later — retry a call refused in that window — while - administrators see the change immediately); an ambiguous or unusable script is + authorize it after a rename. The index authorizes a hit only once its + directory timestamp is provably settled — old enough (roughly two + seconds, the coarsest directory-timestamp granularity assumed) that no + write could still be landing on the same tick unseen — so a matching + generation alone is not enough; a script added to (or renamed within) the + directory becomes callable by agent tokens only after the index has both + refreshed and settled — retry a call refused in that window, up to + roughly two seconds — while administrators see the change immediately. + On darwin and Windows, where a single-entry platform call reports a + path's stored spelling directly, that pre-open probe is only a cheap + gate: the authoritative check re-reads the stored spelling of the file + descriptor MCPProxy actually opened and refuses on any mismatch, so a + rename racing the open itself is caught there too); an ambiguous or unusable script is reported by name and reason only, without its host path or a raw OS error; the REST listing `GET /api/v1/code/scripts` answers an agent token with diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index ae1ef083b..511d4510a 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -83,6 +83,14 @@ var errNonRegular = errors.New("not a regular file") // own requested name. var errIndexGenerationChanged = errors.New("codescripts: scripts directory changed between the index lookup and the open") +// errSpellingUnproven is what the darwin/Windows verifyUnchanged closure +// returns when the OPENED descriptor's stored spelling (round 9 MUST-FIX) +// could not be proven to match the requested name — a mismatch (a +// case-rename or replacement landed between the pre-open probe and the +// open) or a failure of the proof call itself; resolve treats either the +// same as errIndexGenerationChanged, as an ordinary not-found. +var errSpellingUnproven = errors.New("codescripts: the opened file's stored spelling could not be proven to match the requested name") + // Entry is one listed script (FR-007). Paths holds the single source file, or // both candidates when the name is ambiguous. type Entry struct { @@ -394,17 +402,19 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ // occupying the exact name (a folded spelling of it, on a case-folding // mount) for the descriptor's entire lifetime; a no-follow open cannot // tell the difference, because it does not compare names, only symlink - // status. verifyUnchanged re-reads the directory's generation once more: - // gen-before (read for the lookup) == index.gen == gen-after is what - // proves the opened entry is the one the index vouched for. A mismatch - // closes the descriptor (via the defer above) and refuses rather than - // trusting it. Nil for the administrator, and for a scoped resolution - // that never reached an index hit (probeCandidates on darwin/Windows - // re-verifies per candidate instead and has no directory generation to - // recheck). + // status. verifyUnchanged proves this AUTHORITATIVELY on f, the + // descriptor that will actually be read (round 9 MUST-FIX, the + // PROVEN-AT-OPEN rule): on Linux/BSD by re-reading the directory's + // generation once more (gen-before == index.gen == gen-after proves the + // opened entry is the one the index vouched for); on darwin/Windows by + // reading the opened descriptor's own stored spelling (F_GETPATH / + // GetFinalPathNameByHandle) and comparing it byte-for-byte to the name + // that was requested. Either failure closes the descriptor (via the + // defer above) and refuses rather than trusting it. Nil for the + // administrator, whose candidatesFor has nothing to recheck against. if verifyUnchanged != nil { - if verifyErr := verifyUnchanged(); verifyErr != nil { - if errors.Is(verifyErr, errIndexGenerationChanged) { + if verifyErr := verifyUnchanged(f, filepath.Base(path)); verifyErr != nil { + if errors.Is(verifyErr, errIndexGenerationChanged) || errors.Is(verifyErr, errSpellingUnproven) { return nil, "", notFound() } return nil, "", invalid(path, ReasonUnreadable, verifyErr.Error()) @@ -455,10 +465,10 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ // the filesystem's matching from the loop entirely, so the two agree on every // platform. Resolve's no-follow open remains the authoritative check. // -// The third return is the post-open generation recheck probeCandidates -// supplies (round 8 MUST-FIX); the administrator's directory-based decision -// has nothing to recheck against, so it is always nil here. -func candidatesFor(scriptsDir, name string) ([]string, func() error, error) { +// The third return is the post-open authoritative recheck probeCandidates +// supplies (round 8 / round 9 MUST-FIX); the administrator's directory-based +// decision has nothing to recheck against, so it is always nil here. +func candidatesFor(scriptsDir, name string) ([]string, func(f *os.File, want string) error, error) { dirEntries, err := readDir(scriptsDir) if err != nil { return nil, nil, err @@ -500,12 +510,15 @@ func candidatesFor(scriptsDir, name string) ([]string, func() error, error) { // change, never per request (storednames_other.go, codex r5 #1). The // no-follow open remains the authoritative check. // -// The second return is a post-open recheck (round 8 MUST-FIX, the -// lookup→open race): storedSpellingsOf's own verify closure, non-nil only -// where the platform backs a hit with a directory generation to recheck -// (storednames_other.go); the darwin/Windows probe re-verifies every -// candidate directly (entryName) and has none, so it returns nil. -func probeCandidates(scriptsDir, name string) ([]string, func() error, error) { +// The second return is a post-open AUTHORITATIVE recheck (round 8 / round 9 +// MUST-FIX, the lookup→open race): storedSpellingsOf's own verify closure, +// run by resolve on the descriptor that was actually opened — on Linux/BSD a +// directory-generation recheck (storednames_other.go), on darwin/Windows a +// proof of the opened descriptor's own stored spelling +// (storedspellings_probe.go). Never nil on either platform: this is what +// makes the pre-open probe above merely a cheap gate rather than the +// authoritative decision. +func probeCandidates(scriptsDir, name string) ([]string, func(f *os.File, want string) error, error) { storedExactly, verifyUnchanged, err := storedSpellingsOf(scriptsDir) if err != nil { return nil, nil, err diff --git a/internal/codescripts/dirgeneration_ctim.go b/internal/codescripts/dirgeneration_ctim.go index e1be5bff6..380bda785 100644 --- a/internal/codescripts/dirgeneration_ctim.go +++ b/internal/codescripts/dirgeneration_ctim.go @@ -9,12 +9,16 @@ import ( ) // dirGenerationOf reads a directory's generation stamp from its Lstat result. -// The inode and ctime come from the platform stat structure, whose ctime -// field is spelled Ctim here. +// The inode, device and ctime come from the platform stat structure, whose +// ctime field is spelled Ctim here. The device is part of the stamp (round 9 +// MUST-FIX): an inode number is unique only within its device, so without it +// a bind-mount swap to another filesystem could collide on inode, size and +// both timestamps. func dirGenerationOf(info fs.FileInfo) dirGeneration { gen := dirGeneration{modTime: info.ModTime(), size: info.Size()} if st, ok := info.Sys().(*syscall.Stat_t); ok { gen.ino = uint64(st.Ino) + gen.dev = uint64(st.Dev) gen.changeTime = time.Unix(st.Ctim.Unix()) } return gen diff --git a/internal/codescripts/dirgeneration_ctimespec.go b/internal/codescripts/dirgeneration_ctimespec.go index 7b7a39647..5095a1cfd 100644 --- a/internal/codescripts/dirgeneration_ctimespec.go +++ b/internal/codescripts/dirgeneration_ctimespec.go @@ -9,12 +9,16 @@ import ( ) // dirGenerationOf reads a directory's generation stamp from its Lstat result. -// The inode and ctime come from the platform stat structure, whose ctime -// field is spelled Ctimespec here. +// The inode, device and ctime come from the platform stat structure, whose +// ctime field is spelled Ctimespec here. The device is part of the stamp +// (round 9 MUST-FIX): an inode number is unique only within its device, so +// without it a bind-mount swap to another filesystem could collide on inode, +// size and both timestamps. func dirGenerationOf(info fs.FileInfo) dirGeneration { gen := dirGeneration{modTime: info.ModTime(), size: info.Size()} if st, ok := info.Sys().(*syscall.Stat_t); ok { gen.ino = uint64(st.Ino) + gen.dev = uint64(st.Dev) gen.changeTime = time.Unix(st.Ctimespec.Unix()) } return gen diff --git a/internal/codescripts/entryname_darwin.go b/internal/codescripts/entryname_darwin.go index 9c95b36ff..7ce5eca92 100644 --- a/internal/codescripts/entryname_darwin.go +++ b/internal/codescripts/entryname_darwin.go @@ -25,9 +25,28 @@ func entryName(path string) (string, error) { return "", err } defer f.Close() + return entryNameFromFd(f.Fd()) +} + +// openedEntryName is entryName's post-open counterpart (round 9 MUST-FIX): +// it proves the stored spelling of the descriptor that will actually be +// EXECUTED, not of a separate pre-open probe of the same path — a +// case-rename or replacement landing between the pre-open probe +// (storedSpellingsOf) and openScriptFile's own open would otherwise let the +// wrong spelling run, because a case-folding volume folds the subsequent +// open onto whatever now occupies the name. F_GETPATH on the EXECUTED file's +// own descriptor is the same call entryName makes on a descriptor it opened +// itself for the pre-open probe; here it runs on the descriptor +// openScriptFile is about to read from. +func openedEntryName(f *os.File) (string, error) { + return entryNameFromFd(f.Fd()) +} +// entryNameFromFd is the shared F_GETPATH call both entryName and +// openedEntryName resolve to a base name. +func entryNameFromFd(fd uintptr) (string, error) { var buf [1024]byte // MAXPATHLEN - _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, f.Fd(), syscall.F_GETPATH, uintptr(unsafe.Pointer(&buf[0]))) + _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETPATH, uintptr(unsafe.Pointer(&buf[0]))) if errno != 0 { return "", errno } diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go index f771ad8b7..643540bb6 100644 --- a/internal/codescripts/entryname_windows.go +++ b/internal/codescripts/entryname_windows.go @@ -2,7 +2,22 @@ package codescripts -import "golang.org/x/sys/windows" +import ( + "os" + "path/filepath" + + "golang.org/x/sys/windows" +) + +// winFileNameNormalized and winVolumeNameDOS are GetFinalPathNameByHandle's +// dwFlags bits (VOLUME_NAME_DOS | FILE_NAME_NORMALIZED, both 0 — the default +// "\\?\C:\..." form); golang.org/x/sys/windows does not export Win32 +// constants that are plain flag values rather than API surface, so they are +// named here from the documented Win32 API values. +const ( + winFileNameNormalized = 0x0 + winVolumeNameDOS = 0x0 +) // entryName returns the name the filesystem actually stores for the directory // entry at path, without following a reparse point and without listing the @@ -23,3 +38,33 @@ func entryName(path string) (string, error) { _ = windows.FindClose(h) return windows.UTF16ToString(data.FileName[:]), nil } + +// openedEntryName is entryName's post-open counterpart (round 9 MUST-FIX): +// it proves the stored spelling of the descriptor that will actually be +// EXECUTED, not of a separate pre-open probe of the same path — a +// case-rename or replacement landing between the pre-open probe +// (storedSpellingsOf) and openScriptFile's own open would otherwise let the +// wrong spelling run, because NTFS folds the subsequent open onto whatever +// now occupies the name. GetFinalPathNameByHandle on the EXECUTED file's own +// handle is the call that reports the normalized path NTFS actually opened, +// unlike the requested path, which merely echoes what was asked for. +func openedEntryName(f *os.File) (string, error) { + h := windows.Handle(f.Fd()) + flags := uint32(winFileNameNormalized | winVolumeNameDOS) + + buf := make([]uint16, 1024) + n, err := windows.GetFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) + if err != nil { + return "", err + } + if int(n) > len(buf) { + // The path did not fit; n is the required length (including the + // terminator) and the call did not error, so retry once at that size. + buf = make([]uint16, n) + n, err = windows.GetFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) + if err != nil { + return "", err + } + } + return filepath.Base(windows.UTF16ToString(buf[:n])), nil +} diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go index d6eff6f91..25a8fdb5b 100644 --- a/internal/codescripts/storednames_other.go +++ b/internal/codescripts/storednames_other.go @@ -53,6 +53,22 @@ import ( // read is the one the index vouched for, not a replacement that landed in // the window between the probe and the open. // +// A matching generation is not enough on its own (round 9 MUST-FIX): a +// coarse filesystem timestamp (vfat: two seconds) can leave a directory's +// stamp UNCHANGED across a rename that lands in the same tick as the stamp +// the index was listed against — the index is then "current" by the +// gen-equality test above while still blind to the rename, and the renamed +// entry's own Lstat folds onto it on a case-folding mount exactly as a stale +// index's does. An index may therefore AUTHORIZE a hit only once it is +// SETTLED: its stamp predates the listing by at least generationSettleTime, +// so no write still landing on that stamp could have escaped it. An +// unsettled index — current or not — is refused with the same non-disclosing +// not-found a never-built index gets; the documented consequence is that a +// scoped call is refused for up to ~generationSettleTime after any change to +// the scripts directory (retry). The rebuild-scheduling cadence below is +// unaffected by this: it already runs at most once per settle window while +// unsettled, whether or not this request's own hit is authorized. +// // A directory that never stops changing cannot be allowed to keep a rebuild // goroutine re-listing forever, or Warm blocked forever, or a fresh rebuild // spawning the instant the last one gave up (round 8 SHOULD): one rebuild @@ -90,32 +106,133 @@ type storedNames struct { nextAttempt time.Time } -// storedNameIndexes holds one *storedNames per cleaned scripts directory. -var storedNameIndexes sync.Map +// storedNameIndexes holds one *storedNames per cleaned scripts directory, +// bounded so it tracks the directories actually in use rather than every +// directory ever used (round 9 SHOULD): the server calls Warm whenever the +// active scripts directory changes, and Warm keeps only the directory it was +// just called for (pruneOtherIndexesLocked) — so in normal operation exactly +// one index is warm. storedNamesIndex additionally caps the map itself at +// maxStoredNameIndexes, evicting the least-recently-used entry, for the bare +// (never-Warmed) case a scoped request alone can produce. +var ( + storedIndexesMu sync.Mutex + storedIndexes = map[string]*storedNames{} + storedIndexesLRU []string // least-recently-used first; a touched key moves to the end +) + +// maxStoredNameIndexes bounds storedIndexes for bare (never-Warmed) use. +const maxStoredNameIndexes = 4 // storedNamesIndex returns the index of one cleaned scripts directory, -// creating an empty (never built) one on first use. +// creating an empty (never built) one on first use, and records the access +// for LRU eviction. func storedNamesIndex(key string) *storedNames { - v, ok := storedNameIndexes.Load(key) + storedIndexesMu.Lock() + defer storedIndexesMu.Unlock() + idx, ok := storedIndexes[key] if !ok { - v, _ = storedNameIndexes.LoadOrStore(key, &storedNames{}) + idx = &storedNames{} + storedIndexes[key] = idx + } + touchIndexLocked(key) + evictExcessLocked() + return idx +} + +// touchIndexLocked moves key to the most-recently-used end of the LRU order. +// storedIndexesMu must be held. +func touchIndexLocked(key string) { + for i, k := range storedIndexesLRU { + if k == key { + storedIndexesLRU = append(storedIndexesLRU[:i], storedIndexesLRU[i+1:]...) + break + } + } + storedIndexesLRU = append(storedIndexesLRU, key) +} + +// evictExcessLocked drops the least-recently-used indexes once the map holds +// more than maxStoredNameIndexes. storedIndexesMu must be held. +func evictExcessLocked() { + for len(storedIndexesLRU) > maxStoredNameIndexes { + oldest := storedIndexesLRU[0] + storedIndexesLRU = storedIndexesLRU[1:] + delete(storedIndexes, oldest) + } +} + +// pruneOtherIndexesLocked drops every index but keep — Warm's own promise +// that only the active scripts directory stays warm. storedIndexesMu must be +// held. +func pruneOtherIndexesLocked(keep string) { + for k := range storedIndexes { + if k != keep { + delete(storedIndexes, k) + } + } + kept := storedIndexesLRU[:0] + for _, k := range storedIndexesLRU { + if k == keep { + kept = append(kept, k) + } + } + storedIndexesLRU = kept +} + +// forgetIndex removes one directory's index entirely, forcing the next +// storedNamesIndex(key) to start from a never-built index. Production code +// never calls this directly (pruneOtherIndexesLocked and evictExcessLocked +// cover the two bounding cases); it exists so tests can force a cold index +// without reaching into the map's internals. +func forgetIndex(key string) { + storedIndexesMu.Lock() + defer storedIndexesMu.Unlock() + delete(storedIndexes, key) + for i, k := range storedIndexesLRU { + if k == key { + storedIndexesLRU = append(storedIndexesLRU[:i], storedIndexesLRU[i+1:]...) + break + } + } +} + +// forEachIndex calls fn for every currently held index. Production code +// never needs this (each request or Warm call addresses one directory); it +// exists so tests can wait out every rebuild goroutine the suite has left in +// flight, whatever directories they touched. +func forEachIndex(fn func(*storedNames)) { + storedIndexesMu.Lock() + idxs := make([]*storedNames, 0, len(storedIndexes)) + for _, idx := range storedIndexes { + idxs = append(idxs, idx) + } + storedIndexesMu.Unlock() + for _, idx := range idxs { + fn(idx) } - return v.(*storedNames) } // dirGeneration is the Lstat tuple that moves whenever a directory's entry // set can have changed: adding, removing or renaming an entry updates its // mtime and ctime (ctime cannot be set from user space, so a restored mtime — // tar, rsync -a — does not hide a change), a replaced directory has another -// inode, and size is the cheap extra. dirGenerationOf reads it per platform. +// inode, and size is the cheap extra. dev is the device the inode lives on +// (round 9 MUST-FIX): an inode number is unique only WITHIN a device, so +// without it a bind-mount swap to another filesystem whose directory happens +// to collide on inode, size, mtime and ctime would read as the SAME +// generation — the stale index would then vouch for a spelling that was +// never proven on the filesystem now actually mounted there. +// dirGenerationOf reads the tuple per platform. type dirGeneration struct { modTime, changeTime time.Time size int64 ino uint64 + dev uint64 } func (g dirGeneration) equal(o dirGeneration) bool { - return g.modTime.Equal(o.modTime) && g.changeTime.Equal(o.changeTime) && g.size == o.size && g.ino == o.ino + return g.modTime.Equal(o.modTime) && g.changeTime.Equal(o.changeTime) && + g.size == o.size && g.ino == o.ino && g.dev == o.dev } // latest is the later of the two timestamps. @@ -158,6 +275,22 @@ const rebuildBackoff = time.Second // without waiting. var indexClock = time.Now +// SetIndexClockForTest overrides the clock the settle check reads (round 9 +// MUST-FIX) and returns a func that restores it. A directory's ctime cannot +// be forged from user space — it is exactly what makes the settle window a +// real guarantee — so a caller outside this package that needs a freshly +// written scripts directory treated as settled at once (an internal/server +// fixture, say) has no way to fake it by backdating a file; it must move the +// clock the settle check reads instead, as this package's own tests do +// internally. Test-only: production code never calls this, and callers +// outside this package must restore it (defer the returned func, or +// t.Cleanup) before any other test observes the override. +func SetIndexClockForTest(now func() time.Time) (restore func()) { + prev := indexClock + indexClock = now + return func() { indexClock = prev } +} + // spawnIndexRebuild runs one index rebuild on its own goroutine. A variable // so the tests can hold a rebuild back and prove what a request does on its // own goroutine, then land it deliberately. @@ -172,9 +305,17 @@ var spawnIndexRebuild = func(rebuild func()) { go rebuild() } // failed index (scoped callers are refused as unreadable until the directory // changes) and the failure is returned for logging. On darwin and Windows // there is no index and Warm is a no-op. +// +// Warm also keeps ONLY scriptsDir's index (round 9 SHOULD): the server calls +// Warm whenever the active scripts directory changes, so this is the point +// that knows which directory is current — every other directory's index is +// dropped rather than left to accumulate for as long as the process runs. func Warm(scriptsDir string) error { key := filepath.Clean(scriptsDir) idx := storedNamesIndex(key) + storedIndexesMu.Lock() + pruneOtherIndexesLocked(key) + storedIndexesMu.Unlock() for { idx.mu.Lock() if !idx.building { @@ -212,7 +353,7 @@ func Warm(scriptsDir string) error { // if it moved — gen-before == index.gen == gen-after is what proves the file // the open just read is the one the index vouched for, not a replacement // that landed in the window between the probe and the open. -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func() error, err error) { +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func(f *os.File, want string) error, err error) { names, gen, err := storedNamesFor(scriptsDir) if err != nil { return nil, nil, err @@ -229,7 +370,12 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool } return true, nil } - verifyUnchanged = func() error { + // f and want are unused here: the index's own generation recheck (below) + // is what this platform can prove, and it needs neither the opened + // descriptor nor the requested spelling — see the darwin/Windows + // counterpart in storedspellings_probe.go, which proves the spelling + // itself on f because it has no directory-generation index to recheck. + verifyUnchanged = func(_ *os.File, _ string) error { info, err := lstat(scriptsDir) if err != nil { return err @@ -289,7 +435,10 @@ func storedNamesFor(scriptsDir string) (names map[string]struct{}, gen dirGenera defer idx.mu.Unlock() // current is whether the index is BUILT and answers for exactly this - // generation — the only condition under which it may be trusted at all. + // generation — the necessary condition for scheduling logic below, which + // stays exactly as round 8 left it: an out-of-date generation always + // reschedules, an in-date-but-unsettled one reschedules at most once per + // window. current := (idx.names != nil || idx.err != nil) && idx.gen.equal(gen) switch { @@ -299,7 +448,11 @@ func storedNamesFor(scriptsDir string) (names map[string]struct{}, gen dirGenera idx.scheduleRebuildLocked(key, now) } - if !current { + // authorized additionally requires the index to be SETTLED (round 9 + // MUST-FIX, doc comment above): a matching-but-unsettled generation is + // refused exactly as a mismatched one is, because a coarse timestamp + // cannot rule out a rename that landed on the very stamp being trusted. + if !current || !idx.settled { return nil, dirGeneration{}, nil } return idx.names, gen, idx.err diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index fd6902fb1..ce3b51967 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -55,15 +55,13 @@ func simulateCaseFoldingLstat(t *testing.T) { // spawnIndexRebuild) are process-wide, so a helper that installs or restores // one must first let any rebuild still reading them land. func quiesceIndexRebuilds() { - storedNameIndexes.Range(func(_, v any) bool { - idx := v.(*storedNames) + forEachIndex(func(idx *storedNames) { idx.mu.Lock() building, landed := idx.building, idx.landed idx.mu.Unlock() if building { <-landed } - return true }) } @@ -215,7 +213,7 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { t.Run("an absent name and a present case-variant cost the same, cold and warm", func(t *testing.T) { held := holdIndexRebuilds(t) cost := func(name string) (readDirs, lstats int) { - storedNameIndexes.Delete(filepath.Clean(dir)) // cold + forgetIndex(filepath.Clean(dir)) // cold rd, ls := countDirectoryPrimitives(t) _, _, err := ResolveScoped(dir, name, "") requireScopedNotFound(t, err) @@ -358,7 +356,7 @@ func TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize(t *testing.T) held := holdIndexRebuilds(t) probe := func(dir string) (readDirs, lstats int) { - storedNameIndexes.Delete(filepath.Clean(dir)) // cold: never warmed + forgetIndex(filepath.Clean(dir)) // cold: never warmed rd, ls := countDirectoryPrimitives(t) _, _, err := ResolveScoped(dir, "script-00042", "") requireScopedNotFound(t, err) @@ -557,20 +555,32 @@ func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { } } - requests("inside the window", "missing", "gamma", "missing") + // "alpha" is a genuinely stored script — its generation matches the + // index's the whole time — yet it must be refused exactly like "missing" + // and "gamma" until the index is SETTLED (round 9 MUST-FIX): a matching + // generation alone cannot rule out a coarse-timestamp rename that landed + // on the very stamp being trusted, so an unsettled index authorizes + // nothing, hit or miss alike. + requests("inside the window", "alpha", "missing", "gamma", "missing") assert.Equal(t, 1, held.land(), "the unsettled index schedules ONE refresh per window, not one per request") assert.Equal(t, 1, *readDirs) - requests("still inside", "missing", "gamma") + requests("still inside", "alpha", "missing", "gamma") assert.Equal(t, 0, held.land(), "the window is open until it elapses") // The refresh window elapsed but the stamp is still too young: one more. indexClock = func() time.Time { return stamp.Add(generationSettleTime/2 + generationSettleTime) } - requests("next window", "missing") + requests("next window", "alpha", "missing") assert.Equal(t, 1, held.land(), "the next window schedules one more refresh") assert.Equal(t, 2, *readDirs) requests("settled", "missing", "gamma", "missing") assert.Equal(t, 0, held.land(), "the listing landed past the stamp's settle time: the index is trusted") assert.Equal(t, 2, *readDirs) + + // Only now — genuinely settled, not merely gen-matching — does the real + // hit run (round 9 MUST-FIX). + src, _, err := ResolveScoped(dir, "alpha", "") + require.NoError(t, err, "once the index is settled, an exact hit runs") + assert.Equal(t, "1", string(src)) } // TestStoredNames_RebuildAttemptsAreBounded (round 8 SHOULD): a directory @@ -730,3 +740,98 @@ func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { require.Error(t, err, "Warm reports the failure for the server's log") assert.True(t, errors.Is(err, fs.ErrPermission)) } + +// TestDirGeneration_DeviceIsPartOfIdentity (round 9 MUST-FIX): an inode +// number is unique only WITHIN its device, so two directories on different +// devices can legitimately share an inode, size and both timestamps — a +// bind-mount swap from one filesystem to another is exactly this scenario. +// Without the device in the tuple such a swap would read as the SAME +// generation, letting a stale index vouch for a spelling never proven on the +// filesystem now actually mounted there. Pinned at the generation seam +// (dirGeneration.equal) rather than a real bind mount, which CI cannot set +// up portably. +func TestDirGeneration_DeviceIsPartOfIdentity(t *testing.T) { + shared := dirGeneration{modTime: time.Unix(1, 0), changeTime: time.Unix(1, 0), size: 4096, ino: 42} + onDeviceA := shared + onDeviceA.dev = 1 + onDeviceB := shared + onDeviceB.dev = 2 + + assert.False(t, onDeviceA.equal(onDeviceB), + "the same inode/size/timestamps on a different device must not compare equal") + assert.True(t, onDeviceA.equal(onDeviceA), "a generation always equals itself") +} + +// TestDirGenerationOf_ReadsTheDevice pins that the platform reader actually +// populates dev from a real Lstat, not just that equal() considers it. +func TestDirGenerationOf_ReadsTheDevice(t *testing.T) { + dir := t.TempDir() + info, err := lstat(dir) + require.NoError(t, err) + gen := dirGenerationOf(info) + assert.NotZero(t, gen.dev, "a real directory's device must be read, not left at the zero value") +} + +// TestStoredNames_WarmKeepsOnlyTheActiveDirectory (round 9 SHOULD): the +// server calls Warm whenever the active scripts directory changes, so Warm +// itself is where "only the active directory is warm" can be enforced — +// switching the active config path N times must leave exactly one index, +// not one per directory the process has ever served. +func TestStoredNames_WarmKeepsOnlyTheActiveDirectory(t *testing.T) { + quiesceIndexRebuilds() + settleStoredNamesClock(t) + + const n = 5 + dirs := make([]string, n) + for i := range dirs { + dirs[i] = t.TempDir() + writeScript(t, dirs[i], "alpha.js", "1") + } + + for _, d := range dirs { + require.NoError(t, Warm(d)) + } + + storedIndexesMu.Lock() + count := len(storedIndexes) + _, activeIsWarm := storedIndexes[filepath.Clean(dirs[n-1])] + storedIndexesMu.Unlock() + + assert.Equal(t, 1, count, "switching the active config path %d times must leave one index, not %d", n, n) + assert.True(t, activeIsWarm, "the index left behind must be the one Warm was last called for") + + // The still-active directory keeps answering; the abandoned ones are + // simply cold again (fail-closed until something warms or requests them + // afresh) rather than lost or corrupted. + src, _, err := ResolveScoped(dirs[n-1], "alpha", "") + require.NoError(t, err) + assert.Equal(t, "1", string(src)) +} + +// TestStoredNames_BareUseCapsAtLeastRecentlyUsed (round 9 SHOULD): a caller +// that never calls Warm (a scoped request against a directory the server +// never warmed) still must not grow storedIndexes without bound — the map +// caps at maxStoredNameIndexes, evicting the least-recently-used directory. +func TestStoredNames_BareUseCapsAtLeastRecentlyUsed(t *testing.T) { + quiesceIndexRebuilds() + storedIndexesMu.Lock() + storedIndexes = map[string]*storedNames{} + storedIndexesLRU = nil + storedIndexesMu.Unlock() + + keys := make([]string, maxStoredNameIndexes+3) + for i := range keys { + keys[i] = fmt.Sprintf("bare-use-dir-%d", i) + storedNamesIndex(keys[i]) + } + + storedIndexesMu.Lock() + count := len(storedIndexes) + _, oldestSurvived := storedIndexes[keys[0]] + _, newestSurvived := storedIndexes[keys[len(keys)-1]] + storedIndexesMu.Unlock() + + assert.Equal(t, maxStoredNameIndexes, count, "bare use is capped at maxStoredNameIndexes") + assert.False(t, oldestSurvived, "the least-recently-used directory is evicted first") + assert.True(t, newestSurvived, "the most recently touched directory survives") +} diff --git a/internal/codescripts/storedspellings_probe.go b/internal/codescripts/storedspellings_probe.go index 75e65787c..3f0b54384 100644 --- a/internal/codescripts/storedspellings_probe.go +++ b/internal/codescripts/storedspellings_probe.go @@ -5,8 +5,10 @@ package codescripts import ( "errors" "io/fs" + "os" "path/filepath" "strings" + "time" ) // Warm is a no-op where storedSpellingsOf is a single-entry platform call: @@ -14,22 +16,38 @@ import ( // once, off the request path. func Warm(string) error { return nil } +// SetIndexClockForTest is a no-op here: there is no directory-generation +// index or settle window on darwin/Windows — storedSpellingsOf proves the +// spelling directly, on the descriptor that is actually opened, rather than +// trusting a listed generation. Present so a caller outside this package +// (an internal/server fixture built for every platform) compiles and runs +// unchanged on darwin and Windows, where there is nothing to settle. +func SetIndexClockForTest(func() time.Time) (restore func()) { return func() {} } + // storedSpellingsOf answers, for one scoped request, whether scriptsDir holds // an entry spelled exactly `want`, by a fixed number of single-path calls and // never a listing (Spec 105 FR-012). The default APFS/HFS+ and NTFS volumes // are case-insensitive but case-PRESERVING, so the probe alone would accept // `backdoor.JS` for `backdoor.js`; a hit is accepted only when the entry's // stored spelling (entryName, one single-entry platform call) is -// byte-for-byte the requested one, exactly as List decides. The no-follow -// open remains the authoritative check. +// byte-for-byte the requested one, exactly as List decides. +// +// This is the CHEAP pre-open gate only — round 9 MUST-FIX: a case-rename or +// replacement landing between this probe and openScriptFile's own open can +// leave a different, case-folded file behind the same requested spelling for +// the descriptor's entire lifetime, and neither a no-follow open nor Stat +// tells a folded spelling from an exact one. The second return proves the +// spelling AUTHORITATIVELY, on the descriptor that will actually be read — +// see below. // -// The second return is the shared signature's post-open recheck (round 8 -// MUST-FIX on Linux/BSD, the lookup→open race): here every candidate is -// already re-verified directly, per call, against the CURRENT filesystem -// (there is no directory-generation index to fall behind), so there is -// nothing further to recheck after the open and this is always nil. -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func() error, err error) { - return func(want string) (bool, error) { +// A platform-call failure here is not a match (round 9 MUST-FIX): earlier +// rounds let the Lstat verdict alone stand when entryName errored, which +// fails OPEN on a probe race or platform-call failure. The pre-open probe +// need not be perfectly precise — the post-open proof is authoritative and +// would still catch a wrongly admitted candidate — but there is no reason to +// admit one on a failure this function cannot itself explain. +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func(f *os.File, want string) error, err error) { + storedExactly = func(want string) (bool, error) { path := filepath.Join(scriptsDir, want) if _, err := lstat(path); err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -40,9 +58,7 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool stored, err := entryName(path) switch { case err != nil: - // The platform call failed: the Lstat verdict stays in force and - // the no-follow open decides usability. - return true, nil + return false, nil case stored != want && strings.EqualFold(stored, want): // The filesystem folded the case: the entry is spelled differently // and no discovery surface reports it under this name. Only a @@ -51,5 +67,17 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool return false, nil } return true, nil - }, nil, nil + } + verifyUnchanged = func(f *os.File, want string) error { + stored, err := openedEntryName(f) + if err != nil || stored != want { + // Any failure of the proof call, or any mismatch, refuses — the + // pre-open probe already decided "true" and the caller is about + // to read this descriptor, so an unprovable spelling gets no + // benefit of the doubt (round 9 MUST-FIX). + return errSpellingUnproven + } + return nil + } + return storedExactly, verifyUnchanged, nil } diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go index 21810c03a..d061edb60 100644 --- a/internal/codescripts/storedspellings_probe_test.go +++ b/internal/codescripts/storedspellings_probe_test.go @@ -2,7 +2,15 @@ package codescripts -import "testing" +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) // warmStoredNames is a no-op where storedSpellingsOf is a single-entry platform // call (darwin F_GETPATH, Windows FindFirstFile) and there is no index to @@ -13,3 +21,57 @@ func warmStoredNames(t *testing.T, _ string) { // quiesceIndexRebuilds is a no-op here: nothing runs off the request path. func quiesceIndexRebuilds() {} + +// TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor is the +// positive control for the round 9 MUST-FIX post-open proof: nothing raced +// the open, so the opened descriptor's own stored spelling still matches +// exactly what was requested and probed. +func TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor(t *testing.T) { + dir := t.TempDir() + path := writeScript(t, dir, "alpha.js", "1") + + storedExactly, verifyUnchanged, err := storedSpellingsOf(dir) + require.NoError(t, err) + require.NotNil(t, verifyUnchanged, "darwin/Windows always supply the authoritative post-open check") + ok, err := storedExactly("alpha.js") + require.NoError(t, err) + require.True(t, ok) + + f, err := openScriptFile(path) + require.NoError(t, err) + defer f.Close() + + assert.NoError(t, verifyUnchanged(f, "alpha.js")) +} + +// TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor (round +// 9 MUST-FIX): the pre-open probe (storedExactly) is only a cheap gate — it +// can be satisfied and the open can still succeed against a file that a race +// has since case-renamed, because a no-follow open does not compare names, +// only symlink status, and the SAME descriptor keeps reading through a +// rename of its own directory entry. The authoritative check reads the +// OPENED descriptor's own stored spelling (F_GETPATH / GetFinalPathNameByHandle) +// and must refuse once it no longer matches what was requested, wherever in +// the descriptor's lifetime the rename lands. +func TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor(t *testing.T) { + dir := t.TempDir() + path := writeScript(t, dir, "alpha.js", "1") + + _, verifyUnchanged, err := storedSpellingsOf(dir) + require.NoError(t, err) + require.NotNil(t, verifyUnchanged) + + f, err := openScriptFile(path) + require.NoError(t, err) + defer f.Close() + + // The race: case-rename the file the descriptor is already reading. + // F_GETPATH / GetFinalPathNameByHandle on the open descriptor now report + // the RENAMED spelling — proving the descriptor is no longer the exact + // name that was requested and probed. + require.NoError(t, os.Rename(path, filepath.Join(dir, "ALPHA.JS"))) + + verifyErr := verifyUnchanged(f, "alpha.js") + require.Error(t, verifyErr, "the opened descriptor's spelling no longer matches what was requested") + assert.True(t, errors.Is(verifyErr, errSpellingUnproven)) +} diff --git a/internal/server/mcp_code_scripts_test.go b/internal/server/mcp_code_scripts_test.go index 96cd0fc96..35ebe1b50 100644 --- a/internal/server/mcp_code_scripts_test.go +++ b/internal/server/mcp_code_scripts_test.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" @@ -43,6 +44,16 @@ func newStoredScriptProxy(t *testing.T, opts ...MCPProxyOption) (*MCPProxyServer func newStoredScriptProxyCfg(t *testing.T, configure func(*config.Config), opts ...MCPProxyOption) (*MCPProxyServer, string) { t.Helper() + // The stored-name index (Linux/BSD) only authorizes a hit once it is + // SETTLED — its generation stamp must predate the listing by at least + // codescripts' settle window (round 9 MUST-FIX), because a directory's + // ctime cannot be forged from user space to fake settledness. These + // fixtures write scripts and resolve them within the same test, so the + // clock the settle check reads is moved ahead instead of sleeping out + // the real window on every case; darwin/Windows have no settle window + // and this is a no-op there. + t.Cleanup(codescripts.SetIndexClockForTest(func() time.Time { return time.Now().Add(time.Hour) })) + tmpDir := t.TempDir() logger := zap.NewNop() From d0460083b37f9fc80eddcbdc93a2abd78dc42e0f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Wed, 16 Sep 2026 22:45:30 +0300 Subject: [PATCH 15/23] test(codescripts): exercise the post-open spelling proof with a rename Windows allows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows refuses to rename a file another handle holds open, so the rename-after-open race cannot happen there and the test failed with a sharing violation. The portable half of the race — case-rename between the pre-open probe and the open, which APFS and NTFS fold onto the renamed entry — now carries the assertion on both platforms; the after-open variant stays darwin-only. Co-Authored-By: Claude Opus 5 --- .../codescripts/storedspellings_probe_test.go | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go index d061edb60..39f716bd9 100644 --- a/internal/codescripts/storedspellings_probe_test.go +++ b/internal/codescripts/storedspellings_probe_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -61,14 +62,42 @@ func TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor(t *tes require.NoError(t, err) require.NotNil(t, verifyUnchanged) + // The race: the file is case-renamed between the pre-open probe and the + // open. APFS and NTFS fold the requested spelling onto the renamed entry, + // so the open succeeds — and F_GETPATH / GetFinalPathNameByHandle on the + // opened descriptor report the RENAMED spelling, proving the descriptor + // is not the exact name that was requested and probed. + require.NoError(t, os.Rename(path, filepath.Join(dir, "ALPHA.JS"))) + + f, err := openScriptFile(path) + require.NoError(t, err, "a case-folding filesystem opens the renamed entry under the old spelling") + defer f.Close() + + verifyErr := verifyUnchanged(f, "alpha.js") + require.Error(t, verifyErr, "the opened descriptor's spelling no longer matches what was requested") + assert.True(t, errors.Is(verifyErr, errSpellingUnproven)) +} + +// TestStoredSpellingsOf_PostOpenProofCatchesARenameAfterOpen is the same +// proof taken after the open: the descriptor is already reading the file when +// it is case-renamed. Windows refuses to rename a file another handle holds +// open (no FILE_SHARE_DELETE on the executed handle), so that half of the race +// cannot occur there; the test is darwin-only. +func TestStoredSpellingsOf_PostOpenProofCatchesARenameAfterOpen(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows refuses to rename a file held open by another handle") + } + dir := t.TempDir() + path := writeScript(t, dir, "alpha.js", "1") + + _, verifyUnchanged, err := storedSpellingsOf(dir) + require.NoError(t, err) + require.NotNil(t, verifyUnchanged) + f, err := openScriptFile(path) require.NoError(t, err) defer f.Close() - // The race: case-rename the file the descriptor is already reading. - // F_GETPATH / GetFinalPathNameByHandle on the open descriptor now report - // the RENAMED spelling — proving the descriptor is no longer the exact - // name that was requested and probed. require.NoError(t, os.Rename(path, filepath.Join(dir, "ALPHA.JS"))) verifyErr := verifyUnchanged(f, "alpha.js") From f4eec728d08fa274aa093f7c311cda9d1b499ad1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 17 Sep 2026 06:42:05 +0300 Subject: [PATCH 16/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?11=20=E2=80=94=20bind=20scoped=20resolution=20to=20one=20direct?= =?UTF-8?q?ory=20descriptor=20per=20call,=20refuse=20a=20following=20Windo?= =?UTF-8?q?ws=20reparse=20point,=20and=20make=20async=20rebuilds=20cancell?= =?UTF-8?q?able=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from round 9's audit, all fixed per the maintainer's full structural decision: 1. MUST-FIX (Linux/BSD directory-path ABA): every earlier round's scoped resolution re-resolved scriptsDir BY PATH at each step of one request — the generation read, the candidate probe, the open, and the post-open recheck were four independent lookups. A replaceable symlink, ancestor directory, or bind mount retargeted between two of those steps and back before the next let each step separately agree with a different directory than the others saw; st_dev (round 9) rules out a substitution visible during one snapshot, never an alternation across several. Fixed by binding the whole request to ONE retained directory descriptor (dirfd_other.go): opened by path exactly once (openScopedDir), then every generation read (fstatDirGeneration), the candidate probe (fstatatEntry, AT_SYMLINK_NOFOLLOW) and the open itself (openatEntry, O_NOFOLLOW|O_NONBLOCK|O_CLOEXEC) are relative to that same descriptor — no path is resolved twice. The rebuild goroutine lists through the identical construction (listScopedDirOnce), so the listing and the generation it records for it come from one open. Request cost stays O(1): one open, two fstats, one fstatat, one openat for a hit. 2. MUST-FIX (Windows reparse-point race): openScriptFile Lstat'ed the path and then called os.Open, which DOES follow a reparse point planted (on the candidate or a symlinked ancestor) between the two; the post-open proof compared only a basename, which any identically-named file reached through the reparse point satisfied. Fixed: openScriptFile now opens via windows.CreateFile with FILE_FLAG_OPEN_REPARSE_POINT (the Windows equivalent of O_NOFOLLOW — no check-then-open window) and refuses a reparse point or directory via GetFileInformationByHandle (open_windows.go); the scoped post-open proof compares the opened descriptor's FULL normalized path (openedFinalPath) against the scripts directory's own final path — obtained from a handle opened once per request with FILE_FLAG_BACKUP_SEMANTICS — plus the exact basename (entryname_windows.go, storedspellings_probe_windows.go), so a retargeted ancestor cannot substitute an outside file under the same name. 3. SHOULD (cancellable rebuilds): each stored-name index now owns a context; LRU eviction and Warm's pruning of every other directory cancel it before dropping the index, and the ASYNC (request-scheduled) rebuild goroutine checks it between listing attempts and once more before installing, so a cancelled rebuild stops promptly and installs nothing for a directory nobody will query through it any longer. Warm's own synchronous rebuild is deliberately NOT cancellable (the caller is blocked on it and trusts its result) — only the async path honours ctx. Linux-tagged tests (storednames_other_test.go) cover: a real symlink retarget between a session's own steps proving the ABA fix (TestResolveScoped_DirectoryPathABA); the identity check refusing a same-stamp-but-different-device descriptor (TestStoredNamesFor_IdentityMismatchIsAMiss, TestDirFdGeneration_ReadsTheDevice); eviction stopping and not installing from an in-flight async rebuild via a WaitGroup seam (TestStoredNames_EvictionCancelsAnInFlightRebuild); every existing round 4-9 test re-pointed at the new fd-bound primitives and their own bounded cost counters. Windows: GOOS=windows go vet and go test -c both compile; a real Windows run is not available here, so the Windows code mirrors the darwin structure and stays minimal. darwin is unchanged beyond the shared five-return signature. Docs (overview.md, troubleshooting.md, agent-tokens.md) note the single-descriptor binding and the Windows full-path/no-reparse proof. Verified: go build (both editions); gofmt; go vet on darwin/linux/freebsd/ windows for internal/codescripts; GOOS=windows go test -c; go test -race -count=1..3 -shuffle=on natively on darwin; go test -race -count=2 non-root in golang:1.26 Docker for internal/codescripts (the required H0 command); go test -race -count=1, non-root, full internal/server suite with the project's skip regex (294.8s, matching prior rounds' baseline) — no regression; go test ./cmd/mcpproxy/; go test -tags server ./internal/serveredition/...; git diff -- internal/server/testdata is empty (no golden touched). golangci-lint v2 could not run — pre-existing environment issue (its Go 1.25 build refuses this repo's Go 1.26 toolchain line), unrelated to this change and unchanged since round 1. Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 15 +- docs/code_execution/troubleshooting.md | 7 +- docs/features/agent-tokens.md | 11 +- internal/codescripts/codescripts.go | 97 ++- internal/codescripts/codescripts_test.go | 13 +- internal/codescripts/dirfd_other.go | 148 +++++ internal/codescripts/entryname_windows.go | 69 ++- internal/codescripts/open_windows.go | 50 +- internal/codescripts/storednames_other.go | 500 +++++++++------ .../codescripts/storednames_other_test.go | 586 +++++++++++++----- internal/codescripts/storedspellings_probe.go | 32 +- .../codescripts/storedspellings_probe_test.go | 16 +- .../storedspellings_probe_windows.go | 92 +++ 13 files changed, 1210 insertions(+), 426 deletions(-) create mode 100644 internal/codescripts/dirfd_other.go create mode 100644 internal/codescripts/storedspellings_probe_windows.go diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index b5e29e6de..ef3591e32 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -410,7 +410,12 @@ resolver answers ONLY from an exact-name index of the directory that matches its CURRENT state: built when the daemon starts, validated by one stat of the directory per request, and refreshed by a background rebuild when that stat finds the directory changed. No request lists the directory, cold or -warm. A call landing while that rebuild is merely scheduled or in flight is +warm. On Linux/BSD, every step of that per-request check — the stat, the +candidate probe, the open, and the re-check after the open — is bound to +the SAME retained directory descriptor rather than resolving the path +again for each one, so a symlink or bind mount retargeted mid-request +cannot make different steps see different directories. A call landing while +that rebuild is merely scheduled or in flight is refused exactly like one against a directory the index has never seen — never answered from what the index held before the change — so a rename under a scoped caller's feet cannot have that caller's own probe fold onto @@ -431,7 +436,13 @@ stored spelling of the file descriptor MCPProxy actually opened (`F_GETPATH` on darwin, `GetFinalPathNameByHandle` on Windows) and compares it byte-for-byte to the requested name, so a case-rename racing the open itself is caught on the descriptor that would have been read, not just on -an earlier probe of the same path.) The refusal itself: +an earlier probe of the same path. On Windows the open itself never follows +a reparse point at the final path component either, and the post-open check +compares the descriptor's FULL normalized path against the scripts +directory's own — opened once per request — not just the file's base name, +so a reparse point planted on the candidate or on an ancestor directory +cannot substitute a file from elsewhere under the same name.) The refusal +itself: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index b9661ae43..b685cb090 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -651,7 +651,12 @@ an agent-token call is answered ONLY from an exact-name index of the directory that matches its CURRENT state — built at daemon start, validated by one stat of the directory per call, refreshed in the background when the directory changes — so no call lists the directory, whatever name is asked -for, and the refusal body is unchanged. A call landing while that refresh is +for, and the refusal body is unchanged. Every step of one call's own check — +the stat, the candidate probe, the open and the re-check after it — is bound +to a single directory descriptor retained for that call, never a fresh +resolution of the path per step, so a symlink or bind mount retargeted +mid-call cannot make two of those steps disagree about which directory they +are looking at. A call landing while that refresh is scheduled or in flight is refused exactly as one against a directory never seen before, never served from what the index held a moment ago — a rename cannot have a scoped caller's own probe fold onto whatever now occupies the diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index 37ed5b7e8..fca72cf28 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -364,7 +364,11 @@ content are published to every caller by design and sit outside the invariant: matches its CURRENT state — built when the daemon starts and refreshed by a background rebuild whenever a call finds the directory changed — so no call ever lists it, whatever name is asked for and however many scripts - are stored; a call that lands while that rebuild is scheduled or in + are stored, and every step one call takes (the directory stat, the + candidate probe, the open, and the re-check after it) is bound to a + single directory descriptor retained for that call rather than a fresh + resolution of the path each time; a call that lands while that rebuild is + scheduled or in flight is refused once, exactly like a call against a directory it has never seen, rather than answered from what the index held a moment ago — an entry the index once listed under an earlier spelling must never still @@ -380,7 +384,10 @@ content are published to every caller by design and sit outside the invariant: path's stored spelling directly, that pre-open probe is only a cheap gate: the authoritative check re-reads the stored spelling of the file descriptor MCPProxy actually opened and refuses on any mismatch, so a - rename racing the open itself is caught there too); an ambiguous or unusable script is + rename racing the open itself is caught there too — on Windows that open + never follows a reparse point, and the check compares the descriptor's + full path, not just its base name, against a directory handle opened + once for that same call); an ambiguous or unusable script is reported by name and reason only, without its host path or a raw OS error; the REST listing `GET /api/v1/code/scripts` answers an agent token with diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index 511d4510a..f84aa6c70 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -75,6 +75,30 @@ const ( // (Unix maps the kernel's no-follow rejection onto it). var errNonRegular = errors.New("not a regular file") +// scopedOpener opens the winning candidate for reading. nil means "use the +// package's own openScriptFile", the administrator's path-based no-follow +// open and darwin/Windows's default (round 11: their own fixes reach +// authoritatively into the post-open recheck instead — see scopedVerifier). +// Non-nil only on Linux/BSD (round 11 MUST-FIX), where it is bound to the +// single retained directory descriptor the request's own candidates() call +// opened, so the exact entry that was probed is the exact entry that gets +// opened — never a fresh, independent resolution of the path. +type scopedOpener func(path string) (*os.File, error) + +// scopedVerifier re-proves, on the descriptor openScriptFile or a +// scopedOpener actually opened, that nothing was swapped between the probe +// and the open. nil only for the administrator, whose directory-based +// decision has nothing to recheck against. +type scopedVerifier func(f *os.File, want string) error + +// scopedCloser releases whatever per-request resource a candidates() +// implementation opened (round 11 MUST-FIX: the retained directory +// descriptor on Linux/BSD; a directory handle on Windows) — nil when there +// is nothing to release (the administrator; darwin). resolve defers it +// immediately after calling candidates(), so it always runs exactly once, +// whether or not a candidate was ultimately opened. +type scopedCloser func() + // errIndexGenerationChanged is what a post-open verifyUnchanged closure // returns when the scripts directory's generation moved between the index // lookup that produced a hit and this open (round 8 MUST-FIX, the @@ -355,7 +379,15 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ if !disclose { candidates = probeCandidates } - found, verifyUnchanged, err := candidates(scriptsDir, name) + found, open, verifyUnchanged, closeSession, err := candidates(scriptsDir, name) + // Round 11 MUST-FIX: whatever per-request resource candidates() opened + // to decide found (a retained directory descriptor on Linux/BSD, a + // directory handle on Windows) is released exactly once here, however + // resolve returns below — a miss, an ambiguous name, a successful read, + // or any refusal in between. + if closeSession != nil { + defer closeSession() + } if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, "", notFound() @@ -381,7 +413,10 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ return nil, "", err } - f, err := openScriptFile(path) + if open == nil { + open = openScriptFile + } + f, err := open(path) if err != nil { switch { case errors.Is(err, errNonRegular): @@ -465,13 +500,15 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ // the filesystem's matching from the loop entirely, so the two agree on every // platform. Resolve's no-follow open remains the authoritative check. // -// The third return is the post-open authoritative recheck probeCandidates -// supplies (round 8 / round 9 MUST-FIX); the administrator's directory-based -// decision has nothing to recheck against, so it is always nil here. -func candidatesFor(scriptsDir, name string) ([]string, func(f *os.File, want string) error, error) { +// The remaining three returns — the scoped opener, the post-open recheck +// (round 8 / round 9 MUST-FIX) and the per-request resource closer (round +// 11 MUST-FIX) — belong to probeCandidates alone: the administrator's +// directory-based decision has nothing to bind an open to or recheck +// against, so all three are always nil here. +func candidatesFor(scriptsDir, name string) ([]string, scopedOpener, scopedVerifier, scopedCloser, error) { dirEntries, err := readDir(scriptsDir) if err != nil { - return nil, nil, err + return nil, nil, nil, nil, err } present := make(map[string]bool, 2) @@ -490,7 +527,7 @@ func candidatesFor(scriptsDir, name string) ([]string, func(f *os.File, want str found = append(found, filepath.Join(scriptsDir, name+ext)) } } - return found, nil, nil + return found, nil, nil, nil, nil } // probeCandidates is candidatesFor for the SCOPED resolver: the same two @@ -510,31 +547,49 @@ func candidatesFor(scriptsDir, name string) ([]string, func(f *os.File, want str // change, never per request (storednames_other.go, codex r5 #1). The // no-follow open remains the authoritative check. // -// The second return is a post-open AUTHORITATIVE recheck (round 8 / round 9 -// MUST-FIX, the lookup→open race): storedSpellingsOf's own verify closure, -// run by resolve on the descriptor that was actually opened — on Linux/BSD a -// directory-generation recheck (storednames_other.go), on darwin/Windows a -// proof of the opened descriptor's own stored spelling -// (storedspellings_probe.go). Never nil on either platform: this is what -// makes the pre-open probe above merely a cheap gate rather than the -// authoritative decision. -func probeCandidates(scriptsDir, name string) ([]string, func(f *os.File, want string) error, error) { - storedExactly, verifyUnchanged, err := storedSpellingsOf(scriptsDir) +// The verifier this returns is a post-open AUTHORITATIVE recheck (round 8 / +// round 9 MUST-FIX, the lookup→open race): storedSpellingsOf's own verify +// closure, run by resolve on the descriptor that was actually opened — on +// Linux/BSD a directory-generation recheck on the SAME retained descriptor +// the whole request used (storednames_other.go, round 11 MUST-FIX — see the +// opener below), on darwin a proof of the opened descriptor's own stored +// spelling (storedspellings_probe.go), on Windows the same proof plus a +// full-path comparison against a directory handle opened once for the +// request (storedspellings_probe_windows.go, round 11 MUST-FIX). Never nil +// on any platform: this is what makes the pre-open probe above merely a +// cheap gate rather than the authoritative decision. +// +// The opener this returns is non-nil ONLY on Linux/BSD (round 11 MUST-FIX): +// it opens the winning candidate relative to the SAME retained directory +// descriptor the generation check and the candidate probe both used, +// instead of a fresh, independent resolution of the path — the fix for the +// directory-path ABA hole (storednames_other.go's package doc comment has +// the full account). darwin and Windows return nil here (their own fixes +// reach authoritatively into the verifier instead), so resolve falls back +// to the package's ordinary openScriptFile. The closer releases whatever +// per-request resource the opener needs (the retained descriptor on +// Linux/BSD, a directory handle on Windows) exactly once, whether or not a +// candidate was ultimately opened. +func probeCandidates(scriptsDir, name string) ([]string, scopedOpener, scopedVerifier, scopedCloser, error) { + storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(scriptsDir) if err != nil { - return nil, nil, err + return nil, nil, nil, nil, err } found := make([]string, 0, 2) for _, ext := range []string{extJS, extTS} { want := name + ext stored, err := storedExactly(want) if err != nil { - return nil, nil, err + if closeSession != nil { + closeSession() + } + return nil, nil, nil, nil, err } if stored { found = append(found, filepath.Join(scriptsDir, want)) } } - return found, verifyUnchanged, nil + return found, open, verifyUnchanged, closeSession, nil } // listForNotFound is the directory listing newNotFoundError attaches to the diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index f5bfea7f6..885084e3e 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -937,5 +937,16 @@ func TestResolveScoped_MissCostIsIndependentOfDirectorySize(t *testing.T) { assert.Equal(t, 0, emptyReadDirs) assert.Equal(t, 0, crowdedReadDirs, "ten thousand entries must not be enumerated on a scoped caller's behalf") assert.Equal(t, emptyLstats, crowdedLstats, "the number of path probes is independent of the directory's contents") - assert.Greater(t, crowdedLstats, 0, "the candidate paths are probed directly") + // On darwin/Windows the candidate probe itself is a path-based Lstat, so + // it shows up here directly. On Linux/BSD (round 11 MUST-FIX) the probe + // runs through a retained directory descriptor instead (fstatatEntry, + // dirfd_other.go) and never touches this package's lstat var — a MISS + // like "gamma" here never even reaches that probe (its name is not a key + // of the index), so there is nothing to assert here beyond the + // equal-cost check above; storednames_other_test.go pins the Linux/BSD + // primitive counts, including the non-zero fstatat a HIT performs, on + // its own terms. + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + assert.Greater(t, crowdedLstats, 0, "the candidate paths are probed directly") + } } diff --git a/internal/codescripts/dirfd_other.go b/internal/codescripts/dirfd_other.go new file mode 100644 index 000000000..b151d544c --- /dev/null +++ b/internal/codescripts/dirfd_other.go @@ -0,0 +1,148 @@ +//go:build !darwin && !windows + +package codescripts + +import ( + "errors" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// This file is the round 11 MUST-FIX for the Linux/BSD directory-path ABA +// hole: every earlier round's scoped resolution re-resolved scriptsDir BY +// PATH at each step of one request — once to read the directory's +// generation, once per candidate to probe it, once more after the open to +// recheck the generation — and openScriptFile's own no-follow open resolved +// the path a FOURTH time to obtain the descriptor that is actually read. +// Four independent path resolutions leave a window between each pair of +// them: retarget a replaceable symlink, an ancestor directory, or a bind +// mount to a different directory B between two of those steps and back to +// the original A before the next one, and whichever step happens to run +// while the path points at A agrees with everything the index vouches for +// while the step that runs during the B window reads or opens B's content +// instead — st_dev (round 9) rules out a substitution visible DURING one +// snapshot, never an alternation across several snapshots taken at +// different moments. +// +// The fix binds the whole scoped resolution to ONE retained directory +// descriptor per request (storedSpellingsOf, storednames_other.go): the +// scripts directory is opened by path exactly ONCE (openScopedDir); its +// generation is read from THAT descriptor (fstatDirGeneration — an fstat, +// never another Lstat of the path); a candidate is probed relative to the +// SAME descriptor (fstatatEntry, AT_SYMLINK_NOFOLLOW — the no-follow +// counterpart of Lstat, but resolved against the retained fd rather than a +// fresh join of the path); the winning candidate is OPENED relative to the +// SAME descriptor (openatEntry) — so the directory entry Fstatat already +// probed is the exact one Openat opens, never a second, independent lookup +// of the name that a retargeted symlink could have answered differently; +// and the post-open recheck reads the generation from the SAME descriptor +// once more. No path is resolved twice, so nothing about the sequence can +// observe two different directories. The request's own cost stays O(1): +// one open, two fstats (one before the lookup, one after the open), one +// fstatat, one openat. +// +// The rebuild goroutine (storednames_other.go, off the request path) opens +// its own descriptor the same way and lists through it (listScopedDir), so +// the listing and the generation the index records for it come from the +// identical open — never a second resolution of the path either. +// +// Every primitive below is a variable so the package's tests can install a +// real symlink retarget between two of a request's own calls (the actual +// window the fix closes is between separate Go statements the caller makes, +// not inside a single syscall) and, for the narrower races a real retarget +// cannot land deterministically, hook the exact call the race would need to +// win. +var ( + openScopedDir = defaultOpenScopedDir + fstatDirGeneration = defaultFstatDirGeneration + fstatatEntry = defaultFstatatEntry + openatEntry = defaultOpenatEntry + listScopedDir = defaultListScopedDir +) + +// defaultOpenScopedDir opens scriptsDir once. O_DIRECTORY refuses a +// non-directory at the path outright (a symlink resolving to a plain file +// would otherwise silently "open" as if it were an empty directory); +// O_CLOEXEC keeps the descriptor from leaking into a child process spawned +// while a request holds it. +func defaultOpenScopedDir(path string) (int, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return -1, &os.PathError{Op: "open", Path: path, Err: err} + } + return fd, nil +} + +// defaultFstatDirGeneration reads a directory's generation stamp from an +// already-open descriptor — fstat, never a path lookup — so it can be +// called again, after the candidate open, without re-resolving scriptsDir. +// Same tuple as dirGenerationOf (modTime, changeTime, size, ino, dev; round +// 9 MUST-FIX folded dev into it), read from golang.org/x/sys/unix.Stat_t +// directly rather than through fs.FileInfo: the field names (Dev, Ino, Mtim, +// Ctim, Size) are uniform across every platform this file builds for, unlike +// the standard syscall.Stat_t, whose ctime field is spelled differently on +// the BSDs (see dirgeneration_ctim.go / dirgeneration_ctimespec.go, which +// remain the path-based reader the package's tests and the administrator's +// bookkeeping use). +func defaultFstatDirGeneration(fd int) (dirGeneration, error) { + var st unix.Stat_t + if err := unix.Fstat(fd, &st); err != nil { + return dirGeneration{}, err + } + return dirGeneration{ + modTime: time.Unix(st.Mtim.Unix()), + changeTime: time.Unix(st.Ctim.Unix()), + size: st.Size, + ino: st.Ino, + dev: uint64(st.Dev), + }, nil +} + +// defaultFstatatEntry probes name relative to dirfd, AT_SYMLINK_NOFOLLOW — +// the candidate's own existence check, bound to the SAME descriptor the +// generation was just read from rather than a fresh Lstat of the joined +// path (which is exactly the second, independent path resolution the round +// 11 MUST-FIX removes). +func defaultFstatatEntry(dirfd int, name string) error { + var st unix.Stat_t + if err := unix.Fstatat(dirfd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return &os.PathError{Op: "fstatat", Path: name, Err: err} + } + return nil +} + +// defaultOpenatEntry opens name relative to dirfd, refusing a symlink +// atomically (O_NOFOLLOW — the same ELOOP/EMLINK-to-errNonRegular mapping +// open_unix.go's openScriptFile applies for the administrator) and never +// parking on a FIFO (O_NONBLOCK, for the identical reason documented +// there). This is the entry Fstatat already probed, opened relative to the +// SAME descriptor — never a second, independent lookup of the name. +func defaultOpenatEntry(dirfd int, name string) (*os.File, error) { + fd, err := unix.Openat(dirfd, name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.EMLINK) { + return nil, errNonRegular + } + return nil, &os.PathError{Op: "openat", Path: name, Err: err} + } + return os.NewFile(uintptr(fd), name), nil +} + +// defaultListScopedDir lists dirfd's entries through a DUP of the +// descriptor: os.File.Close on the dup releases only the copy, leaving the +// caller's own dirfd — and its read position — untouched. The rebuild +// goroutine calls this on the same descriptor its generation came from +// (storednames_other.go), so the listing and the generation the index +// records for it describe the identical open, never a second resolution of +// the path. +func defaultListScopedDir(dirfd int, path string) ([]string, error) { + dupFd, err := unix.Dup(dirfd) + if err != nil { + return nil, err + } + f := os.NewFile(uintptr(dupFd), path) + defer func() { _ = f.Close() }() + return f.Readdirnames(-1) +} diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go index 643540bb6..3d0533f93 100644 --- a/internal/codescripts/entryname_windows.go +++ b/internal/codescripts/entryname_windows.go @@ -41,15 +41,66 @@ func entryName(path string) (string, error) { // openedEntryName is entryName's post-open counterpart (round 9 MUST-FIX): // it proves the stored spelling of the descriptor that will actually be -// EXECUTED, not of a separate pre-open probe of the same path — a -// case-rename or replacement landing between the pre-open probe -// (storedSpellingsOf) and openScriptFile's own open would otherwise let the -// wrong spelling run, because NTFS folds the subsequent open onto whatever -// now occupies the name. GetFinalPathNameByHandle on the EXECUTED file's own -// handle is the call that reports the normalized path NTFS actually opened, -// unlike the requested path, which merely echoes what was asked for. +// EXECUTED, not of a separate pre-open probe of the same path. Superseded as +// the AUTHORITATIVE proof by openedFinalPath (round 11 MUST-FIX: a basename +// alone is satisfied by any identically named file reached through a +// retargeted reparse point — see storedspellings_probe_windows.go), but kept +// for entryNameFromFd's shared plumbing and any caller that only needs the +// base name. func openedEntryName(f *os.File) (string, error) { - h := windows.Handle(f.Fd()) + full, err := finalPathOfHandle(windows.Handle(f.Fd())) + if err != nil { + return "", err + } + return filepath.Base(full), nil +} + +// openedFinalPath is openedEntryName's FULL-PATH counterpart (round 11 +// MUST-FIX, the reparse-point escape): the basename that openedEntryName +// reports is satisfied by any identically named file reachable through a +// reparse point planted between the pre-open probe and the open, so the +// authoritative proof must compare the descriptor's complete normalized +// path — parent directory included — against the scripts directory's own +// final path (dirFinalPath) plus the exact basename, not the basename +// alone. +func openedFinalPath(f *os.File) (string, error) { + return finalPathOfHandle(windows.Handle(f.Fd())) +} + +// dirFinalPath opens scriptsDir once — FILE_FLAG_BACKUP_SEMANTICS is +// required to obtain a handle on a directory at all — and returns its own +// normalized final path together with a func that releases the handle. This +// is the baseline openedFinalPath is compared against (round 11 MUST-FIX): +// confirming a candidate's PARENT is this exact directory, not merely that +// its basename matches, is what a retargeted reparse point on an ancestor +// cannot spoof. +func dirFinalPath(scriptsDir string) (path string, closeHandle func(), err error) { + p, err := windows.UTF16PtrFromString(scriptsDir) + if err != nil { + return "", nil, err + } + h, err := windows.CreateFile(p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0) + if err != nil { + return "", nil, err + } + fp, err := finalPathOfHandle(h) + if err != nil { + _ = windows.CloseHandle(h) + return "", nil, err + } + return fp, func() { _ = windows.CloseHandle(h) }, nil +} + +// finalPathOfHandle is the shared GetFinalPathNameByHandle call: the +// normalized path NTFS actually resolved a handle to, unlike the path that +// was requested, which merely echoes what was asked for. +func finalPathOfHandle(h windows.Handle) (string, error) { flags := uint32(winFileNameNormalized | winVolumeNameDOS) buf := make([]uint16, 1024) @@ -66,5 +117,5 @@ func openedEntryName(f *os.File) (string, error) { return "", err } } - return filepath.Base(windows.UTF16ToString(buf[:n])), nil + return windows.UTF16ToString(buf[:n]), nil } diff --git a/internal/codescripts/open_windows.go b/internal/codescripts/open_windows.go index deab8b2b1..17be76191 100644 --- a/internal/codescripts/open_windows.go +++ b/internal/codescripts/open_windows.go @@ -2,21 +2,51 @@ package codescripts -import "os" +import ( + "os" -// openScriptFile opens a stored script for reading. Windows has no O_NOFOLLOW, -// so the symlink/reparse-point rejection is BEST-EFFORT: the path is Lstat'ed -// first and the descriptor re-verified by the caller after the open. The -// residual window is narrow and creating a symlink on Windows requires -// elevation (or developer mode) in the first place; the confinement boundary -// itself is the name validator, which does not depend on this check. + "golang.org/x/sys/windows" +) + +// openScriptFile opens a stored script for reading without ever following a +// reparse point at the final path component (round 11 MUST-FIX). Earlier +// rounds Lstat'ed the path to rule out a symlink/junction and then called +// os.Open, which DOES follow a reparse point: a symlink or junction planted +// between the Lstat and the Open — or a symlinked ANCESTOR directory +// retargeted the same way — is followed straight through to whatever it now +// points at, and the caller's own descriptor-spelling proof used to compare +// only a basename (round 9), which an identically named file reached +// through the reparse point satisfies just as well. +// +// FILE_FLAG_OPEN_REPARSE_POINT makes CreateFile open the reparse point +// ITSELF rather than transparently resolving it — the Windows equivalent of +// O_NOFOLLOW — so there is no check-then-open window: whatever the entry +// is, this is the handle it opens, atomically. GetFileInformationByHandle on +// that handle then refuses a reparse point or a directory outright, exactly +// as O_NOFOLLOW plus the regular-file Fstat check does on Unix. func openScriptFile(path string) (*os.File, error) { - info, err := os.Lstat(path) + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + h, err := windows.CreateFile(p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0) if err != nil { return nil, err } - if !info.Mode().IsRegular() { + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + _ = windows.CloseHandle(h) + return nil, err + } + if fi.FileAttributes&(windows.FILE_ATTRIBUTE_REPARSE_POINT|windows.FILE_ATTRIBUTE_DIRECTORY) != 0 { + _ = windows.CloseHandle(h) return nil, errNonRegular } - return os.Open(path) + return os.NewFile(uintptr(h), path), nil } diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go index 25a8fdb5b..e2ea142c2 100644 --- a/internal/codescripts/storednames_other.go +++ b/internal/codescripts/storednames_other.go @@ -3,12 +3,15 @@ package codescripts import ( + "context" "errors" "io/fs" "os" "path/filepath" "sync" "time" + + "golang.org/x/sys/unix" ) // Linux and the BSDs resolve names case-sensitively on their native @@ -28,54 +31,57 @@ import ( // spellings a scripts directory holds, maintained OFF the request path. The // index is built when the server learns its scripts directory (Warm) and // rebuilt by a single-flight goroutine whenever a request finds it behind the -// directory's GENERATION (one Lstat of the directory itself). No request ever -// lists: it answers from the index that exists — an exact hit is re-probed by -// the candidate's own Lstat and opened no-follow, so a removed or replaced -// file fails closed; a script added since the listing is refused until the -// rebuild lands, milliseconds later (the administrator's directory read sees -// it at once). Every request — hit, miss or case-variant, cold or warm, in a -// directory of ten thousand entries or none — costs a directory Lstat and an -// O(1) set lookup (codex r6 #1). Listing cost follows the administrator's -// writes, never the requested name, and never lands on a caller's goroutine. +// directory's GENERATION. No request ever lists: it answers from the index +// that exists — an exact hit is re-probed by the candidate's own no-follow +// stat and opened no-follow, so a removed or replaced file fails closed; a +// script added since the listing is refused until the rebuild lands, +// milliseconds later (the administrator's directory read sees it at once). +// Every request — hit, miss or case-variant, cold or warm, in a directory of +// ten thousand entries or none — costs the same bounded number of directory +// primitives and an O(1) set lookup. Listing cost follows the +// administrator's writes, never the requested name, and never lands on a +// caller's goroutine. // -// The index answers ONLY for the generation it was built against (codex r7 -// #1 / round 8 MUST-FIX). Scheduling a rebuild is not the same as having one: -// earlier rounds handed back whatever the index held while a mismatched -// generation's rebuild was merely scheduled or in flight, and a stale index -// can still vouch for an entry under a spelling the directory no longer has -// it under — on a case-folding mount, a rename lets the stale hit's own -// Lstat fold onto whatever now occupies that name. So a generation mismatch -// refuses exactly as a never-built index refuses, fail closed, until its own -// rebuild lands (a call landing within milliseconds of a directory change is -// refused once — retry). The generation is checked once more after an -// exact-set hit's no-follow open (round 8 MUST-FIX, the lookup→open race): -// gen-before == index.gen == gen-after is what proves the file the open just -// read is the one the index vouched for, not a replacement that landed in -// the window between the probe and the open. +// The index answers ONLY for the generation it was built against (round 8 +// MUST-FIX): a stale index is refused exactly as a never-built one is, fail +// closed, until its own rebuild lands (a call landing within milliseconds of +// a directory change is refused once — retry). The generation is checked +// once more after an exact-set hit's no-follow open (round 8 MUST-FIX, the +// lookup→open race): gen-before == index.gen == gen-after is what proves the +// file the open just read is the one the index vouched for. // // A matching generation is not enough on its own (round 9 MUST-FIX): a // coarse filesystem timestamp (vfat: two seconds) can leave a directory's // stamp UNCHANGED across a rename that lands in the same tick as the stamp -// the index was listed against — the index is then "current" by the -// gen-equality test above while still blind to the rename, and the renamed -// entry's own Lstat folds onto it on a case-folding mount exactly as a stale -// index's does. An index may therefore AUTHORIZE a hit only once it is -// SETTLED: its stamp predates the listing by at least generationSettleTime, -// so no write still landing on that stamp could have escaped it. An -// unsettled index — current or not — is refused with the same non-disclosing -// not-found a never-built index gets; the documented consequence is that a -// scoped call is refused for up to ~generationSettleTime after any change to -// the scripts directory (retry). The rebuild-scheduling cadence below is -// unaffected by this: it already runs at most once per settle window while -// unsettled, whether or not this request's own hit is authorized. +// the index was listed against. An index may therefore AUTHORIZE a hit only +// once it is SETTLED: its stamp predates the listing by at least +// generationSettleTime, so no write still landing on that stamp could have +// escaped it. +// +// Round 11 MUST-FIX (the directory-path ABA hole): every check above — +// gen-before, the candidate probe, gen-after — and the eventual open used to +// be FOUR INDEPENDENT resolutions of scriptsDir BY PATH. A replaceable +// symlink, ancestor directory, or bind mount retargeted between two of those +// steps and back before the next one let each step separately agree with a +// DIFFERENT directory than the one the others saw — st_dev (round 9) rules +// out a substitution visible during one snapshot, never an alternation +// across several. The fix (dirfd_other.go) binds the entire request to ONE +// retained directory descriptor: opened by path exactly once, then every +// generation read, the candidate probe, and the open itself are all +// performed RELATIVE TO THAT DESCRIPTOR (fstat / fstatat / openat) — no +// second path resolution exists for anything to retarget. See +// dirfd_other.go for the full account and storedSpellingsOf below for where +// the descriptor is opened and released. // -// A directory that never stops changing cannot be allowed to keep a rebuild -// goroutine re-listing forever, or Warm blocked forever, or a fresh rebuild -// spawning the instant the last one gave up (round 8 SHOULD): one rebuild -// re-lists at most maxRebuildAttempts times, and scheduleRebuildLocked -// withholds a new rebuild goroutine for rebuildBackoff after the previous one -// ends — a request's own cost is unaffected either way, since a stale or -// absent index answers fail-closed at the same one-Lstat cost regardless. +// Round 11 SHOULD (cancellable rebuilds): a directory that keeps changing +// must not leave orphaned rebuild goroutines running forever after their +// index has been evicted (LRU) or pruned (Warm keeping only the active +// directory) — each index owns a context that eviction cancels, and its +// rebuild goroutine (the ASYNC, request-scheduled kind only — Warm's own +// synchronous rebuild is what the caller is waiting on and always runs to +// completion) checks it between listing attempts and once more before +// installing a result, so a cancelled rebuild stops promptly and writes +// nothing nobody will read. See storedNames.rebuild below. // storedNames is the exact-spelling index of one scripts directory. names is // replaced, never mutated, so a set handed out under the lock stays valid @@ -101,12 +107,27 @@ type storedNames struct { // the previous one finished (round 8 SHOULD): a continuously changing // directory would otherwise let scheduleRebuildLocked spawn another // rebuild the instant the last one gives up, listing back to back - // forever. Set at the end of every rebuild, win or lose; zero means - // none has ever finished. + // forever. Set at the end of every ASYNC rebuild, win or lose; zero + // means none has ever finished. nextAttempt time.Time + + // ctx/cancel bind this index's ASYNC rebuild goroutines to the index's + // own lifetime (round 11 SHOULD): every place that discards this index + // — LRU eviction, Warm pruning every OTHER directory, forgetIndex — + // cancels ctx before the map forgets it, so a rebuild goroutine still + // mid-listing for a directory nobody will query through THIS index any + // longer stops re-listing and installs nothing rather than racing the + // eviction to finish a write no reader needed. wg is the seam a test (or + // a future caller) waits on to know the goroutine has actually + // returned, not merely that cancel was called; every rebuild call — the + // async ones AND Warm's own synchronous one — is wg.Add(1)'d before it + // starts, so wg.Wait() always reflects work truly in flight. + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup } -// storedNameIndexes holds one *storedNames per cleaned scripts directory, +// storedIndexes holds one *storedNames per cleaned scripts directory, // bounded so it tracks the directories actually in use rather than every // directory ever used (round 9 SHOULD): the server calls Warm whenever the // active scripts directory changes, and Warm keeps only the directory it was @@ -124,14 +145,15 @@ var ( const maxStoredNameIndexes = 4 // storedNamesIndex returns the index of one cleaned scripts directory, -// creating an empty (never built) one on first use, and records the access -// for LRU eviction. +// creating an empty (never built) one — with its own cancellation context — +// on first use, and records the access for LRU eviction. func storedNamesIndex(key string) *storedNames { storedIndexesMu.Lock() defer storedIndexesMu.Unlock() idx, ok := storedIndexes[key] if !ok { - idx = &storedNames{} + ctx, cancel := context.WithCancel(context.Background()) + idx = &storedNames{ctx: ctx, cancel: cancel} storedIndexes[key] = idx } touchIndexLocked(key) @@ -152,21 +174,28 @@ func touchIndexLocked(key string) { } // evictExcessLocked drops the least-recently-used indexes once the map holds -// more than maxStoredNameIndexes. storedIndexesMu must be held. +// more than maxStoredNameIndexes, cancelling each one's rebuild context +// first (round 11 SHOULD) so an in-flight async rebuild for a directory this +// map no longer tracks does not keep listing. storedIndexesMu must be held. func evictExcessLocked() { for len(storedIndexesLRU) > maxStoredNameIndexes { oldest := storedIndexesLRU[0] storedIndexesLRU = storedIndexesLRU[1:] + if idx, ok := storedIndexes[oldest]; ok { + idx.cancel() + } delete(storedIndexes, oldest) } } // pruneOtherIndexesLocked drops every index but keep — Warm's own promise -// that only the active scripts directory stays warm. storedIndexesMu must be -// held. +// that only the active scripts directory stays warm — cancelling each +// dropped index's rebuild context first (round 11 SHOULD). storedIndexesMu +// must be held. func pruneOtherIndexesLocked(keep string) { - for k := range storedIndexes { + for k, idx := range storedIndexes { if k != keep { + idx.cancel() delete(storedIndexes, k) } } @@ -179,14 +208,18 @@ func pruneOtherIndexesLocked(keep string) { storedIndexesLRU = kept } -// forgetIndex removes one directory's index entirely, forcing the next -// storedNamesIndex(key) to start from a never-built index. Production code -// never calls this directly (pruneOtherIndexesLocked and evictExcessLocked -// cover the two bounding cases); it exists so tests can force a cold index -// without reaching into the map's internals. +// forgetIndex removes one directory's index entirely, cancelling its +// rebuild context first (round 11 SHOULD), forcing the next +// storedNamesIndex(key) to start from a fresh, never-built index. Production +// code never calls this directly (pruneOtherIndexesLocked and +// evictExcessLocked cover the two bounding cases); it exists so tests can +// force a cold index without reaching into the map's internals. func forgetIndex(key string) { storedIndexesMu.Lock() defer storedIndexesMu.Unlock() + if idx, ok := storedIndexes[key]; ok { + idx.cancel() + } delete(storedIndexes, key) for i, k := range storedIndexesLRU { if k == key { @@ -212,7 +245,7 @@ func forEachIndex(fn func(*storedNames)) { } } -// dirGeneration is the Lstat tuple that moves whenever a directory's entry +// dirGeneration is the stat tuple that moves whenever a directory's entry // set can have changed: adding, removing or renaming an entry updates its // mtime and ctime (ctime cannot be set from user space, so a restored mtime — // tar, rsync -a — does not hide a change), a replaced directory has another @@ -220,9 +253,11 @@ func forEachIndex(fn func(*storedNames)) { // (round 9 MUST-FIX): an inode number is unique only WITHIN a device, so // without it a bind-mount swap to another filesystem whose directory happens // to collide on inode, size, mtime and ctime would read as the SAME -// generation — the stale index would then vouch for a spelling that was -// never proven on the filesystem now actually mounted there. -// dirGenerationOf reads the tuple per platform. +// generation. dirGenerationOf reads the tuple from a path-based Lstat result +// (used by the package's tests and by the pre-round-11 callers that still +// have only a path, never a descriptor); dirFdGeneration in dirfd_other.go +// reads the identical tuple from an already-open descriptor via fstat — the +// form every request and rebuild actually uses (round 11 MUST-FIX). type dirGeneration struct { modTime, changeTime time.Time size int64 @@ -262,13 +297,13 @@ const generationSettleTime = 2 * time.Second // listing or spinning on one that can never confirm. const maxRebuildAttempts = 3 -// rebuildBackoff is the minimum gap between the end of one rebuild goroutine -// and the start of the next for the same directory (round 8 SHOULD). Without -// it, a directory changing on every request would let scheduleRebuildLocked -// spawn a fresh rebuild the instant the bounded one above gives up — the -// same unbounded listing cost, just resumed one goroutine later. During the -// backoff a request's own cost is unchanged: one Lstat, answered fail-closed -// from whatever the index holds (or does not). +// rebuildBackoff is the minimum gap between the end of one ASYNC rebuild +// goroutine and the start of the next for the same directory (round 8 +// SHOULD). Without it, a directory changing on every request would let +// scheduleRebuildLocked spawn a fresh rebuild the instant the bounded one +// above gives up. During the backoff a request's own cost is unchanged: one +// open and one fstat, answered fail-closed from whatever the index holds (or +// does not). const rebuildBackoff = time.Second // indexClock is time.Now, a variable so the tests can settle an index @@ -291,9 +326,9 @@ func SetIndexClockForTest(now func() time.Time) (restore func()) { return func() { indexClock = prev } } -// spawnIndexRebuild runs one index rebuild on its own goroutine. A variable -// so the tests can hold a rebuild back and prove what a request does on its -// own goroutine, then land it deliberately. +// spawnIndexRebuild runs one ASYNC index rebuild on its own goroutine. A +// variable so the tests can hold a rebuild back and prove what a request +// does on its own goroutine, then land it deliberately. var spawnIndexRebuild = func(rebuild func()) { go rebuild() } // Warm builds the stored-name index of scriptsDir on the caller's goroutine, @@ -301,7 +336,7 @@ var spawnIndexRebuild = func(rebuild func()) { go rebuild() } // learns its scripts directory; it is never called on a request's behalf. // The listing is taken after Warm was called (a rebuild already in flight is // waited for, then Warm lists again), so the index reflects the directory as -// it was at the call. A directory that cannot be stat-ed or listed leaves a +// it was at the call. A directory that cannot be opened or listed leaves a // failed index (scoped callers are refused as unreadable until the directory // changes) and the failure is returned for logging. On darwin and Windows // there is no index and Warm is a no-op. @@ -309,7 +344,18 @@ var spawnIndexRebuild = func(rebuild func()) { go rebuild() } // Warm also keeps ONLY scriptsDir's index (round 9 SHOULD): the server calls // Warm whenever the active scripts directory changes, so this is the point // that knows which directory is current — every other directory's index is -// dropped rather than left to accumulate for as long as the process runs. +// dropped (its rebuild context cancelled, round 11 SHOULD) rather than left +// to accumulate for as long as the process runs. +// +// Warm's own rebuild is never cancelled by that pruning (round 11 SHOULD): +// cancellation exists to stop an ASYNC rebuild nobody is waiting for from +// outliving the index that scheduled it, not to let a concurrent caller's +// eviction of a DIFFERENT directory silently turn this synchronous call — +// which the caller is blocked on and whose error it trusts — into a no-op. +// scriptsDir's own index is never among the ones pruneOtherIndexesLocked +// drops here, so this is only a concern for a hypothetical concurrent Warm +// of a different directory; the rebuild call below simply does not consult +// ctx, so it always runs to completion and its result is always installed. func Warm(scriptsDir string) error { key := filepath.Clean(scriptsDir) idx := storedNamesIndex(key) @@ -332,37 +378,59 @@ func Warm(scriptsDir string) error { // moves), not a request-triggered rebuild guarding against runaway // churn — it must not spend part of the round 8 SHOULD backoff a moment // after startup refuses the very first real change to the directory. - idx.rebuild(key, false) + // cancellable is false for the reason in the doc comment above. + idx.wg.Add(1) + idx.rebuild(key, false, false) idx.mu.Lock() defer idx.mu.Unlock() return idx.err } // storedSpellingsOf answers, for one scoped request, whether scriptsDir holds -// an entry spelled exactly `want`: an index hit, confirmed by the candidate's -// own Lstat (the entry may have gone since the listing; the no-follow open -// remains the authoritative check). The index is validated once per request, -// and only an index hit is probed, so an absent name and a differently cased -// one cost the same. A directory that cannot be stat-ed or listed is an error -// the scoped resolver reports as unreadable, as the administrator's directory -// read always has (SC-005). +// an entry spelled exactly `want`, and hands back how to open and re-verify +// the winning candidate — all bound to the SINGLE directory descriptor this +// call opens (round 11 MUST-FIX; see dirfd_other.go and the package doc +// comment above). The index is validated once per request against that +// descriptor's own generation, and only an index hit is probed, so an +// absent name and a differently cased one cost the same. A directory that +// cannot be opened or listed is an error the scoped resolver reports as +// unreadable, as the administrator's directory read always has (SC-005). // -// The second return is a post-open recheck (round 8 MUST-FIX, the -// lookup→open race): the directory's generation as read for THIS lookup, -// wrapped so the caller can re-read it once more after the open and refuse -// if it moved — gen-before == index.gen == gen-after is what proves the file -// the open just read is the one the index vouched for, not a replacement -// that landed in the window between the probe and the open. -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func(f *os.File, want string) error, err error) { - names, gen, err := storedNamesFor(scriptsDir) +// The returned open func opens the winning candidate relative to the same +// descriptor (openatEntry) rather than a fresh resolution of the path — the +// core of the round 11 MUST-FIX. verifyUnchanged is the post-open recheck +// (round 8 MUST-FIX, the lookup→open race): the SAME descriptor's +// generation, read once more after the open; gen-before == index.gen == +// gen-after is what proves the file the open just read is the one the index +// vouched for. closeSession releases the descriptor once the caller is done +// with it, whether or not a candidate was ever opened — callers must call it +// exactly once (resolve, in codescripts.go, defers it immediately). +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { + key := filepath.Clean(scriptsDir) + + dirfd, err := openScopedDir(key) + if err != nil { + return nil, nil, nil, nil, err + } + closeSession = func() { _ = unix.Close(dirfd) } + + gen, err := fstatDirGeneration(dirfd) if err != nil { - return nil, nil, err + closeSession() + return nil, nil, nil, nil, err } + + names, lookupErr := storedNamesFor(key, dirfd, gen) + if lookupErr != nil { + closeSession() + return nil, nil, nil, nil, lookupErr + } + storedExactly = func(want string) (bool, error) { if _, ok := names[want]; !ok { return false, nil } - if _, err := lstat(filepath.Join(scriptsDir, want)); err != nil { + if err := fstatatEntry(dirfd, want); err != nil { if errors.Is(err, fs.ErrNotExist) { return false, nil } @@ -370,75 +438,56 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool } return true, nil } - // f and want are unused here: the index's own generation recheck (below) - // is what this platform can prove, and it needs neither the opened - // descriptor nor the requested spelling — see the darwin/Windows - // counterpart in storedspellings_probe.go, which proves the spelling - // itself on f because it has no directory-generation index to recheck. + open = func(path string) (*os.File, error) { + return openatEntry(dirfd, filepath.Base(path)) + } + // f and want are unused here: the SAME descriptor's own generation + // recheck (below) is what this platform can prove, and it needs + // neither the opened file nor the requested spelling — see the + // darwin/Windows counterpart in storedspellings_probe.go and + // storedspellings_probe_windows.go, which prove the spelling itself on + // f because they have no directory-generation index to recheck. verifyUnchanged = func(_ *os.File, _ string) error { - info, err := lstat(scriptsDir) + cur, err := fstatDirGeneration(dirfd) if err != nil { return err } - if !dirGenerationOf(info).equal(gen) { + if !cur.equal(gen) { return errIndexGenerationChanged } return nil } - return storedExactly, verifyUnchanged, nil + return storedExactly, open, verifyUnchanged, closeSession, nil } // storedNamesFor returns the exact-name set of scriptsDir as the index holds -// it — never listing on the caller's behalf — together with the directory -// generation this request read. One Lstat of the directory reads that -// generation; when it does not equal the index's OWN generation (never -// built, behind, or a rebuild merely scheduled or in flight for it), the -// request is answered as fail-closed as a never-built index: nil names, no -// error, nothing scheduled beyond the rebuild that (still) needs to run. +// it for THIS request's already-open descriptor and its freshly read +// generation (round 11 MUST-FIX: dirfd and gen both come from the SAME open, +// never a path lookup of their own) — never listing on the caller's behalf. +// When dirfd's generation does not equal the index's OWN generation (never +// built, behind, or a rebuild merely scheduled or in flight for it) — or the +// index is not yet SETTLED (round 9 MUST-FIX) — the request is answered as +// fail-closed as a never-built index: nil names, no error, nothing scheduled +// beyond the rebuild that (still) needs to run. // -// Round 8 MUST-FIX: earlier rounds scheduled that rebuild but still handed -// back whatever the index held before — a stale index that had listed an -// entry under an EARLIER spelling stayed good enough to authorize it. On a -// case-folding mount that is exploitable: warm the index with `report.js`, -// rename it to `REPORT.JS` (which moves the directory's generation), and -// the stale index's own Lstat of `report.js` still succeeds by folding onto -// the renamed file — a stale index is not evidence about the directory's -// CURRENT contents, whatever it used to be right about. The index now -// answers ONLY for the generation it was built against; any other request -// gets the same non-disclosing not-found a directory it has never seen -// would get, until the rebuild it schedules lands. Documented consequence: -// a scoped call landing within milliseconds of a change to the directory is -// refused once — retry. -func storedNamesFor(scriptsDir string) (names map[string]struct{}, gen dirGeneration, err error) { - key := filepath.Clean(scriptsDir) +// Because dirfd's generation already carries the directory's device and +// inode (dirGeneration, round 9 MUST-FIX), this is also what refuses a +// request whose descriptor resolves to a DIFFERENT directory than the one +// the index was built from, even should every other field of the stamp +// happen to collide: idx.gen.equal(gen) requires the identical dev+ino, so +// an index built from directory A never authorizes a request whose dirfd +// opened directory B. +func storedNamesFor(key string, dirfd int, gen dirGeneration) (names map[string]struct{}, err error) { idx := storedNamesIndex(key) - - info, err := lstat(key) - if err != nil { - return nil, dirGeneration{}, err - } - // A directory the process may not READ is answered with the unreadable - // form whatever the index holds — the same reason the administrator's - // listing gives (SC-005), and the same answer on every platform. Opening - // the directory (no readdir) is one constant-cost syscall; without it a - // scripts directory that lost its read bit after the index was built - // would be reported not-found until a rebuild recorded the error. - dirFile, err := os.Open(key) - if err != nil { - return nil, dirGeneration{}, err - } - _ = dirFile.Close() - gen = dirGenerationOf(info) now := indexClock() idx.mu.Lock() defer idx.mu.Unlock() // current is whether the index is BUILT and answers for exactly this - // generation — the necessary condition for scheduling logic below, which - // stays exactly as round 8 left it: an out-of-date generation always - // reschedules, an in-date-but-unsettled one reschedules at most once per - // window. + // generation — the necessary condition for scheduling logic below: an + // out-of-date generation always reschedules, an in-date-but-unsettled + // one reschedules at most once per window. current := (idx.names != nil || idx.err != nil) && idx.gen.equal(gen) switch { @@ -449,21 +498,21 @@ func storedNamesFor(scriptsDir string) (names map[string]struct{}, gen dirGenera } // authorized additionally requires the index to be SETTLED (round 9 - // MUST-FIX, doc comment above): a matching-but-unsettled generation is - // refused exactly as a mismatched one is, because a coarse timestamp - // cannot rule out a rename that landed on the very stamp being trusted. + // MUST-FIX): a matching-but-unsettled generation is refused exactly as + // a mismatched one is, because a coarse timestamp cannot rule out a + // rename that landed on the very stamp being trusted. if !current || !idx.settled { - return nil, dirGeneration{}, nil + return nil, nil } - return idx.names, gen, idx.err + return idx.names, idx.err } -// scheduleRebuildLocked starts the directory's rebuild goroutine unless one -// is already in flight or the backoff since the last one has not elapsed -// (round 8 SHOULD), and opens the next refresh window either way. During the -// backoff a request's own cost is unaffected — one Lstat, answered -// fail-closed from whatever the index holds (or does not) — only a NEW -// rebuild goroutine is withheld. +// scheduleRebuildLocked starts the directory's ASYNC rebuild goroutine +// unless one is already in flight or the backoff since the last one has not +// elapsed (round 8 SHOULD), and opens the next refresh window either way. +// During the backoff a request's own cost is unaffected — one open, one +// fstat, answered fail-closed from whatever the index holds (or does not) — +// only a NEW rebuild goroutine is withheld. func (idx *storedNames) scheduleRebuildLocked(key string, now time.Time) { idx.refreshAfter = now.Add(generationSettleTime) if idx.building { @@ -473,7 +522,11 @@ func (idx *storedNames) scheduleRebuildLocked(key string, now time.Time) { return } idx.beginRebuildLocked() - spawnIndexRebuild(func() { idx.rebuild(key, true) }) + // wg.Add happens before spawnIndexRebuild hands the closure off (which + // may run it synchronously, in a test that holds rebuilds back) so + // idx.wg.Wait() is never called before the matching Add is visible. + idx.wg.Add(1) + spawnIndexRebuild(func() { idx.rebuild(key, true, true) }) } // beginRebuildLocked claims the single-flight slot. @@ -482,34 +535,55 @@ func (idx *storedNames) beginRebuildLocked() { idx.landed = make(chan struct{}) } -// rebuild lists the directory and installs the result, holding no lock across -// the listing. The stamp is read BEFORE the listing and re-read after it -// under the lock: a write that lands during the listing moves the stamp, and -// the listing is taken again rather than trusted (list-then-stamp race) — up -// to maxRebuildAttempts (round 8 SHOULD): a directory that never stops -// changing cannot keep this goroutine re-listing forever, nor keep Warm -// blocked forever. Giving up leaves whatever the LAST attempt installed; -// that attempt's own generation almost certainly no longer matches the -// directory's current one (it kept moving), so storedNamesFor's own check +// rebuild lists the directory (through dirfd_other.go's fd-bound primitives: +// round 11 MUST-FIX) and installs the result, holding no lock across the +// listing. cancellable selects whether this call honours idx.ctx (true for +// every ASYNC, request-scheduled rebuild) or always runs to completion +// (false, for Warm's own synchronous call — see Warm's doc comment for why). +// +// A change during the listing itself (list-then-stamp race) is caught by +// listScopedDirOnce's own before/after generation read on the SAME +// descriptor and retried — up to maxRebuildAttempts (round 8 SHOULD): a +// directory that never stops changing cannot keep this goroutine re-listing +// forever, nor keep Warm blocked forever. Giving up leaves whatever the LAST +// attempt installed; that attempt's own generation almost certainly no +// longer matches the directory's current one, so storedNamesFor's own check // finds the index stale and refuses fail-closed exactly as it would a -// rebuild still in flight — this loop never leaves a lie standing, it -// simply stops asserting anything. Requests that arrive while a rebuild is -// in flight see building set and schedule nothing; their generation read -// precedes this re-check, so the re-check covers whatever they saw. Ends by -// releasing the slot and closing landed; when backoffAfter is set (every -// spawnIndexRebuild-triggered call — Warm's own direct call passes false), -// it also opens the backoff window before another rebuild of this directory -// may start. -func (idx *storedNames) rebuild(key string, backoffAfter bool) { +// rebuild still in flight. +// +// When cancellable and idx.ctx is done — the index has been evicted or +// pruned since this rebuild started (round 11 SHOULD) — the loop stops at +// the next checkpoint (between attempts, and once more right before +// installing) and installs NOTHING: there is no reader left this index +// could still be wrong for, so there is no reason to pay for, or trust, a +// listing nobody will read. Ends by releasing the single-flight slot and +// closing landed either way, so a concurrent waiter (Warm, or another +// request) is never left blocked; when backoffAfter is set (every +// spawnIndexRebuild-triggered call), it also opens the backoff window +// before another rebuild of this directory may start. +func (idx *storedNames) rebuild(key string, backoffAfter, cancellable bool) { + defer idx.wg.Done() for attempt := 1; ; attempt++ { - gen, err := idx.build(key) - idx.mu.Lock() - if err == nil && attempt < maxRebuildAttempts { - if info, statErr := lstat(key); statErr == nil && !dirGenerationOf(info).equal(gen) { - idx.mu.Unlock() - continue - } + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return + } + before, after, names, listErr := listScopedDirOnce(key) + now := indexClock() + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return } + if listErr == nil && attempt < maxRebuildAttempts && !before.equal(after) { + continue + } + gen := after + if listErr != nil { + gen = before + } + idx.mu.Lock() + idx.names, idx.err, idx.gen = names, listErr, gen + idx.settled = listErr == nil && now.Sub(gen.latest()) >= generationSettleTime idx.building = false if backoffAfter { idx.nextAttempt = indexClock().Add(rebuildBackoff) @@ -520,31 +594,51 @@ func (idx *storedNames) rebuild(key string, backoffAfter bool) { } } -// build takes one listing of key and installs it — or the failure — as the -// index, replacing names atomically under the lock. -func (idx *storedNames) build(key string) (dirGeneration, error) { - var ( - gen dirGeneration - names map[string]struct{} - ) - info, err := lstat(key) - now := indexClock() - if err == nil { - gen = dirGenerationOf(info) - var entries []fs.DirEntry - if entries, err = readDir(key); err == nil { - names = make(map[string]struct{}, len(entries)) - for _, e := range entries { - names[e.Name()] = struct{}{} - } - } +// finishRebuild releases the single-flight slot and closes landed without +// installing anything — used only when a cancellable rebuild stops early +// (round 11 SHOULD). +func (idx *storedNames) finishRebuild(backoffAfter bool) { + idx.mu.Lock() + idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) } + close(idx.landed) + idx.mu.Unlock() +} - idx.mu.Lock() - defer idx.mu.Unlock() - idx.names, idx.err, idx.gen = names, err, gen - // Settled means no write can still land on this stamp: the tick was over - // before the listing began, so nothing the listing missed shares it. - idx.settled = err == nil && now.Sub(gen.latest()) >= generationSettleTime - return gen, err +// listScopedDirOnce opens key once, reads its generation, lists its entries +// through the SAME descriptor, and reads the generation once more — all +// round 11 MUST-FIX: a single open serves the generation read AND the +// listing, so a change during the listing (the list-then-stamp race) is +// caught by the two reads disagreeing, without ever resolving the path a +// second time. A variable so the tests can inject the directory-open seam's +// behaviour directly; the primitives it calls (dirfd_other.go) are +// themselves variables for finer-grained races. +var listScopedDirOnce = defaultListScopedDirOnce + +func defaultListScopedDirOnce(key string) (before, after dirGeneration, names map[string]struct{}, err error) { + dirfd, err := openScopedDir(key) + if err != nil { + return dirGeneration{}, dirGeneration{}, nil, err + } + defer func() { _ = unix.Close(dirfd) }() + + before, err = fstatDirGeneration(dirfd) + if err != nil { + return dirGeneration{}, dirGeneration{}, nil, err + } + entryNames, err := listScopedDir(dirfd, key) + if err != nil { + return before, dirGeneration{}, nil, err + } + after, err = fstatDirGeneration(dirfd) + if err != nil { + return before, dirGeneration{}, nil, err + } + names = make(map[string]struct{}, len(entryNames)) + for _, n := range entryNames { + names[n] = struct{}{} + } + return before, after, names, nil } diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index ce3b51967..d7c3fe62c 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -5,6 +5,7 @@ package codescripts import ( "errors" "fmt" + "io" "io/fs" "os" "path/filepath" @@ -15,45 +16,49 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" ) -// simulateCaseFoldingLstat makes the package's lstat seam behave like a -// case-insensitive, case-preserving directory lookup (APFS, NTFS, ext4 -// casefold, vfat): a path that does not exist as spelled resolves to the -// entry whose name matches it case-insensitively. The listing it consults is -// the simulation's own (os.ReadDir directly), invisible to the readDir seam. -// Installed BEFORE countDirectoryPrimitives when both are used, so the -// counters see the resolver's calls and not the simulation's. -func simulateCaseFoldingLstat(t *testing.T) { +// simulateCaseFoldingFstatat makes the package's fstatatEntry seam behave +// like a case-insensitive, case-preserving directory lookup (APFS, NTFS, +// ext4 casefold, vfat): a name that does not exist as spelled resolves to +// the entry whose name matches it case-insensitively. The listing it +// consults is the simulation's own (os.ReadDir directly, on dir, since +// fstatatEntry only receives a bare name relative to an already-open +// descriptor), invisible to the listScopedDir seam. Installed BEFORE +// countScopedDirPrimitives when both are used, so the counters see the +// resolver's own calls and not the simulation's. +func simulateCaseFoldingFstatat(t *testing.T, dir string) { t.Helper() quiesceIndexRebuilds() - orig := lstat - lstat = func(name string) (os.FileInfo, error) { - info, err := orig(name) + orig := fstatatEntry + fstatatEntry = func(dirfd int, name string) error { + err := orig(dirfd, name) if err == nil || !errors.Is(err, fs.ErrNotExist) { - return info, err + return err } - entries, readErr := os.ReadDir(filepath.Dir(name)) + entries, readErr := os.ReadDir(dir) if readErr != nil { - return nil, err + return err } for _, e := range entries { - if strings.EqualFold(e.Name(), filepath.Base(name)) { - return orig(filepath.Join(filepath.Dir(name), e.Name())) + if strings.EqualFold(e.Name(), name) { + return orig(dirfd, e.Name()) } } - return nil, err + return err } t.Cleanup(func() { quiesceIndexRebuilds() - lstat = orig + fstatatEntry = orig }) } // quiesceIndexRebuilds waits for every rebuild goroutine the tests so far -// have left in flight. The package's seams (readDir, lstat, indexClock, -// spawnIndexRebuild) are process-wide, so a helper that installs or restores -// one must first let any rebuild still reading them land. +// have left in flight. The package's seams (the dirfd_other.go primitives, +// listScopedDirOnce, indexClock, spawnIndexRebuild) are process-wide, so a +// helper that installs or restores one must first let any rebuild still +// reading them land. func quiesceIndexRebuilds() { forEachIndex(func(idx *storedNames) { idx.mu.Lock() @@ -157,6 +162,76 @@ func requireScopedNotFound(t *testing.T, err error) { assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") } +// primCounts tallies the round 11 fd-bound primitives (dirfd_other.go) a +// scoped resolution or a rebuild goroutine performs: opens (openScopedDir), +// fstats (fstatDirGeneration — twice for a hit: before the lookup and after +// the open), fstatats (fstatatEntry — the candidate's own probe) and openats +// (openatEntry — the actual read). lists counts listScopedDir, the rebuild +// goroutine's own readdir. These replace the readDir/lstat counters +// countDirectoryPrimitives (codescripts_test.go) still uses for the +// administrator's unchanged, path-based decision. +type primCounts struct { + opens, fstats, fstatats, openats, lists int +} + +// countScopedDirPrimitives routes every round 11 fd-bound primitive through +// counters for the duration of the test, so a test can pin the EXACT +// bounded cost of one request — one open, two fstats, one fstatat, one +// openat for a hit; fewer for a miss, which never probes or opens a +// candidate — whatever the directory holds. Any rebuild still in flight +// lands first so its own calls do not pollute what the counted request +// itself performs. +func countScopedDirPrimitives(t *testing.T) *primCounts { + t.Helper() + c := &primCounts{} + quiesceIndexRebuilds() + origOpen, origFstat, origFstatat, origOpenat, origList := openScopedDir, fstatDirGeneration, fstatatEntry, openatEntry, listScopedDir + openScopedDir = func(path string) (int, error) { + c.opens++ + return origOpen(path) + } + fstatDirGeneration = func(fd int) (dirGeneration, error) { + c.fstats++ + return origFstat(fd) + } + fstatatEntry = func(dirfd int, name string) error { + c.fstatats++ + return origFstatat(dirfd, name) + } + openatEntry = func(dirfd int, name string) (*os.File, error) { + c.openats++ + return origOpenat(dirfd, name) + } + listScopedDir = func(dirfd int, path string) ([]string, error) { + c.lists++ + return origList(dirfd, path) + } + t.Cleanup(func() { + quiesceIndexRebuilds() + openScopedDir, fstatDirGeneration, fstatatEntry, openatEntry, listScopedDir = origOpen, origFstat, origFstatat, origOpenat, origList + }) + return c +} + +// lookupStoredNamesForTest is storedNamesFor for a self-contained, +// throwaway lookup: it opens the directory, reads its generation, calls +// storedNamesFor, and closes the descriptor itself. Production code never +// needs this — every real request already holds the session +// storedSpellingsOf opened for it — but the package's own tests, which only +// want to inspect what the index currently answers, do. +func lookupStoredNamesForTest(t *testing.T, dir string) map[string]struct{} { + t.Helper() + key := filepath.Clean(dir) + fd, err := openScopedDir(key) + require.NoError(t, err) + defer func() { _ = unix.Close(fd) }() + gen, err := fstatDirGeneration(fd) + require.NoError(t, err) + names, err := storedNamesFor(key, fd, gen) + require.NoError(t, err) + return names +} + // TestResolveScoped_OnAFoldingDirectory (Spec 105 FR-012, codex r3 #1, r4 #1 // and r5 #1): Linux has no single-entry call that reports an entry's stored // spelling, so on a case-folding mount (ext4 casefold, vfat, a bind mount @@ -168,24 +243,29 @@ func requireScopedNotFound(t *testing.T, err error) { // scoped resolver answers from the directory's stored-name index, so a // folded spelling is refused with the ordinary non-disclosing not-found, an // exact name runs for every caller, and no request lists the directory — -// warm or cold. The folding lookup is simulated through the lstat seam so -// the rule is pinned on the case-sensitive filesystems CI runs on; the same -// test on a real folding mount (TMPDIR and GOTMPDIR on a Docker Desktop bind -// mount of an APFS directory) exercises the kernel's own fold. +// warm or cold. Since round 11 the index is validated and probed through a +// single retained directory descriptor per request (dirfd_other.go); a +// folded name is refused here purely because it is not a KEY in the index's +// exact-spelling set (built from the real on-disk names), so it never even +// reaches the candidate probe — the fold simulation matters for the +// STALE-index scenario below, where a name that WAS a key must still be +// refused. func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "backdoor.JS", "({pwned: true})") writeScript(t, dir, "exact.js", "({exact: true})") - simulateCaseFoldingLstat(t) + simulateCaseFoldingFstatat(t, dir) warmStoredNames(t, dir) t.Run("a folded spelling is not a stored script, and settling it lists nothing", func(t *testing.T) { - readDirs, lstats := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) src, _, err := ResolveScoped(dir, "backdoor", "") requireScopedNotFound(t, err) assert.NotContains(t, string(src), "pwned") - assert.Equal(t, 0, *readDirs, "a warm index answers the fold without a listing (codex r5 #1)") - assert.Equal(t, 1, *lstats, "one directory Lstat validates the index; the candidate itself is never probed") + assert.Equal(t, 0, c.lists, "a warm index answers the fold without a listing (codex r5 #1)") + assert.Equal(t, 1, c.opens, "one directory open validates the index") + assert.Equal(t, 1, c.fstats, "one fstat reads its generation; the candidate itself is never probed") + assert.Equal(t, 0, c.fstatats, "\"backdoor.js\" is not a key of the index built from the real on-disk name") // The administrator's directory read agrees: byte-for-byte, .JS is // not an extension of a stored script. @@ -196,13 +276,16 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { }) t.Run("an exactly spelled script runs for scoped callers and administrators alike", func(t *testing.T) { - readDirs, lstats := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) src, lang, err := ResolveScoped(dir, "exact", "") require.NoError(t, err, "a correctly named script must not be refused to an agent token (codex r4 #1)") assert.Equal(t, "({exact: true})", string(src)) assert.Equal(t, LanguageJavaScript, lang) - assert.Equal(t, 0, *readDirs) - assert.Equal(t, 3, *lstats, "the directory Lstat, the hit's own probe, and the post-open generation recheck (round 8 MUST-FIX)") + assert.Equal(t, 0, c.lists) + assert.Equal(t, 1, c.opens, "the SINGLE directory descriptor this request opens (round 11 MUST-FIX)") + assert.Equal(t, 2, c.fstats, "the generation read before the lookup and the post-open recheck after (round 8 MUST-FIX), both on that same descriptor") + assert.Equal(t, 1, c.fstatats, "the hit's own candidate probe") + assert.Equal(t, 1, c.openats, "the open, relative to the same descriptor") src, lang, err = Resolve(dir, "exact", "") require.NoError(t, err) @@ -212,30 +295,29 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { t.Run("an absent name and a present case-variant cost the same, cold and warm", func(t *testing.T) { held := holdIndexRebuilds(t) - cost := func(name string) (readDirs, lstats int) { + cost := func(name string) (opens, fstats int) { forgetIndex(filepath.Clean(dir)) // cold - rd, ls := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) _, _, err := ResolveScoped(dir, name, "") requireScopedNotFound(t, err) - assert.Equal(t, 0, *rd, "%s: a cold request lists nothing itself (codex r6 #1)", name) + assert.Equal(t, 0, c.lists, "%s: a cold request lists nothing itself (codex r6 #1)", name) assert.Equal(t, 1, held.land(), "%s: it schedules the one rebuild", name) - assert.Equal(t, 1, *rd, "%s: which is the one listing, off the request path", name) - cold := *ls + assert.Equal(t, 1, c.lists, "%s: which is the one listing, off the request path", name) + cOpens, cFstats := c.opens, c.fstats _, _, err = ResolveScoped(dir, name, "") requireScopedNotFound(t, err) - assert.Equal(t, 1, *rd, "%s: the second request finds the index warm", name) + assert.Equal(t, 1, c.lists, "%s: the second request finds the index warm", name) assert.Equal(t, 0, held.land(), "%s: and schedules nothing", name) - return *rd, cold + return c.opens - cOpens, c.fstats - cFstats } - absentReadDirs, absentLstats := cost("missing") - variantReadDirs, variantLstats := cost("backdoor") - assert.Equal(t, absentReadDirs, variantReadDirs, "the listing count does not depend on the requested name") - assert.Equal(t, absentLstats, variantLstats, "nor does the probe count") + absentOpens, absentFstats := cost("missing") + variantOpens, variantFstats := cost("backdoor") + assert.Equal(t, absentOpens, variantOpens, "the open count does not depend on the requested name") + assert.Equal(t, absentFstats, variantFstats, "nor does the fstat count") }) t.Run("the index holds the stored spelling, so the fold is settled by an exact lookup", func(t *testing.T) { - names, _, err := storedNamesFor(dir) - require.NoError(t, err) + names := lookupStoredNamesForTest(t, dir) assert.Contains(t, names, "backdoor.JS") assert.NotContains(t, names, "backdoor.js") assert.Contains(t, names, "exact.js") @@ -250,17 +332,17 @@ func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { // case-folding mount that executes the wrong file: warm the index with // `report.js`, then rename it to `REPORT.JS` (a real rename, so the // directory's generation genuinely moves); the stale index still contains -// `report.js`, and that entry's own Lstat — simulated through the lstat seam -// so the fold is exercised on the case-sensitive filesystems CI runs on — -// folds onto the renamed file and succeeds, which round 7 trusted as a hit. -// The fix: the index answers ONLY for the generation it was built against, -// so a request landing while the rebuild is merely scheduled is refused -// exactly like a never-built index, without ever probing the candidate the -// stale index used to hold. +// `report.js`, and that entry's own probe — simulated through the +// fstatatEntry seam so the fold is exercised on the case-sensitive +// filesystems CI runs on — folds onto the renamed file and succeeds, which +// round 7 trusted as a hit. The fix: the index answers ONLY for the +// generation it was built against, so a request landing while the rebuild +// is merely scheduled is refused exactly like a never-built index, without +// ever probing the candidate the stale index used to hold. func TestResolveScoped_StaleIndexRefusesARenamedEntry(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "report.js", "({pwned: true})") - simulateCaseFoldingLstat(t) + simulateCaseFoldingFstatat(t, dir) warmStoredNames(t, dir) held := holdIndexRebuilds(t) @@ -270,14 +352,16 @@ func TestResolveScoped_StaleIndexRefusesARenamedEntry(t *testing.T) { require.NoError(t, os.Rename(filepath.Join(dir, "report.js"), filepath.Join(dir, "REPORT.JS"))) waitForGenerationChange(t, dir, dirGenerationOf(before)) - readDirs, lstats := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) src, _, err := ResolveScoped(dir, "report", "") requireScopedNotFound(t, err) - assert.Nil(t, src, "the stale index must never authorize the renamed file, whatever its own Lstat folds onto") - assert.Equal(t, 0, *readDirs, "the refusal lists nothing (it is fail-closed on the generation mismatch alone)") - assert.Equal(t, 1, *lstats, "one directory Lstat decides staleness; the stale index's candidate is never probed") + assert.Nil(t, src, "the stale index must never authorize the renamed file, whatever its own probe folds onto") + assert.Equal(t, 0, c.lists, "the refusal lists nothing (it is fail-closed on the generation mismatch alone)") + assert.Equal(t, 1, c.opens, "one directory open") + assert.Equal(t, 1, c.fstats, "one fstat decides staleness; the stale index's candidate is never probed") + assert.Equal(t, 0, c.fstatats) assert.Equal(t, 1, held.land(), "the rename moved the generation: one rebuild is scheduled") - assert.Equal(t, 1, *readDirs, "which is the one listing, off the request path") + assert.Equal(t, 1, c.lists, "which is the one listing, off the request path") // The rebuild has landed: the index now holds REPORT.JS, not report.js. // The old spelling is still refused — never executed — for the same @@ -294,58 +378,57 @@ func TestResolveScoped_StaleIndexRefusesARenamedEntry(t *testing.T) { // TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses (round 8 // MUST-FIX, the lookup→open race): an index hit is re-probed by the -// candidate's own Lstat, but neither that nor a successful no-follow open -// proves the file just opened is the one the index vouched for — a write -// landing between the probe and the open can leave a DIFFERENT file -// occupying the exact name for the descriptor's entire lifetime, and a -// no-follow open does not compare names, only symlink status. The directory -// generation is read once more after the open and must still equal the one -// read before the lookup; a mismatch closes the descriptor and refuses. The -// race is simulated deterministically through the lstat seam: the -// directory's SECOND Lstat this request performs (the post-open recheck) is -// where a real race could land at an arbitrary point, so that is where the -// swap happens here. +// candidate's own no-follow stat, but neither that nor a successful +// no-follow open proves the file just opened is the one the index vouched +// for — a write landing between the probe and the open can leave a +// DIFFERENT file occupying the exact name for the descriptor's entire +// lifetime. The directory's generation (round 11: read from the SAME +// retained descriptor the whole request uses) is read once more after the +// open and must still equal the one read before the lookup; a mismatch +// closes the descriptor and refuses. The race is simulated at the seam +// where it actually lands in production: right after openatEntry succeeds +// and before the post-open recheck runs. func TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha.js", "({original: true})") warmStoredNames(t, dir) - orig := lstat - seenDirLstats := 0 - t.Cleanup(func() { lstat = orig }) - lstat = func(name string) (os.FileInfo, error) { - if name == dir { - seenDirLstats++ - if seenDirLstats == 2 { - // Races the open: a write lands after the index vouched for - // the candidate but before the descriptor is trusted. - require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) - require.NoError(t, os.WriteFile(filepath.Join(dir, "alpha.js"), []byte("({swapped: true})"), 0o644)) - // A same-name remove-then-recreate can land on the exact - // same coarse directory timestamp as the original write (a - // container filesystem observed to do this even at - // nanosecond "resolution"): force the generation forward so - // it is unambiguously the write's, not the clock's - // granularity, that the recheck must catch. - require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Second))) - } + origOpenat := openatEntry + var races int + t.Cleanup(func() { openatEntry = origOpenat }) + openatEntry = func(dirfd int, name string) (*os.File, error) { + f, err := origOpenat(dirfd, name) + if err == nil && name == "alpha.js" && races == 0 { + races++ + // Races the open: a write lands after the index vouched for the + // candidate and the open succeeded, but before the descriptor is + // trusted. + require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "alpha.js"), []byte("({swapped: true})"), 0o644)) + // A same-name remove-then-recreate can land on the exact same + // coarse directory timestamp as the original write (a container + // filesystem observed to do this even at nanosecond + // "resolution"): force the generation forward so it is + // unambiguously the write's, not the clock's granularity, that + // the recheck must catch. + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Second))) } - return orig(name) + return f, err } src, _, err := ResolveScoped(dir, "alpha", "") requireScopedNotFound(t, err) assert.Nil(t, src, "a file swapped in during the open's own window must never be read, original or swapped content alike") - assert.Equal(t, 2, seenDirLstats, "the directory Lstat before the lookup and the recheck after the open") + assert.Equal(t, 1, races, "the race must actually have run for this to prove anything") } // TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize (codex r6 // #1): the FIRST scoped request against a directory — before any index // exists — must cost the same for an empty directory and for one holding ten // thousand scripts. It lists nothing on its own goroutine, performs the same -// one directory Lstat, schedules the one rebuild and is refused fail-closed; -// the listing happens when the rebuild lands, and the next request is -// answered from it. +// bounded number of directory primitives and is refused fail-closed; the +// listing happens when the rebuild lands, and the next request is answered +// from it. func TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize(t *testing.T) { empty := t.TempDir() crowded := t.TempDir() @@ -355,32 +438,37 @@ func TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize(t *testing.T) settleStoredNamesClock(t) held := holdIndexRebuilds(t) - probe := func(dir string) (readDirs, lstats int) { + probe := func(dir string) (opens, fstats int) { forgetIndex(filepath.Clean(dir)) // cold: never warmed - rd, ls := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) _, _, err := ResolveScoped(dir, "script-00042", "") requireScopedNotFound(t, err) - assert.Equal(t, 0, *rd, "%s: a cold request must not list on its own goroutine", dir) - lstats = *ls + assert.Equal(t, 0, c.lists, "%s: a cold request must not list on its own goroutine", dir) + // Snapshot before landing the rebuild: the rebuild's own listing + // performs its own open/fstat calls through the SAME counters, which + // must not be attributed to the request that merely scheduled it. + opens, fstats = c.opens, c.fstats assert.Equal(t, 1, held.land(), "%s: the cold request schedules exactly one rebuild", dir) - return *rd, lstats + return opens, fstats } - emptyReadDirs, emptyLstats := probe(empty) - crowdedReadDirs, crowdedLstats := probe(crowded) - assert.Equal(t, 1, emptyReadDirs, "the rebuild is the one listing") - assert.Equal(t, 1, crowdedReadDirs, "ten thousand entries are listed once, off the request path") - assert.Equal(t, emptyLstats, crowdedLstats, "the number of Lstats is independent of the directory's contents") - assert.Equal(t, 1, emptyLstats, "the request's own directory Lstat") + emptyOpens, emptyFstats := probe(empty) + crowdedOpens, crowdedFstats := probe(crowded) + assert.Equal(t, emptyOpens, crowdedOpens, "the number of directory opens is independent of the directory's contents") + assert.Equal(t, emptyFstats, crowdedFstats, "nor does the fstat count depend on it") + assert.Equal(t, 1, emptyOpens, "the request's own directory open") // Landed: the script that was refused a moment ago now runs, with no // listing on the request goroutine and none scheduled. - rd, ls := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) src, _, err := ResolveScoped(crowded, "script-00042", "") require.NoError(t, err, "after the rebuild lands the same request executes") assert.Equal(t, "1", string(src)) - assert.Equal(t, 0, *rd) - assert.Equal(t, 3, *ls, "the directory Lstat, the hit's own probe, and the post-open generation recheck (round 8 MUST-FIX)") + assert.Equal(t, 0, c.lists) + assert.Equal(t, 1, c.opens, "the single retained descriptor (round 11 MUST-FIX)") + assert.Equal(t, 2, c.fstats, "the generation read before the lookup and the post-open recheck (round 8 MUST-FIX)") + assert.Equal(t, 1, c.fstatats, "the hit's own candidate probe") + assert.Equal(t, 1, c.openats) assert.Equal(t, 0, held.land()) } @@ -396,13 +484,13 @@ func TestStoredNames_GenerationChangeRebuildsOffTheRequestPath(t *testing.T) { writeScript(t, dir, "alpha.js", "1") warmStoredNames(t, dir) held := holdIndexRebuilds(t) - readDirs, lstats := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) for i, name := range []string{"alpha", "missing", "ALPHA"} { for j := 0; j < 20; j++ { _, _, _ = ResolveScoped(dir, name, "") } - assert.Equal(t, 0, *readDirs, "%d: an unchanged directory is never listed", i) + assert.Equal(t, 0, c.lists, "%d: an unchanged directory is never listed", i) assert.Equal(t, 0, held.land(), "%d: nor is a rebuild scheduled", i) } @@ -412,19 +500,19 @@ func TestStoredNames_GenerationChangeRebuildsOffTheRequestPath(t *testing.T) { writeScript(t, dir, "beta.ts", "1") waitForGenerationChange(t, dir, dirGenerationOf(before)) - *lstats = 0 + c.opens, c.fstats = 0, 0 _, _, err = ResolveScoped(dir, "beta", "") requireScopedNotFound(t, err) // fail closed until the rebuild lands - assert.Equal(t, 0, *readDirs, "the request that finds the generation moved lists nothing itself (codex r6 #1)") - assert.Equal(t, 1, *lstats, "one directory Lstat, no candidate probe") + assert.Equal(t, 0, c.lists, "the request that finds the generation moved lists nothing itself (codex r6 #1)") + assert.Equal(t, 1, c.opens, "one directory open, no candidate probe") assert.Equal(t, 1, held.land(), "it schedules the one rebuild") - assert.Equal(t, 1, *readDirs, "which is the one listing") + assert.Equal(t, 1, c.lists, "which is the one listing") src, lang, err := ResolveScoped(dir, "beta", "") require.NoError(t, err, "the script added is found once the rebuild has landed") assert.Equal(t, "1", string(src)) assert.Equal(t, LanguageTypeScript, lang) - assert.Equal(t, 1, *readDirs) + assert.Equal(t, 1, c.lists) assert.Equal(t, 0, held.land(), "the directory is warm again") }) @@ -439,16 +527,16 @@ func TestStoredNames_GenerationChangeRebuildsOffTheRequestPath(t *testing.T) { writeScript(t, dir, "beta.ts", "1") waitForGenerationChange(t, dir, dirGenerationOf(before)) - readDirs, _ := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) _, _, err = ResolveScoped(dir, "beta", "") requireScopedNotFound(t, err) waitForIndexRebuild(t, dir) - assert.Equal(t, 1, *readDirs, "the rebuild is the one listing") + assert.Equal(t, 1, c.lists, "the rebuild is the one listing") src, _, err := ResolveScoped(dir, "beta", "") require.NoError(t, err, "a script added to the directory is callable after the rebuild lands") assert.Equal(t, "1", string(src)) - assert.Equal(t, 1, *readDirs) + assert.Equal(t, 1, c.lists) // The administrator's directory read never waited for anything. _, _, err = Resolve(dir, "beta", "") @@ -460,7 +548,10 @@ func TestStoredNames_GenerationChangeRebuildsOffTheRequestPath(t *testing.T) { // generationSettleTime old, so the next write lands on a later stamp whatever // the filesystem's timestamp granularity (Linux stamps files with the coarse // tick clock, so a write in the same tick as the index's listing would not -// move the generation — the guarantee the index itself relies on). +// move the generation — the guarantee the index itself relies on). Uses the +// package's path-based dirGenerationOf/lstat reader (dirgeneration_ctim.go / +// dirgeneration_ctimespec.go), unchanged by round 11 — a convenience for +// test bookkeeping only, never on the request or rebuild path. func outliveStamp(t *testing.T, dir string) { t.Helper() info, err := lstat(dir) @@ -492,11 +583,7 @@ func waitForGenerationChange(t *testing.T, dir string, was dirGeneration) { // is refused exactly as a never-built index is (round 8 MUST-FIX): a script // removed after the last listing is refused at once, before the rebuild // that will drop it from the index has even STARTED to run, and WITHOUT -// probing the candidate the stale index used to hold (earlier rounds still -// answered from that stale index and let the candidate's own Lstat, which -// happened to miss here, catch the removal — a stale index is refused on -// the generation mismatch alone now, so there is nothing left for a -// candidate probe to catch or miss). +// probing the candidate the stale index used to hold. func TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha.js", "1") @@ -508,15 +595,15 @@ func TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands(t *testing.T) require.NoError(t, err) require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) waitForGenerationChange(t, dir, dirGenerationOf(before)) - readDirs, lstats := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) _, _, err = ResolveScoped(dir, "alpha", "") requireScopedNotFound(t, err) - assert.Equal(t, 0, *readDirs, "the refusal lists nothing") - assert.Equal(t, 1, *lstats, "the directory Lstat alone decides staleness; the stale index's candidate is never probed (round 8 MUST-FIX)") + assert.Equal(t, 0, c.lists, "the refusal lists nothing") + assert.Equal(t, 1, c.opens, "the directory open alone decides staleness; the stale index's candidate is never probed (round 8 MUST-FIX)") + assert.Equal(t, 0, c.fstatats) assert.Equal(t, 1, held.land(), "the removal moved the generation: one rebuild") - names, _, err := storedNamesFor(dir) - require.NoError(t, err) + names := lookupStoredNamesForTest(t, dir) assert.NotContains(t, names, "alpha.js") _, _, err = ResolveScoped(dir, "alpha", "") requireScopedNotFound(t, err) @@ -541,17 +628,17 @@ func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { indexClock = func() time.Time { return stamp.Add(generationSettleTime / 2) } held := holdIndexRebuilds(t) require.NoError(t, Warm(dir), "warmed inside the window: the index is not settled") - readDirs, lstats := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) // requests issues scoped misses and pins each one's own cost: no listing, - // one directory Lstat — the landed rebuilds' calls are counted between. + // one directory open — the landed rebuilds' calls are counted between. requests := func(label string, names ...string) { for i, name := range names { - rd, ls := *readDirs, *lstats + lists, opens := c.lists, c.opens _, _, err := ResolveScoped(dir, name, "") requireScopedNotFound(t, err) - assert.Equal(t, rd, *readDirs, "%s %d: a request never lists", label, i) - assert.Equal(t, ls+1, *lstats, "%s %d: one directory Lstat per request", label, i) + assert.Equal(t, lists, c.lists, "%s %d: a request never lists", label, i) + assert.Equal(t, opens+1, c.opens, "%s %d: one directory open per request", label, i) } } @@ -563,7 +650,7 @@ func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { // nothing, hit or miss alike. requests("inside the window", "alpha", "missing", "gamma", "missing") assert.Equal(t, 1, held.land(), "the unsettled index schedules ONE refresh per window, not one per request") - assert.Equal(t, 1, *readDirs) + assert.Equal(t, 1, c.lists) requests("still inside", "alpha", "missing", "gamma") assert.Equal(t, 0, held.land(), "the window is open until it elapses") @@ -571,10 +658,10 @@ func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { indexClock = func() time.Time { return stamp.Add(generationSettleTime/2 + generationSettleTime) } requests("next window", "alpha", "missing") assert.Equal(t, 1, held.land(), "the next window schedules one more refresh") - assert.Equal(t, 2, *readDirs) + assert.Equal(t, 2, c.lists) requests("settled", "missing", "gamma", "missing") assert.Equal(t, 0, held.land(), "the listing landed past the stamp's settle time: the index is trusted") - assert.Equal(t, 2, *readDirs) + assert.Equal(t, 2, c.lists) // Only now — genuinely settled, not merely gen-matching — does the real // hit run (round 9 MUST-FIX). @@ -591,28 +678,31 @@ func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { // directory keeps doing next; what the last attempt installed simply goes // stale against the directory's true current generation, and the next // request's own check (the MUST-FIX rule above) refuses it rather than this -// loop spinning to prove something it never can. +// loop spinning to prove something it never can. The churn is simulated at +// fstatDirGeneration — called twice per listing attempt by listScopedDirOnce +// (round 11 MUST-FIX), once before the listing and once after — advancing +// the directory's own mtime on every call so the two never agree within one +// attempt. func TestStoredNames_RebuildAttemptsAreBounded(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha.js", "1") quiesceIndexRebuilds() - origLstat, origReadDir := lstat, readDir - var lstats, readDirs int - t.Cleanup(func() { lstat, readDir = origLstat, origReadDir }) - lstat = func(name string) (os.FileInfo, error) { - if name == dir { - lstats++ - // Simulate another process continuously changing the directory: - // its own generation moves on every observation, so rebuild's - // list-then-recheck can never confirm stability. - require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Duration(lstats)*time.Second))) - } - return origLstat(name) + origFstat, origList := fstatDirGeneration, listScopedDir + var fstats, lists int + t.Cleanup(func() { fstatDirGeneration, listScopedDir = origFstat, origList }) + fstatDirGeneration = func(fd int) (dirGeneration, error) { + fstats++ + // Simulate another process continuously changing the directory: its + // own generation moves on every observation, so the before/after + // check listScopedDirOnce performs around the listing can never + // confirm stability. + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Duration(fstats)*time.Second))) + return origFstat(fd) } - readDir = func(name string) ([]fs.DirEntry, error) { - readDirs++ - return origReadDir(name) + listScopedDir = func(dirfd int, path string) ([]string, error) { + lists++ + return origList(dirfd, path) } done := make(chan error, 1) @@ -623,7 +713,7 @@ func TestStoredNames_RebuildAttemptsAreBounded(t *testing.T) { case <-time.After(10 * time.Second): t.Fatal("Warm did not return against a continuously changing directory (round 8 SHOULD)") } - assert.Equal(t, maxRebuildAttempts, readDirs, "one rebuild lists at most maxRebuildAttempts times, however long the directory keeps changing") + assert.Equal(t, maxRebuildAttempts, lists, "one rebuild lists at most maxRebuildAttempts times, however long the directory keeps changing") } // TestStoredNames_RebuildBackoffThrottlesReschedules (round 8 SHOULD): once @@ -633,8 +723,9 @@ func TestStoredNames_RebuildAttemptsAreBounded(t *testing.T) { // on every request would let scheduleRebuildLocked spawn a fresh rebuild the // instant the bounded one above gives up, resuming the same unbounded // listing cost one goroutine later. A request inside the backoff still -// costs one Lstat and answers fail-closed from whatever the index holds (or -// does not); only the new rebuild goroutine is withheld. +// costs the same bounded directory primitives and answers fail-closed from +// whatever the index holds (or does not); only the new rebuild goroutine is +// withheld. func TestStoredNames_RebuildBackoffThrottlesReschedules(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "alpha.js", "1") @@ -684,7 +775,7 @@ func TestStoredNames_WarmListsAfterAnInFlightRebuild(t *testing.T) { writeScript(t, dir, "alpha.js", "1") settleStoredNamesClock(t) held := holdIndexRebuilds(t) - readDirs, _ := countDirectoryPrimitives(t) + c := countScopedDirPrimitives(t) _, _, err := ResolveScoped(dir, "alpha", "") requireScopedNotFound(t, err) // cold: the rebuild is scheduled and held @@ -699,7 +790,7 @@ func TestStoredNames_WarmListsAfterAnInFlightRebuild(t *testing.T) { } assert.Equal(t, 1, held.land(), "the held rebuild lands") require.NoError(t, <-warmed) - assert.Equal(t, 2, *readDirs, "Warm listed again after the in-flight rebuild landed") + assert.Equal(t, 2, c.lists, "Warm listed again after the in-flight rebuild landed") src, _, err := ResolveScoped(dir, "beta", "") require.NoError(t, err, "the script written before Warm is in the index Warm returned") @@ -712,8 +803,7 @@ func TestStoredNames_WarmListsAfterAnInFlightRebuild(t *testing.T) { // unreadable form — no path, no OS error — on the very first request, cold // or warm, whatever the index holds: the request's own constant-cost open of // the directory decides it, exactly where the administrator's directory read -// refuses (SC-005). (Answering not-found until a rebuild had recorded the -// error made the refusal shape depend on index state.) +// refuses (SC-005). func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: directory permissions are not enforced") @@ -762,8 +852,10 @@ func TestDirGeneration_DeviceIsPartOfIdentity(t *testing.T) { assert.True(t, onDeviceA.equal(onDeviceA), "a generation always equals itself") } -// TestDirGenerationOf_ReadsTheDevice pins that the platform reader actually -// populates dev from a real Lstat, not just that equal() considers it. +// TestDirGenerationOf_ReadsTheDevice pins that the path-based platform +// reader actually populates dev from a real Lstat, not just that equal() +// considers it. TestDirFdGeneration_ReadsTheDevice below pins the same for +// the fd-based reader round 11 introduced. func TestDirGenerationOf_ReadsTheDevice(t *testing.T) { dir := t.TempDir() info, err := lstat(dir) @@ -772,6 +864,178 @@ func TestDirGenerationOf_ReadsTheDevice(t *testing.T) { assert.NotZero(t, gen.dev, "a real directory's device must be read, not left at the zero value") } +// TestDirFdGeneration_ReadsTheDevice (round 11 MUST-FIX): the fd-based +// generation reader every scoped request and rebuild actually uses +// (dirfd_other.go) must populate dev/ino identically to the path-based +// reader the tests and the administrator's bookkeeping use — both describe +// the SAME real directory here, so they must agree exactly. +func TestDirFdGeneration_ReadsTheDevice(t *testing.T) { + dir := t.TempDir() + info, err := lstat(dir) + require.NoError(t, err) + fromPath := dirGenerationOf(info) + + fd, err := defaultOpenScopedDir(dir) + require.NoError(t, err) + defer func() { _ = unix.Close(fd) }() + fromFd, err := defaultFstatDirGeneration(fd) + require.NoError(t, err) + + assert.NotZero(t, fromFd.dev) + assert.Equal(t, fromPath.dev, fromFd.dev, "the same real directory's device must read the same whether reached by path or by descriptor") + assert.Equal(t, fromPath.ino, fromFd.ino) +} + +// TestStoredNamesFor_IdentityMismatchIsAMiss (round 11 MUST-FIX): even when +// a request's own directory descriptor happens to agree with the index's +// recorded generation on every OTHER field, a different device (the +// bind-mount-swap scenario round 9's dirGeneration.equal already refuses at +// the field-comparison level) must never authorize a hit, exercised here +// through the actual lookup function every request calls rather than only +// at the struct-equality level. +func TestStoredNamesFor_IdentityMismatchIsAMiss(t *testing.T) { + held := holdIndexRebuilds(t) + key := "codescripts-test-identity-mismatch-dir-does-not-exist" + forgetIndex(key) + t.Cleanup(func() { forgetIndex(key) }) + + idx := storedNamesIndex(key) + idx.mu.Lock() + idx.names = map[string]struct{}{"report.js": {}} + idx.gen = dirGeneration{modTime: time.Unix(1, 0), changeTime: time.Unix(1, 0), size: 4096, ino: 42, dev: 1} + idx.settled = true + idx.mu.Unlock() + + sameButDifferentDevice := dirGeneration{modTime: time.Unix(1, 0), changeTime: time.Unix(1, 0), size: 4096, ino: 42, dev: 2} + names, err := storedNamesFor(key, -1, sameButDifferentDevice) + require.NoError(t, err) + assert.Nil(t, names, "an index built for one directory must never answer for a request whose descriptor resolved to a different one") + held.land() // the mismatch schedules a (harmless, doomed-to-fail) rebuild of the bogus key; land it so nothing is left in flight +} + +// TestResolveScoped_DirectoryPathABA (round 11 MUST-FIX, the directory-path +// ABA hole): every earlier round's scoped resolution re-resolved scriptsDir +// BY PATH at each step — reading the generation, probing a candidate, +// opening it, and rechecking the generation were four independent lookups +// of the same path, each of which a replaceable symlink, ancestor +// directory, or bind mount retargeted between two of them could answer +// differently. The fix binds the whole request to the ONE descriptor +// storedSpellingsOf opens: everything the returned closures still do — the +// candidate probe already ran before the retarget below, the open, and the +// post-open recheck — must be UNAFFECTED by retargeting the path after that +// call returns, because none of them ever resolve scriptsDir again. A real +// symlink retarget between the session's own open and the caller's +// subsequent calls to open()/verifyUnchanged() is exactly the window a +// naive (path-re-resolving) implementation would lose to, and exactly the +// window production code — resolve(), in codescripts.go — leaves between +// calling candidates() and later calling the open and verify closures it +// returned. +func TestResolveScoped_DirectoryPathABA(t *testing.T) { + base := t.TempDir() + dirA := filepath.Join(base, "a") + dirB := filepath.Join(base, "b") + require.NoError(t, os.Mkdir(dirA, 0o755)) + require.NoError(t, os.Mkdir(dirB, 0o755)) + writeScript(t, dirA, "report.js", "FROM-A") + writeScript(t, dirB, "report.js", "FROM-B") + + link := filepath.Join(base, "scripts") + require.NoError(t, os.Symlink(dirA, link)) + warmStoredNames(t, link) + + storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(link) + require.NoError(t, err) + require.NotNil(t, open, "Linux/BSD always binds the open to the retained descriptor (round 11 MUST-FIX)") + require.NotNil(t, verifyUnchanged) + if closeSession != nil { + defer closeSession() + } + + ok, err := storedExactly("report.js") + require.NoError(t, err) + require.True(t, ok) + + // The window the fix closes: retarget the symlink AFTER the session + // above already resolved it (dirA), before this request's remaining + // steps run — exactly the gap between resolve() calling candidates() + // and resolve() later calling the open and verify closures it got back. + require.NoError(t, os.Remove(link)) + require.NoError(t, os.Symlink(dirB, link)) + + f, err := open(filepath.Join(link, "report.js")) + require.NoError(t, err, "the open must succeed against the descriptor this request originally resolved") + defer f.Close() + data, err := io.ReadAll(f) + require.NoError(t, err) + assert.Equal(t, "FROM-A", string(data), "the open must read the directory this request originally resolved, never the retargeted one") + + assert.NoError(t, verifyUnchanged(f, "report.js"), "the retarget must not be visible to the post-open recheck either: it reads the SAME descriptor's generation, unaffected by what the path now points at") +} + +// TestStoredNames_EvictionCancelsAnInFlightRebuild (round 11 SHOULD, +// cancellable rebuilds): a rebuild goroutine still mid-listing when its +// index is evicted (LRU) or pruned (Warm keeping only the active directory) +// must stop promptly rather than keep listing for a directory nobody will +// query through it any longer, and must install NOTHING — there is no +// reader left it could still be wrong for. The goroutine is parked inside +// listScopedDirOnce (via the openScopedDir seam) so the eviction genuinely +// races an in-flight rebuild rather than one that already finished; wg is +// the seam that proves the goroutine actually stopped, not merely that +// cancel was called. +func TestStoredNames_EvictionCancelsAnInFlightRebuild(t *testing.T) { + quiesceIndexRebuilds() + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + + release := make(chan struct{}) + entered := make(chan struct{}, 1) + origOpen := openScopedDir + t.Cleanup(func() { openScopedDir = origOpen }) + openScopedDir = func(path string) (int, error) { + select { + case entered <- struct{}{}: + default: + } + <-release + return origOpen(path) + } + + key := filepath.Clean(dir) + idx := storedNamesIndex(key) + idx.mu.Lock() + idx.beginRebuildLocked() + idx.mu.Unlock() + idx.wg.Add(1) + go idx.rebuild(key, true, true) + + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("the rebuild goroutine never reached the directory-open seam") + } + + // Evict it exactly as LRU eviction / Warm's own pruning of every other + // directory would: cancel, then drop from the map. + forgetIndex(key) + + close(release) // let the blocked open proceed; the listing itself succeeds + + done := make(chan struct{}) + go func() { idx.wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the rebuild goroutine did not stop after its index was evicted") + } + + idx.mu.Lock() + names, buildErr, building := idx.names, idx.err, idx.building + idx.mu.Unlock() + assert.Nil(t, names, "a cancelled rebuild installs nothing") + assert.NoError(t, buildErr, "nor does it install a failure") + assert.False(t, building, "the single-flight slot is released so a future request can rebuild") +} + // TestStoredNames_WarmKeepsOnlyTheActiveDirectory (round 9 SHOULD): the // server calls Warm whenever the active scripts directory changes, so Warm // itself is where "only the active directory is warm" can be enforced — diff --git a/internal/codescripts/storedspellings_probe.go b/internal/codescripts/storedspellings_probe.go index 3f0b54384..8224be3ca 100644 --- a/internal/codescripts/storedspellings_probe.go +++ b/internal/codescripts/storedspellings_probe.go @@ -1,4 +1,4 @@ -//go:build darwin || windows +//go:build darwin package codescripts @@ -17,17 +17,17 @@ import ( func Warm(string) error { return nil } // SetIndexClockForTest is a no-op here: there is no directory-generation -// index or settle window on darwin/Windows — storedSpellingsOf proves the -// spelling directly, on the descriptor that is actually opened, rather than -// trusting a listed generation. Present so a caller outside this package -// (an internal/server fixture built for every platform) compiles and runs -// unchanged on darwin and Windows, where there is nothing to settle. +// index or settle window on darwin — storedSpellingsOf proves the spelling +// directly, on the descriptor that is actually opened, rather than trusting +// a listed generation. Present so a caller outside this package (an +// internal/server fixture built for every platform) compiles and runs +// unchanged on darwin, where there is nothing to settle. func SetIndexClockForTest(func() time.Time) (restore func()) { return func() {} } // storedSpellingsOf answers, for one scoped request, whether scriptsDir holds // an entry spelled exactly `want`, by a fixed number of single-path calls and -// never a listing (Spec 105 FR-012). The default APFS/HFS+ and NTFS volumes -// are case-insensitive but case-PRESERVING, so the probe alone would accept +// never a listing (Spec 105 FR-012). The default APFS/HFS+ volume is +// case-insensitive but case-PRESERVING, so the probe alone would accept // `backdoor.JS` for `backdoor.js`; a hit is accepted only when the entry's // stored spelling (entryName, one single-entry platform call) is // byte-for-byte the requested one, exactly as List decides. @@ -36,9 +36,15 @@ func SetIndexClockForTest(func() time.Time) (restore func()) { return func() {} // replacement landing between this probe and openScriptFile's own open can // leave a different, case-folded file behind the same requested spelling for // the descriptor's entire lifetime, and neither a no-follow open nor Stat -// tells a folded spelling from an exact one. The second return proves the -// spelling AUTHORITATIVELY, on the descriptor that will actually be read — -// see below. +// tells a folded spelling from an exact one. The returned verifyUnchanged +// proves the spelling AUTHORITATIVELY, on the descriptor that will actually +// be read — see below. open and closeSession are always nil here (round 11: +// darwin is unchanged beyond this shared five-return signature — see +// storednames_other.go's Linux/BSD counterpart and +// storedspellings_probe_windows.go for the platforms that need them): +// openScriptFile's own O_SYMLINK-probed, O_NOFOLLOW no-follow open already +// resolves the path exactly once for the actual read, and there is no +// per-request resource to release. // // A platform-call failure here is not a match (round 9 MUST-FIX): earlier // rounds let the Lstat verdict alone stand when entryName errored, which @@ -46,7 +52,7 @@ func SetIndexClockForTest(func() time.Time) (restore func()) { return func() {} // need not be perfectly precise — the post-open proof is authoritative and // would still catch a wrongly admitted candidate — but there is no reason to // admit one on a failure this function cannot itself explain. -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), verifyUnchanged func(f *os.File, want string) error, err error) { +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { storedExactly = func(want string) (bool, error) { path := filepath.Join(scriptsDir, want) if _, err := lstat(path); err != nil { @@ -79,5 +85,5 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool } return nil } - return storedExactly, verifyUnchanged, nil + return storedExactly, nil, verifyUnchanged, nil, nil } diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go index 39f716bd9..afe895885 100644 --- a/internal/codescripts/storedspellings_probe_test.go +++ b/internal/codescripts/storedspellings_probe_test.go @@ -31,9 +31,13 @@ func TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor(t *testing. dir := t.TempDir() path := writeScript(t, dir, "alpha.js", "1") - storedExactly, verifyUnchanged, err := storedSpellingsOf(dir) + storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) require.NoError(t, err) + require.Nil(t, open, "darwin/Windows never bind the open to a per-request descriptor (round 11): openScriptFile's own no-follow open is already authoritative") require.NotNil(t, verifyUnchanged, "darwin/Windows always supply the authoritative post-open check") + if closeSession != nil { + defer closeSession() + } ok, err := storedExactly("alpha.js") require.NoError(t, err) require.True(t, ok) @@ -58,9 +62,12 @@ func TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor(t *tes dir := t.TempDir() path := writeScript(t, dir, "alpha.js", "1") - _, verifyUnchanged, err := storedSpellingsOf(dir) + _, _, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) require.NoError(t, err) require.NotNil(t, verifyUnchanged) + if closeSession != nil { + defer closeSession() + } // The race: the file is case-renamed between the pre-open probe and the // open. APFS and NTFS fold the requested spelling onto the renamed entry, @@ -90,9 +97,12 @@ func TestStoredSpellingsOf_PostOpenProofCatchesARenameAfterOpen(t *testing.T) { dir := t.TempDir() path := writeScript(t, dir, "alpha.js", "1") - _, verifyUnchanged, err := storedSpellingsOf(dir) + _, _, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) require.NoError(t, err) require.NotNil(t, verifyUnchanged) + if closeSession != nil { + defer closeSession() + } f, err := openScriptFile(path) require.NoError(t, err) diff --git a/internal/codescripts/storedspellings_probe_windows.go b/internal/codescripts/storedspellings_probe_windows.go new file mode 100644 index 000000000..691ea20bb --- /dev/null +++ b/internal/codescripts/storedspellings_probe_windows.go @@ -0,0 +1,92 @@ +//go:build windows + +package codescripts + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +// Warm is a no-op where storedSpellingsOf is a single-entry platform call: +// there is no index to build. The Linux/BSD counterpart lists the directory +// once, off the request path. +func Warm(string) error { return nil } + +// SetIndexClockForTest is a no-op here: there is no directory-generation +// index or settle window on Windows — storedSpellingsOf proves the spelling +// directly, on the descriptor that is actually opened, rather than trusting +// a listed generation. Present so a caller outside this package (an +// internal/server fixture built for every platform) compiles and runs +// unchanged on Windows, where there is nothing to settle. +func SetIndexClockForTest(func() time.Time) (restore func()) { return func() {} } + +// storedSpellingsOf answers, for one scoped request, whether scriptsDir holds +// an entry spelled exactly `want`, by a fixed number of single-path calls and +// never a listing (Spec 105 FR-012). NTFS is case-insensitive but +// case-PRESERVING, so the probe alone would accept `backdoor.JS` for +// `backdoor.js`; a hit is accepted only when the entry's stored spelling +// (entryName, one single-entry platform call) is byte-for-byte the requested +// one, exactly as List decides. This is the CHEAP pre-open gate only — the +// returned verifyUnchanged is what proves the winning candidate +// AUTHORITATIVELY, on the descriptor that is actually read. +// +// Round 11 MUST-FIX: a basename-only proof (round 9's openedEntryName) is +// satisfied by ANY identically named file reachable through a reparse point +// planted on the candidate itself, or on a symlinked ancestor directory, +// between the pre-open probe and openScriptFile's own open (which round 11 +// also hardened — see open_windows.go — to never follow a reparse point at +// the final component, closing that half of the race; the ancestor half +// remains open to a plain basename check). The fix opens the scripts +// directory itself ONCE per request (dirFinalPath, FILE_FLAG_BACKUP_SEMANTICS) +// and compares the opened candidate's FULL normalized path +// (openedFinalPath) against that directory's own final path plus the exact +// basename — so the proof confirms both the name AND the parent, and a +// retargeted ancestor cannot make an outside file's basename satisfy it. +// closeSession releases the directory handle once the caller (resolve, in +// codescripts.go) is done with it. open stays nil: openScriptFile's own +// no-follow open (round 11 MUST-FIX above) is already authoritative about +// which entry it opens; there is no descriptor to bind it to beyond that. +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { + dirPath, closeDir, err := dirFinalPath(scriptsDir) + if err != nil { + return nil, nil, nil, nil, err + } + closeSession = closeDir + baseline := strings.TrimRight(dirPath, `\`) + `\` + + storedExactly = func(want string) (bool, error) { + path := filepath.Join(scriptsDir, want) + if _, err := lstat(path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + stored, err := entryName(path) + switch { + case err != nil: + return false, nil + case stored != want && strings.EqualFold(stored, want): + return false, nil + } + return true, nil + } + verifyUnchanged = func(f *os.File, want string) error { + got, err := openedFinalPath(f) + if err != nil || got != baseline+want { + // Any failure of the proof call, a mismatched basename, or a + // parent directory other than the one this request opened all + // refuse alike (round 9 / round 11 MUST-FIX): the pre-open + // probe already decided "true" and the caller is about to read + // this descriptor, so an unprovable or misparented spelling + // gets no benefit of the doubt. + return errSpellingUnproven + } + return nil + } + return storedExactly, nil, verifyUnchanged, closeSession, nil +} From 80e28ec3a2e0106a38a10df66029a4fa3b64eb9f Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 17 Sep 2026 08:15:37 +0300 Subject: [PATCH 17/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?13=20=E2=80=94=20unify=20darwin/Windows=20onto=20the=20shared?= =?UTF-8?q?=20index=20+=20retained-descriptor=20design=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer decision on codex round-10's six findings: rather than patch darwin and Windows separately, unify every platform onto the same design. darwin joins the Linux/BSD fd-bound implementation (dirfd_other.go, storednames_other.go: build tag `!darwin && !windows` -> `unix`) — the per-directory exact-spelling index, off the request path, answers a scoped lookup in O(1) regardless of whether a differently-cased entry exists (closes the finding-3 timing oracle for darwin). F_GETPATH survives as an additional, belt-and-suspenders basename proof on top (extraVerifyOpened, entryname_darwin.go), since openat's own identity binding already proves the parent (closes finding 1: a retargeted scriptsDir symlink cannot redirect an already-open fd). Windows gets a parallel index implementation (storednames_windows.go) built from a directory HANDLE retained for the whole request; the candidate probe and the actual open are both performed RELATIVE TO THAT HANDLE via windows.NtCreateFile (OBJECT_ATTRIBUTES.RootDirectory), never a fresh path lookup a rename or reparse point could redirect (closes finding 2's ABA repro structurally: NT handles, like Unix fds, are bound to the file object, not the path). Sharing widened to READ|WRITE|DELETE on both the new relative opens and open_windows.go's administrator path (finding 4). Finding 5 (SHOULD): a process-wide semaphore (rebuildsemaphore.go, cap 2) bounds concurrent async rebuild goroutines across both platform index implementations — a rebuild that cannot acquire a slot is skipped, not queued, and the next request retries. Finding 6 (SHOULD): the `unix` build tag (plus open_unix.go, which had the identical latent gap) excludes plan9/js/wasip1, which now build via a new fail-closed fallback (fallback_other.go). Sibling sweep: Windows's CreateFile+FILE_FLAG_BACKUP_SEMANTICS does not itself refuse a non-directory the way unix's O_DIRECTORY does — the scripts directory replaced by a plain file — so defaultWinOpenScopedDir now checks FILE_ATTRIBUTE_DIRECTORY explicitly. Verified: full native darwin suite (-race, shuffle, real symlink-retarget ABA test) green; non-root Linux in Docker green; go vet clean for linux/freebsd/windows; GOOS=windows go test -c compiles; GOOS=plan9 and GOOS=js/wasm builds succeed (previously failing outright). Both editions build; internal/server (skip regex applied), internal/httpapi, internal/serveredition and cmd/mcpproxy all green. No tool-surface golden touched. Co-Authored-By: Claude Opus 5 --- docs/code_execution/overview.md | 31 +- docs/code_execution/troubleshooting.md | 35 +- docs/features/agent-tokens.md | 17 +- internal/codescripts/codescripts.go | 111 ++-- internal/codescripts/codescripts_test.go | 24 +- internal/codescripts/dirfd_other.go | 28 +- .../codescripts/dirgeneration_ctimespec.go | 14 +- internal/codescripts/entryname_darwin.go | 57 +- internal/codescripts/entryname_windows.go | 89 +-- internal/codescripts/fallback_other.go | 71 +++ internal/codescripts/indexclock.go | 69 ++ internal/codescripts/open_unix.go | 8 +- internal/codescripts/open_windows.go | 16 +- internal/codescripts/rebuildsemaphore.go | 41 ++ internal/codescripts/storednames_other.go | 135 ++-- .../codescripts/storednames_other_test.go | 53 +- internal/codescripts/storednames_windows.go | 603 ++++++++++++++++++ internal/codescripts/storedspellings_probe.go | 89 --- .../codescripts/storedspellings_probe_test.go | 161 +++-- .../storedspellings_probe_windows.go | 92 --- 20 files changed, 1228 insertions(+), 516 deletions(-) create mode 100644 internal/codescripts/fallback_other.go create mode 100644 internal/codescripts/indexclock.go create mode 100644 internal/codescripts/rebuildsemaphore.go create mode 100644 internal/codescripts/storednames_windows.go delete mode 100644 internal/codescripts/storedspellings_probe.go delete mode 100644 internal/codescripts/storedspellings_probe_windows.go diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index ef3591e32..bd6f52463 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -429,20 +429,23 @@ the same fail-closed way. A script added to, or renamed within, the directory becomes callable by agent tokens once the index has both refreshed AND settled — typically milliseconds for the refresh, up to about two seconds to settle; retry a call refused in that window — while -administrators see the change immediately. On darwin and Windows, where a -single-entry platform call reports a path's stored spelling directly, that -pre-open probe is only the cheap gate: the authoritative check re-reads the -stored spelling of the file descriptor MCPProxy actually opened -(`F_GETPATH` on darwin, `GetFinalPathNameByHandle` on Windows) and compares -it byte-for-byte to the requested name, so a case-rename racing the open -itself is caught on the descriptor that would have been read, not just on -an earlier probe of the same path. On Windows the open itself never follows -a reparse point at the final path component either, and the post-open check -compares the descriptor's FULL normalized path against the scripts -directory's own — opened once per request — not just the file's base name, -so a reparse point planted on the candidate or on an ancestor directory -cannot substitute a file from elsewhere under the same name.) The refusal -itself: +administrators see the change immediately. Every platform — Linux, the +BSDs, darwin and Windows alike — answers from this same index, so a name +that is merely a case-variant of a stored one and a name that is not stored +at all cost the same: both are plain index misses. macOS/darwin adds one +extra, belt-and-suspenders check on top: after the winning candidate is +opened, MCPProxy re-reads its on-disk spelling from the open descriptor +itself (`F_GETPATH`) and compares it to what was requested, so a +case-rename racing the open is caught on the descriptor that would actually +have been read. On Windows every step — probing a candidate, opening it, +listing the directory to refresh the index — is performed relative to ONE +directory handle retained for the whole call (`NtCreateFile` with the +handle as the open's root), so a rename or a reparse point planted on the +directory itself or an ancestor cannot redirect where a "relative" open +actually lands; the post-open check then only needs to confirm the opened +descriptor's own base name (`GetFinalPathNameByHandle`), since the parent +is already structurally guaranteed by the handle-relative open itself. The +refusal itself: ```text Cannot execute stored script: stored script "fetch-pr" not found (the stored-script diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index b685cb090..145c9b3cc 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -645,16 +645,17 @@ Linux a Docker Desktop bind mount from a macOS or Windows host, vfat, an ext4 `FETCH-PR.JS` or `Fetch-pr.js` is not the script `fetch-pr` even where the filesystem would open it under that name — the daemon verifies the stored spelling before running anything, so the administrator's listing, the -administrator's call and an agent-token call all agree. On Linux and the BSDs -(which have no single-entry call that reports how a name is spelled on disk) -an agent-token call is answered ONLY from an exact-name index of the -directory that matches its CURRENT state — built at daemon start, validated -by one stat of the directory per call, refreshed in the background when the -directory changes — so no call lists the directory, whatever name is asked -for, and the refusal body is unchanged. Every step of one call's own check — -the stat, the candidate probe, the open and the re-check after it — is bound -to a single directory descriptor retained for that call, never a fresh -resolution of the path per step, so a symlink or bind mount retargeted +administrator's call and an agent-token call all agree. Every platform — +Linux, the BSDs, macOS/darwin and Windows — answers an agent-token call +ONLY from an exact-name index of the directory that matches its CURRENT +state — built at daemon start, validated once per call, refreshed in the +background when the directory changes — so no call lists the directory, +whatever name is asked for; a differently-cased name and one that is not +stored at all cost exactly the same, and the refusal body is unchanged. +Every step of one call's own check — the stat, the candidate probe, the +open and the re-check after it — is bound to a single directory descriptor +(or, on Windows, handle) retained for that call, never a fresh resolution +of the path per step, so a symlink, bind mount or reparse point retargeted mid-call cannot make two of those steps disagree about which directory they are looking at. A call landing while that refresh is scheduled or in flight is refused exactly as one against a directory never @@ -666,12 +667,14 @@ that a write could not still be landing on the same coarse tick): a script you have just added or renamed is callable by agent tokens only after the index has both refreshed AND settled — retry a call refused in that window, up to about two seconds — while administrators see the change at -once; mcpproxy never creates the directory itself, `mkdir -p` it. On darwin -and Windows the pre-open check is only a cheap gate — the authoritative -check re-reads the stored spelling of the actually-opened file descriptor -(`F_GETPATH` / `GetFinalPathNameByHandle`) and refuses on any mismatch, so a -rename racing the open itself is caught there too, not just by the earlier -probe. +once; mcpproxy never creates the directory itself, `mkdir -p` it. macOS +adds one extra, belt-and-suspenders check on top: after the open, it +re-reads the opened descriptor's own stored spelling (`F_GETPATH`) and +refuses on any mismatch. Windows performs the probe, the open and the +background listing all relative to the SAME retained directory handle +(`NtCreateFile`), so the post-open check only needs to confirm the opened +handle's own name (`GetFinalPathNameByHandle`) rather than re-walking a +path that a retargeted reparse point could have redirected. --- diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index fca72cf28..fb7056964 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -380,14 +380,15 @@ content are published to every caller by design and sit outside the invariant: directory becomes callable by agent tokens only after the index has both refreshed and settled — retry a call refused in that window, up to roughly two seconds — while administrators see the change immediately. - On darwin and Windows, where a single-entry platform call reports a - path's stored spelling directly, that pre-open probe is only a cheap - gate: the authoritative check re-reads the stored spelling of the file - descriptor MCPProxy actually opened and refuses on any mismatch, so a - rename racing the open itself is caught there too — on Windows that open - never follows a reparse point, and the check compares the descriptor's - full path, not just its base name, against a directory handle opened - once for that same call); an ambiguous or unusable script is + Linux, the BSDs, darwin and Windows all answer from this same index, so + a differently-cased name and one that is not stored at all cost the + same — both are plain misses. darwin re-checks the opened descriptor's + on-disk spelling as an extra, belt-and-suspenders proof; Windows performs + every step of a call — probing, opening, and the background listing that + refreshes the index — relative to ONE directory handle retained for the + whole call, so a rename or a reparse point cannot redirect where a + "relative" open lands, and the post-open check need only confirm the + opened descriptor's own name; an ambiguous or unusable script is reported by name and reason only, without its host path or a raw OS error; the REST listing `GET /api/v1/code/scripts` answers an agent token with diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index f84aa6c70..e3fe7faba 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -76,13 +76,15 @@ const ( var errNonRegular = errors.New("not a regular file") // scopedOpener opens the winning candidate for reading. nil means "use the -// package's own openScriptFile", the administrator's path-based no-follow -// open and darwin/Windows's default (round 11: their own fixes reach -// authoritatively into the post-open recheck instead — see scopedVerifier). -// Non-nil only on Linux/BSD (round 11 MUST-FIX), where it is bound to the -// single retained directory descriptor the request's own candidates() call -// opened, so the exact entry that was probed is the exact entry that gets -// opened — never a fresh, independent resolution of the path. +// package's own openScriptFile" — the administrator's path-based no-follow +// open (round 13: also Windows's own storedspellings_probe_windows.go, +// whose own reparse-hardened openScriptFile in open_windows.go is already +// authoritative — see scopedVerifier). Non-nil on every unix platform +// (round 11 MUST-FIX for Linux/BSD, round 13 for darwin joining the same +// design), where it is bound to the single retained directory descriptor +// the request's own candidates() call opened, so the exact entry that was +// probed is the exact entry that gets opened — never a fresh, independent +// resolution of the path. type scopedOpener func(path string) (*os.File, error) // scopedVerifier re-proves, on the descriptor openScriptFile or a @@ -93,10 +95,11 @@ type scopedVerifier func(f *os.File, want string) error // scopedCloser releases whatever per-request resource a candidates() // implementation opened (round 11 MUST-FIX: the retained directory -// descriptor on Linux/BSD; a directory handle on Windows) — nil when there -// is nothing to release (the administrator; darwin). resolve defers it -// immediately after calling candidates(), so it always runs exactly once, -// whether or not a candidate was ultimately opened. +// descriptor on every unix platform, round 13 darwin included; a directory +// handle on Windows) — nil when there is nothing to release (the +// administrator alone). resolve defers it immediately after calling +// candidates(), so it always runs exactly once, whether or not a candidate +// was ultimately opened. type scopedCloser func() // errIndexGenerationChanged is what a post-open verifyUnchanged closure @@ -104,15 +107,21 @@ type scopedCloser func() // lookup that produced a hit and this open (round 8 MUST-FIX, the // lookup→open race): resolve treats it as an ordinary not-found, never as an // unreadable-directory error, so it discloses nothing beyond the caller's -// own requested name. +// own requested name. Every unix platform's index (Linux/BSD since round 8, +// darwin since round 13) can return this; Windows has no directory +// generation to recheck and never returns it. var errIndexGenerationChanged = errors.New("codescripts: scripts directory changed between the index lookup and the open") -// errSpellingUnproven is what the darwin/Windows verifyUnchanged closure -// returns when the OPENED descriptor's stored spelling (round 9 MUST-FIX) -// could not be proven to match the requested name — a mismatch (a +// errSpellingUnproven is what a spelling proof beyond the generation +// recheck returns when the OPENED descriptor's stored spelling (round 9 +// MUST-FIX) could not be proven to match the requested name — a mismatch (a // case-rename or replacement landed between the pre-open probe and the // open) or a failure of the proof call itself; resolve treats either the -// same as errIndexGenerationChanged, as an ordinary not-found. +// same as errIndexGenerationChanged, as an ordinary not-found. Returned by +// darwin's F_GETPATH belt-and-suspenders check (extraVerifyOpened, +// entryname_darwin.go, round 13) on top of its own generation recheck, and +// by Windows's full-path proof (storedspellings_probe_windows.go), which has +// no generation to recheck at all. var errSpellingUnproven = errors.New("codescripts: the opened file's stored spelling could not be proven to match the requested name") // Entry is one listed script (FR-007). Paths holds the single source file, or @@ -439,13 +448,15 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ // tell the difference, because it does not compare names, only symlink // status. verifyUnchanged proves this AUTHORITATIVELY on f, the // descriptor that will actually be read (round 9 MUST-FIX, the - // PROVEN-AT-OPEN rule): on Linux/BSD by re-reading the directory's + // PROVEN-AT-OPEN rule): on every unix platform (Linux/BSD since round 8, + // darwin since round 13) by re-reading the SAME retained descriptor's // generation once more (gen-before == index.gen == gen-after proves the - // opened entry is the one the index vouched for); on darwin/Windows by - // reading the opened descriptor's own stored spelling (F_GETPATH / - // GetFinalPathNameByHandle) and comparing it byte-for-byte to the name - // that was requested. Either failure closes the descriptor (via the - // defer above) and refuses rather than trusting it. Nil for the + // opened entry is the one the index vouched for) plus, on darwin, an + // additional F_GETPATH basename check; on Windows by reading the opened + // descriptor's own stored spelling (GetFinalPathNameByHandle) and + // comparing it to the retained directory handle's own final path plus + // the name that was requested. Either failure closes the descriptor (via + // the defer above) and refuses rather than trusting it. Nil for the // administrator, whose candidatesFor has nothing to recheck against. if verifyUnchanged != nil { if verifyErr := verifyUnchanged(f, filepath.Base(path)); verifyErr != nil { @@ -540,36 +551,42 @@ func candidatesFor(scriptsDir, name string) ([]string, scopedOpener, scopedVerif // filesystem's own name→file decision is case-insensitive on the default // macOS and Windows volumes and on a Linux case-folding mount (see // candidatesFor): a `backdoor.JS` that a probe for `backdoor.js` would open -// is not a stored script, exactly as List decides. On darwin and Windows a -// single-entry platform call reports the stored spelling of a probed path; on -// Linux and the BSDs, which have no such call, the answer comes from a -// per-directory index of exact names that is listed once per directory -// change, never per request (storednames_other.go, codex r5 #1). The -// no-follow open remains the authoritative check. +// is not a stored script, exactly as List decides. On every unix platform +// (Linux, the BSDs, and — round 13, closing the round-10 finding-1/finding-3 +// pair — darwin too) the answer comes from a per-directory index of exact +// names that is listed once per directory change, never per request +// (storednames_other.go, codex r5 #1): an absent name and a differently +// cased one are both plain index misses, identical cost. Windows alone still +// answers from a single-entry platform call per probed path +// (storedspellings_probe_windows.go). The no-follow open remains the +// authoritative check. // // The verifier this returns is a post-open AUTHORITATIVE recheck (round 8 / // round 9 MUST-FIX, the lookup→open race): storedSpellingsOf's own verify // closure, run by resolve on the descriptor that was actually opened — on -// Linux/BSD a directory-generation recheck on the SAME retained descriptor -// the whole request used (storednames_other.go, round 11 MUST-FIX — see the -// opener below), on darwin a proof of the opened descriptor's own stored -// spelling (storedspellings_probe.go), on Windows the same proof plus a -// full-path comparison against a directory handle opened once for the -// request (storedspellings_probe_windows.go, round 11 MUST-FIX). Never nil -// on any platform: this is what makes the pre-open probe above merely a -// cheap gate rather than the authoritative decision. +// every unix platform a directory-generation recheck on the SAME retained +// descriptor the whole request used (storednames_other.go, round 11 +// MUST-FIX — see the opener below), with darwin adding its own F_GETPATH +// proof of the opened descriptor's stored spelling on top +// (entryname_darwin.go, round 13); on Windows a full-path comparison against +// a directory handle opened once for the request +// (storedspellings_probe_windows.go, round 11 MUST-FIX). Never nil on any +// platform: this is what makes the pre-open probe above merely a cheap gate +// rather than the authoritative decision. // -// The opener this returns is non-nil ONLY on Linux/BSD (round 11 MUST-FIX): -// it opens the winning candidate relative to the SAME retained directory -// descriptor the generation check and the candidate probe both used, -// instead of a fresh, independent resolution of the path — the fix for the -// directory-path ABA hole (storednames_other.go's package doc comment has -// the full account). darwin and Windows return nil here (their own fixes -// reach authoritatively into the verifier instead), so resolve falls back -// to the package's ordinary openScriptFile. The closer releases whatever -// per-request resource the opener needs (the retained descriptor on -// Linux/BSD, a directory handle on Windows) exactly once, whether or not a -// candidate was ultimately opened. +// The opener this returns is non-nil on every unix platform (round 11 +// MUST-FIX for Linux/BSD, round 13 for darwin): it opens the winning +// candidate relative to the SAME retained directory descriptor the +// generation check and the candidate probe both used, instead of a fresh, +// independent resolution of the path — the fix for the directory-path ABA +// hole (storednames_other.go's package doc comment has the full account). +// Windows returns nil here (its own fix reaches authoritatively into the +// verifier instead, and openScriptFile in open_windows.go is already the +// reparse-hardened no-follow open), so resolve falls back to the package's +// ordinary openScriptFile. The closer releases whatever per-request +// resource the opener needs (the retained descriptor on unix, a directory +// handle on Windows) exactly once, whether or not a candidate was +// ultimately opened. func probeCandidates(scriptsDir, name string) ([]string, scopedOpener, scopedVerifier, scopedCloser, error) { storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(scriptsDir) if err != nil { diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index 885084e3e..28cf2519e 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -937,16 +937,16 @@ func TestResolveScoped_MissCostIsIndependentOfDirectorySize(t *testing.T) { assert.Equal(t, 0, emptyReadDirs) assert.Equal(t, 0, crowdedReadDirs, "ten thousand entries must not be enumerated on a scoped caller's behalf") assert.Equal(t, emptyLstats, crowdedLstats, "the number of path probes is independent of the directory's contents") - // On darwin/Windows the candidate probe itself is a path-based Lstat, so - // it shows up here directly. On Linux/BSD (round 11 MUST-FIX) the probe - // runs through a retained directory descriptor instead (fstatatEntry, - // dirfd_other.go) and never touches this package's lstat var — a MISS - // like "gamma" here never even reaches that probe (its name is not a key - // of the index), so there is nothing to assert here beyond the - // equal-cost check above; storednames_other_test.go pins the Linux/BSD - // primitive counts, including the non-zero fstatat a HIT performs, on - // its own terms. - if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { - assert.Greater(t, crowdedLstats, 0, "the candidate paths are probed directly") - } + // Round 13 (round-10 finding 3): every platform now answers a scoped + // candidate probe from a per-directory exact-spelling INDEX — Linux/BSD + // and darwin through a retained directory descriptor (fstatatEntry, + // dirfd_other.go), Windows through a retained directory handle + // (winProbeEntry, storednames_windows.go) — never through this + // package's shared lstat var, which the administrator's candidatesFor + // alone still uses. A MISS like "gamma" here never even reaches the + // per-platform probe (its name is not a key of the index), so both + // counts are 0 on every platform; storednames_other_test.go (unix) and + // storedspellings_probe_test.go (Windows) each pin their own non-zero + // HIT primitive counts on their own terms. + assert.Equal(t, 0, crowdedLstats, "the scoped candidate probe never touches the package's shared lstat var on any platform") } diff --git a/internal/codescripts/dirfd_other.go b/internal/codescripts/dirfd_other.go index b151d544c..54b6f98f5 100644 --- a/internal/codescripts/dirfd_other.go +++ b/internal/codescripts/dirfd_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !windows +//go:build unix package codescripts @@ -48,6 +48,32 @@ import ( // the listing and the generation the index records for it come from the // identical open — never a second resolution of the path either. // +// Round 13 (unify darwin onto this design; round-10 finding 1, and finding 3 +// — the case-variant timing oracle — for free): darwin now builds this build +// tag list too (`unix`, below) rather than opening a fresh path-based +// probe per request the way storedspellings_probe.go used to. x/sys/unix's +// Stat_t already spells the ctime field Mtim/Ctim uniformly across every +// platform `unix` covers — including darwin, unlike the standard library's +// syscall.Stat_t, which spells it Mtimespec/Ctimespec there — so +// defaultFstatDirGeneration below needs no darwin-specific variant. Darwin's +// own F_GETPATH stays in service as an ADDITIONAL, belt-and-suspenders proof +// on the opened descriptor (entryname_darwin.go, wired onto +// storednames_other.go's extraVerifyOpened hook) — openat's identity +// binding already proves the parent, so only the basename is worth +// re-checking. +// +// Round 13 SHOULD (finding 6 — plan9/js/wasip1 do not build): the `unix` +// build constraint (recognized by cmd/go for every real Unix GOOS; see +// https://pkg.go.dev/go/build#hdr-Build_Constraints) replaces the former +// `!darwin && !windows`, which also matched plan9, js/wasip1 and any future +// non-Unix GOOS — none of which have an x/sys/unix package to import. The +// package still builds for those targets: fallback_other.go +// (`!unix && !windows`) supplies a ResolveScoped that fails closed +// (non-disclosing not-found, matching this file's own fail-closed answer to +// an unreadable directory) and a no-op Warm, so a plan9 or js/wasm build of +// the module compiles without ever being able to serve a scoped stored +// script on those targets. +// // Every primitive below is a variable so the package's tests can install a // real symlink retarget between two of a request's own calls (the actual // window the fix closes is between separate Go statements the caller makes, diff --git a/internal/codescripts/dirgeneration_ctimespec.go b/internal/codescripts/dirgeneration_ctimespec.go index 5095a1cfd..d6f03ad10 100644 --- a/internal/codescripts/dirgeneration_ctimespec.go +++ b/internal/codescripts/dirgeneration_ctimespec.go @@ -1,4 +1,4 @@ -//go:build freebsd || netbsd +//go:build freebsd || netbsd || darwin package codescripts @@ -10,10 +10,14 @@ import ( // dirGenerationOf reads a directory's generation stamp from its Lstat result. // The inode, device and ctime come from the platform stat structure, whose -// ctime field is spelled Ctimespec here. The device is part of the stamp -// (round 9 MUST-FIX): an inode number is unique only within its device, so -// without it a bind-mount swap to another filesystem could collide on inode, -// size and both timestamps. +// ctime field is spelled Ctimespec here (freebsd, netbsd, and — round 13, +// darwin's join of the shared Linux/BSD design — darwin too; the standard +// library's syscall.Stat_t spells it Ctimespec on all three, unlike +// x/sys/unix.Stat_t, which dirfd_other.go's fd-based reader uses instead and +// which normalizes the field to Ctim uniformly, darwin included). The device +// is part of the stamp (round 9 MUST-FIX): an inode number is unique only +// within its device, so without it a bind-mount swap to another filesystem +// could collide on inode, size and both timestamps. func dirGenerationOf(info fs.FileInfo) dirGeneration { gen := dirGeneration{modTime: info.ModTime(), size: info.Size()} if st, ok := info.Sys().(*syscall.Stat_t); ok { diff --git a/internal/codescripts/entryname_darwin.go b/internal/codescripts/entryname_darwin.go index 7ce5eca92..bd884220a 100644 --- a/internal/codescripts/entryname_darwin.go +++ b/internal/codescripts/entryname_darwin.go @@ -10,40 +10,45 @@ import ( "unsafe" ) -// entryName returns the name the filesystem actually stores for the directory -// entry at path, without following a symlink and without listing the -// directory. The default APFS/HFS+ volumes are case-insensitive but -// case-PRESERVING: a probe for `backdoor.js` opens `backdoor.JS`, and the -// on-disk spelling is what F_GETPATH on the descriptor reports. +// Round 13 MUST-FIX (unify darwin onto the fd-bound Linux/BSD design): +// darwin now answers a scoped request from the same directory-generation +// index and the same retained-descriptor primitives every other Unix +// platform uses (dirfd_other.go, storednames_other.go — this file's build +// tag joined `unix` this round). x/sys/unix.Stat_t already normalizes the +// ctime field name across darwin and the BSDs (Mtim/Ctim, not the standard +// library syscall.Stat_t's Mtimespec/Ctimespec — see dirfd_other.go's own +// comment), so no darwin-specific generation reader is needed. // -// O_SYMLINK opens a symlink itself rather than its target (the no-follow -// counterpart to Lstat), so a link's own entry name is the one verified; -// O_NONBLOCK keeps a FIFO from parking the open, as in openScriptFile. -func entryName(path string) (string, error) { - f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_SYMLINK|syscall.O_NONBLOCK, 0) - if err != nil { - return "", err +// What darwin keeps that Linux/BSD do not is F_GETPATH: a single-entry +// platform call that reports the exact on-disk spelling of an already-open +// descriptor. openat's own identity binding (dirfd_other.go) already proves +// the opened entry is a child of the retained directory descriptor — a +// retargeted symlink or ancestor cannot make it otherwise — so this file +// wires F_GETPATH in as an ADDITIONAL, belt-and-suspenders spelling proof +// registered on extraVerifyOpened (storednames_other.go): after the shared +// generation recheck passes, compare the opened descriptor's own reported +// basename (the parent is already bound by openat, so only the basename is +// worth comparing) to the name that was requested. +func init() { + extraVerifyOpened = func(f *os.File, want string) error { + stored, err := openedEntryName(f) + if err != nil || stored != want { + return errSpellingUnproven + } + return nil } - defer f.Close() - return entryNameFromFd(f.Fd()) } -// openedEntryName is entryName's post-open counterpart (round 9 MUST-FIX): -// it proves the stored spelling of the descriptor that will actually be -// EXECUTED, not of a separate pre-open probe of the same path — a -// case-rename or replacement landing between the pre-open probe -// (storedSpellingsOf) and openScriptFile's own open would otherwise let the -// wrong spelling run, because a case-folding volume folds the subsequent -// open onto whatever now occupies the name. F_GETPATH on the EXECUTED file's -// own descriptor is the same call entryName makes on a descriptor it opened -// itself for the pre-open probe; here it runs on the descriptor -// openScriptFile is about to read from. +// openedEntryName reports the on-disk spelling of the directory entry an +// already-open descriptor was opened from (F_GETPATH), truncated to its base +// name — the proof this file registers on extraVerifyOpened, run on the +// descriptor that will actually be EXECUTED (openatEntry's result), not a +// separate pre-open probe of the same path. func openedEntryName(f *os.File) (string, error) { return entryNameFromFd(f.Fd()) } -// entryNameFromFd is the shared F_GETPATH call both entryName and -// openedEntryName resolve to a base name. +// entryNameFromFd is the shared F_GETPATH call. func entryNameFromFd(fd uintptr) (string, error) { var buf [1024]byte // MAXPATHLEN _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETPATH, uintptr(unsafe.Pointer(&buf[0]))) diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go index 3d0533f93..c0c2caafd 100644 --- a/internal/codescripts/entryname_windows.go +++ b/internal/codescripts/entryname_windows.go @@ -19,35 +19,24 @@ const ( winVolumeNameDOS = 0x0 ) -// entryName returns the name the filesystem actually stores for the directory -// entry at path, without following a reparse point and without listing the -// directory. NTFS is case-insensitive but case-PRESERVING: a probe for -// `backdoor.js` finds `backdoor.JS`, and FindFirstFile on the exact path is -// the single-entry lookup that reports the stored spelling (the same call the -// standard library's filepath.EvalSymlinks uses to normalise case). -func entryName(path string) (string, error) { - p, err := windows.UTF16PtrFromString(path) - if err != nil { - return "", err - } - var data windows.Win32finddata - h, err := windows.FindFirstFile(p, &data) - if err != nil { - return "", err - } - _ = windows.FindClose(h) - return windows.UTF16ToString(data.FileName[:]), nil -} - -// openedEntryName is entryName's post-open counterpart (round 9 MUST-FIX): -// it proves the stored spelling of the descriptor that will actually be -// EXECUTED, not of a separate pre-open probe of the same path. Superseded as -// the AUTHORITATIVE proof by openedFinalPath (round 11 MUST-FIX: a basename -// alone is satisfied by any identically named file reached through a -// retargeted reparse point — see storedspellings_probe_windows.go), but kept -// for entryNameFromFd's shared plumbing and any caller that only needs the -// base name. -func openedEntryName(f *os.File) (string, error) { +// Round 13 MUST-FIX (round-10 findings 2 and 3 — unify Windows onto the +// index + retained-directory-handle design storednames_windows.go now +// builds): the path-based single-entry lookups this file used to hold +// (entryName/FindFirstFile, dirFinalPath, openedFinalPath, a full-path +// baseline comparison) are gone. storedExactly now answers from the same +// per-directory exact-spelling INDEX every unix platform uses (an absent +// name and a present case-variant are both plain index misses — closing +// finding 3's timing oracle for Windows too), and both the candidate probe +// and the actual open are performed RELATIVE TO ONE RETAINED DIRECTORY +// HANDLE via NtCreateFile with RootDirectory set (storednames_windows.go) — +// a rename of the directory, or a reparse point planted on an ancestor, +// cannot redirect a relative open the way it could a fresh path lookup +// (finding 2). Because the open is already structurally bound to the +// retained handle, the post-open proof needs only the opened descriptor's +// own BASENAME (winOpenedBaseName, storednames_windows.go) — the parent is +// no longer in question — so this file keeps just finalPathOfHandle, the +// shared GetFinalPathNameByHandle call that proof uses. +func openedBaseName(f *os.File) (string, error) { full, err := finalPathOfHandle(windows.Handle(f.Fd())) if err != nil { return "", err @@ -55,48 +44,6 @@ func openedEntryName(f *os.File) (string, error) { return filepath.Base(full), nil } -// openedFinalPath is openedEntryName's FULL-PATH counterpart (round 11 -// MUST-FIX, the reparse-point escape): the basename that openedEntryName -// reports is satisfied by any identically named file reachable through a -// reparse point planted between the pre-open probe and the open, so the -// authoritative proof must compare the descriptor's complete normalized -// path — parent directory included — against the scripts directory's own -// final path (dirFinalPath) plus the exact basename, not the basename -// alone. -func openedFinalPath(f *os.File) (string, error) { - return finalPathOfHandle(windows.Handle(f.Fd())) -} - -// dirFinalPath opens scriptsDir once — FILE_FLAG_BACKUP_SEMANTICS is -// required to obtain a handle on a directory at all — and returns its own -// normalized final path together with a func that releases the handle. This -// is the baseline openedFinalPath is compared against (round 11 MUST-FIX): -// confirming a candidate's PARENT is this exact directory, not merely that -// its basename matches, is what a retargeted reparse point on an ancestor -// cannot spoof. -func dirFinalPath(scriptsDir string) (path string, closeHandle func(), err error) { - p, err := windows.UTF16PtrFromString(scriptsDir) - if err != nil { - return "", nil, err - } - h, err := windows.CreateFile(p, - windows.GENERIC_READ, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, - nil, - windows.OPEN_EXISTING, - windows.FILE_FLAG_BACKUP_SEMANTICS, - 0) - if err != nil { - return "", nil, err - } - fp, err := finalPathOfHandle(h) - if err != nil { - _ = windows.CloseHandle(h) - return "", nil, err - } - return fp, func() { _ = windows.CloseHandle(h) }, nil -} - // finalPathOfHandle is the shared GetFinalPathNameByHandle call: the // normalized path NTFS actually resolved a handle to, unlike the path that // was requested, which merely echoes what was asked for. diff --git a/internal/codescripts/fallback_other.go b/internal/codescripts/fallback_other.go new file mode 100644 index 000000000..7bc21f992 --- /dev/null +++ b/internal/codescripts/fallback_other.go @@ -0,0 +1,71 @@ +//go:build !unix && !windows + +package codescripts + +import ( + "errors" + "io/fs" + "os" +) + +// Round 13 SHOULD (finding 6): a build target that is neither `unix` +// (dirfd_other.go, storednames_other.go — the fd-bound Linux/BSD/darwin +// design) nor `windows` (open_windows.go, storedspellings_probe_windows.go) +// — plan9, js/wasm, wasip1, or any future GOOS this package has not been +// taught a directory-descriptor primitive for — has no platform primitive +// this package can trust to answer a scoped request, or even to open the +// administrator's own no-follow read (open_unix.go needs +// syscall.O_NOFOLLOW/ELOOP/EMLINK, none of which exist on plan9 or +// js/wasm). The concrete failure this closes: `GOOS=plan9 go build +// ./internal/codescripts` and `GOOS=js GOARCH=wasm go build +// ./internal/codescripts` failed outright before this file existed, because +// every one of storedSpellingsOf/Warm/SetIndexClockForTest/openScriptFile +// was defined only under tags that (before round 13) or now (after +// narrowing dirfd_other.go, storednames_other.go and open_unix.go to their +// correct, narrower tags) exclude these targets entirely. +// +// Rather than leave the package unable to compile there, it compiles and +// fails CLOSED and undisclosed: Warm is a no-op (there is no index to +// build, so nothing needs warming); storedSpellingsOf and openScriptFile +// both report the stored script as not found — wrapped so +// errors.Is(err, fs.ErrNotExist) is true — which resolve (codescripts.go) +// turns into the caller's ordinary NotFoundError, non-disclosing for a +// scoped caller exactly as SC-005 requires and, for an administrator, +// the same not-found form a genuinely empty or unreadable directory +// produces on every other platform. Neither function silently succeeds, +// silently discloses anything about what scriptsDir might hold, or panics; +// the package is simply unable to serve a stored script on a target with +// no directory-descriptor primitive of its own. +var errUnsupportedPlatform = errors.New("codescripts: stored scripts are not supported on this platform") + +// Warm is a no-op: there is no index to build on a platform with no +// directory-descriptor primitive. +func Warm(string) error { return nil } + +// SetIndexClockForTest itself is shared, no-build-tag code (indexclock.go): +// there is no settle window to fake on a platform with no index at all, but +// production never calls it here either way, so the shared (real) clock +// override is harmless to inherit rather than needing its own no-op. + +// storedSpellingsOf always reports the requested name as not found — +// fail-closed, never fail-open — since this platform has no primitive this +// package trusts to answer whether scriptsDir holds an entry at all. +func storedSpellingsOf(string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { + return nil, nil, nil, nil, errUnsupportedPlatformNotFound() +} + +// openScriptFile always fails: there is no platform no-follow primitive +// here for the administrator's own read to rely on, so the safe answer is +// "not found" rather than an open that cannot promise it refused a symlink. +func openScriptFile(string) (*os.File, error) { + return nil, errUnsupportedPlatformNotFound() +} + +// errUnsupportedPlatformNotFound wraps errUnsupportedPlatform so +// errors.Is(err, fs.ErrNotExist) is true — resolve (codescripts.go) treats +// any fs.ErrNotExist from a candidates()/open call as an ordinary not-found, +// never as ReasonUnreadable, which is what keeps this fail-closed answer +// non-disclosing for a scoped caller. +func errUnsupportedPlatformNotFound() error { + return &fs.PathError{Op: "open", Path: "", Err: errors.Join(errUnsupportedPlatform, fs.ErrNotExist)} +} diff --git a/internal/codescripts/indexclock.go b/internal/codescripts/indexclock.go new file mode 100644 index 000000000..bfcd62298 --- /dev/null +++ b/internal/codescripts/indexclock.go @@ -0,0 +1,69 @@ +package codescripts + +import "time" + +// generationSettleTime is how far a directory's stamp must predate a listing +// for its index to be trusted until the stamp moves. Timestamps can be +// coarse (vfat: two seconds; round 13: also a FAT-formatted volume on +// Windows, whose directory write time carries the same two-second +// resolution — NTFS itself is fine-grained, but a scripts directory is not +// guaranteed to live on it), so a write landing in the same tick as the +// recorded stamp would leave it unchanged; until the stamp is older than +// the coarsest tick, requests keep scheduling a refresh — at most one per +// window, and off the request path. The bound depends on the clock alone, +// never on the requested name. +// +// No build tag: shared by both platform index implementations — unix +// (storednames_other.go) and Windows (storedspellings_probe_windows.go) — +// which never build together, so one definition and one clock serve +// whichever is active rather than each keeping its own copy that could +// drift out of step. +const generationSettleTime = 2 * time.Second + +// maxRebuildAttempts bounds how many times one rebuild re-lists when the +// directory's generation keeps moving out from under it (round 8 SHOULD): a +// directory that never stops changing must not keep this goroutine listing +// forever, nor block Warm forever. After the bound, whatever the last +// attempt installed stays as the index — the next request finds it stale +// against the directory's CURRENT generation and refuses fail-closed, rather +// than this loop trusting an unconfirmed listing or spinning on one that can +// never confirm. +const maxRebuildAttempts = 3 + +// rebuildBackoff is the minimum gap between the end of one ASYNC rebuild +// goroutine and the start of the next for the same directory (round 8 +// SHOULD). Without it, a directory changing on every request would let +// scheduling spawn a fresh rebuild the instant the bounded one above gives +// up. During the backoff a request's own cost is unchanged — answered +// fail-closed from whatever the index holds (or does not); only the new +// rebuild goroutine is withheld. +const rebuildBackoff = time.Second + +// maxStoredNameIndexes bounds a platform's index map for bare (never-Warmed) +// use — the server calls Warm whenever the active scripts directory changes, +// and Warm keeps only that one directory's index (pruneOtherIndexesLocked), +// so this cap matters only for the directories a scoped request alone +// touches without ever being Warmed for. Shared by both platform index +// implementations for the same reason as everything else in this file. +const maxStoredNameIndexes = 4 + +// indexClock is time.Now, a variable so the tests can settle an index +// without waiting. +var indexClock = time.Now + +// SetIndexClockForTest overrides the clock the settle check reads (round 9 +// MUST-FIX) and returns a func that restores it. A directory's on-disk +// change stamp cannot be forged from user space — it is exactly what makes +// the settle window a real guarantee — so a caller outside this package +// that needs a freshly written scripts directory treated as settled at once +// (an internal/server fixture, say) has no way to fake it by backdating a +// file; it must move the clock the settle check reads instead, as this +// package's own tests do internally. Test-only: production code never +// calls this, and callers outside this package must restore it (defer the +// returned func, or t.Cleanup) before any other test observes the +// override. +func SetIndexClockForTest(now func() time.Time) (restore func()) { + prev := indexClock + indexClock = now + return func() { indexClock = prev } +} diff --git a/internal/codescripts/open_unix.go b/internal/codescripts/open_unix.go index 335510f92..e68a0d94c 100644 --- a/internal/codescripts/open_unix.go +++ b/internal/codescripts/open_unix.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build unix package codescripts @@ -8,6 +8,12 @@ import ( "syscall" ) +// Round 13 SHOULD (finding 6): this file's build tag narrowed from +// `!windows` to `unix` — the constants it needs (syscall.O_NOFOLLOW, +// ELOOP, EMLINK) do not exist on plan9 or js/wasm, so `!windows` alone +// still failed to build there; fallback_other.go supplies a stub for +// every non-unix, non-Windows target instead. +// // openScriptFile opens a stored script for reading, rejecting a symlink at the // final path component ATOMICALLY: O_NOFOLLOW makes the kernel refuse the open // (ELOOP) instead of resolving the link, so there is no check-then-open window diff --git a/internal/codescripts/open_windows.go b/internal/codescripts/open_windows.go index 17be76191..4f4022f40 100644 --- a/internal/codescripts/open_windows.go +++ b/internal/codescripts/open_windows.go @@ -24,6 +24,20 @@ import ( // is, this is the handle it opens, atomically. GetFileInformationByHandle on // that handle then refuses a reparse point or a directory outright, exactly // as O_NOFOLLOW plus the regular-file Fstat check does on Unix. +// +// Round 13 MUST-FIX (round-10 finding 4): the share mode widened from +// FILE_SHARE_READ alone to FILE_SHARE_READ|WRITE|DELETE — the same sharing +// os.Open itself requests (syscall.Open on windows: FILE_SHARE_READ| +// FILE_SHARE_WRITE) plus DELETE, so this read cannot itself block a +// concurrent atomic replace (rename-over) of the very file it is reading. +// Concrete failure this closes: an editor (or an atomic-write deploy of a +// new script version) holds the file open with delete sharing enabled — +// origin/main's os.Open could still read it; the round-11 CreateFile with +// FILE_SHARE_READ alone returned a sharing violation instead, a behavior +// change from the pre-Spec-105 administrator path that SC-005 does not +// call for, and this open in turn withheld FILE_SHARE_DELETE from ITS OWN +// handle, which would have blocked that same atomic replace for as long as +// this read holds the file open. func openScriptFile(path string) (*os.File, error) { p, err := windows.UTF16PtrFromString(path) if err != nil { @@ -31,7 +45,7 @@ func openScriptFile(path string) (*os.File, error) { } h, err := windows.CreateFile(p, windows.GENERIC_READ, - windows.FILE_SHARE_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, diff --git a/internal/codescripts/rebuildsemaphore.go b/internal/codescripts/rebuildsemaphore.go new file mode 100644 index 000000000..261cd3bf5 --- /dev/null +++ b/internal/codescripts/rebuildsemaphore.go @@ -0,0 +1,41 @@ +package codescripts + +// spawnIndexRebuild runs one ASYNC index rebuild on its own goroutine, +// shared by both platform index implementations (storednames_other.go, +// storednames_windows.go — they never build together). A variable so the +// tests can hold a rebuild back and prove what a request does on its own +// goroutine, then land it deliberately. +var spawnIndexRebuild = func(rebuild func()) { go rebuild() } + +// maxConcurrentRebuilds bounds how many ASYNC index-rebuild goroutines may +// run at once, PROCESS-WIDE across every directory's index and both +// platform index implementations (round 13 SHOULD, finding 5) — the unix +// one (storednames_other.go) and the Windows one (storedspellings_probe_windows.go), +// both of which share this single semaphore rather than each keeping its +// own bound. Cancellation (round 11 SHOULD, idx.ctx) stops an evicted +// index's rebuild only at its next checkpoint — between listing attempts, +// or right before installing — never mid-listing, which is uninterruptible; +// a directory backed by a slow or stalled filesystem can therefore leave a +// rebuild goroutine (and its retained directory handle/descriptor) running +// for as long as that one blocking listing takes, however many DIFFERENT +// directories keep triggering new ones in the meantime. rebuildSlots below +// is what keeps that count bounded rather than merely eventually-cancelled. +// +// No build tag: this file compiles identically on every platform so the +// unix and Windows index implementations — which never build together — +// can each import the identical semaphore and bound without duplicating its +// definition (or its capacity) in two places that could drift apart. +const maxConcurrentRebuilds = 2 + +// rebuildSlots is the process-wide semaphore a scheduleRebuildLocked +// implementation acquires before spawning an ASYNC rebuild and releases +// when that rebuild returns — win, lose, or cancelled. A directory whose +// rebuild cannot acquire a slot is not queued or blocked on one becoming +// free: scheduling simply does not spawn it this time, leaving the index +// exactly as stale as it already was. Nothing is lost — the NEXT request +// against that directory still finds it stale and tries to schedule again, +// acquiring a slot afresh. A capacity of 2 lets one directory's rebuild +// proceed while another directory's request-triggered rebuild is scheduled +// too, without letting an unbounded number of evicted, still-listing +// goroutines accumulate. +var rebuildSlots = make(chan struct{}, maxConcurrentRebuilds) diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go index e2ea142c2..f3eba9965 100644 --- a/internal/codescripts/storednames_other.go +++ b/internal/codescripts/storednames_other.go @@ -1,4 +1,4 @@ -//go:build !darwin && !windows +//go:build unix package codescripts @@ -17,9 +17,9 @@ import ( // Linux and the BSDs resolve names case-sensitively on their native // filesystems, but a case-folding mount (vfat, an ext4 casefold directory, a // bind mount from a case-insensitive host) finds `backdoor.JS` for -// `backdoor.js` just as APFS and NTFS do — and unlike those it offers no -// single-entry call that reports how an entry is spelled on disk: F_GETPATH -// does not exist, and a readlink of /proc/self/fd/N echoes the spelling that +// `backdoor.js` just as the default APFS volume does — and unlike APFS +// (F_GETPATH) they offer no single-entry call that reports how an entry is +// spelled on disk: a readlink of /proc/self/fd/N echoes the spelling that // was looked up, not the one stored. The only exact answer is the directory // listing, and a listing paid on a scoped caller's request is what Spec 105 // FR-012 forbids: its cost grows with the directory, and paying it only when @@ -27,6 +27,21 @@ import ( // cost O(directory) while absence cost O(1) — a timing oracle on the stored // names. // +// Round 13 (round-10 finding 1 and finding 3): darwin answers from this same +// index rather than a per-request F_GETPATH probe. F_GETPATH is real and +// exact, so darwin's OWN probe never had a case-folding blind spot — but it +// answered a hit (Lstat succeeds, then entryName) and a miss (Lstat alone) +// with a DIFFERENT number of platform calls, which is exactly the timing +// oracle finding 3 named: a scoped caller could distinguish "no entry" from +// "a case-variant exists" by latency alone, whatever a non-disclosing +// refusal's contract (SC-005) requires. Answering darwin from the index too +// makes an absent name and a present case-variant both plain index MISSES — +// identical work, an O(1) map lookup, neither one reaching Fstatat — closing +// the oracle the same way it is already closed for Linux/BSD. See +// dirfd_other.go's own round-13 note for why no darwin-specific generation +// reader was needed to do this, and entryname_darwin.go for the +// belt-and-suspenders F_GETPATH proof darwin keeps on top of this index. +// // So the scoped resolver answers from a stored-name INDEX instead: the exact // spellings a scripts directory holds, maintained OFF the request path. The // index is built when the server learns its scripts directory (Warm) and @@ -141,9 +156,6 @@ var ( storedIndexesLRU []string // least-recently-used first; a touched key moves to the end ) -// maxStoredNameIndexes bounds storedIndexes for bare (never-Warmed) use. -const maxStoredNameIndexes = 4 - // storedNamesIndex returns the index of one cleaned scripts directory, // creating an empty (never built) one — with its own cancellation context — // on first use, and records the access for LRU eviction. @@ -278,58 +290,20 @@ func (g dirGeneration) latest() time.Time { return g.modTime } -// generationSettleTime is how far a directory's stamp must predate a listing -// for the index to be trusted until the stamp moves. Timestamps can be coarse -// (vfat: two seconds), so a write landing in the same tick as the recorded -// stamp would leave it unchanged; until the stamp is older than the coarsest -// tick, requests keep scheduling a refresh — at most one per window, and off -// the request path. The bound depends on the clock alone, never on the -// requested name. -const generationSettleTime = 2 * time.Second - -// maxRebuildAttempts bounds how many times one rebuild re-lists when the -// directory's generation keeps moving out from under it (round 8 SHOULD): a -// directory that never stops changing must not keep this goroutine listing -// forever, nor block Warm forever. After the bound, whatever the last -// attempt installed stays as the index — the next request finds it stale -// against the directory's CURRENT generation and refuses fail-closed (the -// MUST-FIX rule above), rather than this loop trusting an unconfirmed -// listing or spinning on one that can never confirm. -const maxRebuildAttempts = 3 - -// rebuildBackoff is the minimum gap between the end of one ASYNC rebuild -// goroutine and the start of the next for the same directory (round 8 -// SHOULD). Without it, a directory changing on every request would let -// scheduleRebuildLocked spawn a fresh rebuild the instant the bounded one -// above gives up. During the backoff a request's own cost is unchanged: one -// open and one fstat, answered fail-closed from whatever the index holds (or -// does not). -const rebuildBackoff = time.Second - -// indexClock is time.Now, a variable so the tests can settle an index -// without waiting. -var indexClock = time.Now - -// SetIndexClockForTest overrides the clock the settle check reads (round 9 -// MUST-FIX) and returns a func that restores it. A directory's ctime cannot -// be forged from user space — it is exactly what makes the settle window a -// real guarantee — so a caller outside this package that needs a freshly -// written scripts directory treated as settled at once (an internal/server -// fixture, say) has no way to fake it by backdating a file; it must move the -// clock the settle check reads instead, as this package's own tests do -// internally. Test-only: production code never calls this, and callers -// outside this package must restore it (defer the returned func, or -// t.Cleanup) before any other test observes the override. -func SetIndexClockForTest(now func() time.Time) (restore func()) { - prev := indexClock - indexClock = now - return func() { indexClock = prev } -} - -// spawnIndexRebuild runs one ASYNC index rebuild on its own goroutine. A -// variable so the tests can hold a rebuild back and prove what a request -// does on its own goroutine, then land it deliberately. -var spawnIndexRebuild = func(rebuild func()) { go rebuild() } +// generationSettleTime, maxRebuildAttempts, rebuildBackoff, indexClock and +// SetIndexClockForTest now live in indexclock.go (no build tag): round 13 +// gave Windows a real settle-window index too, sharing the identical clock +// and constants rather than each platform keeping its own copy. + +// extraVerifyOpened is an additional, platform-specific spelling proof run +// on the opened descriptor after the shared generation recheck passes +// (round 13). The default is a no-op: openat's identity binding +// (dirfd_other.go) plus the generation recheck above is everything Linux +// and the BSDs can prove, and nothing more is needed. darwin overrides this +// (entryname_darwin.go's init) with an F_GETPATH check of the opened +// descriptor's own basename — belt-and-suspenders on top of the same index, +// not a substitute for it. +var extraVerifyOpened = func(*os.File, string) error { return nil } // Warm builds the stored-name index of scriptsDir on the caller's goroutine, // so the first scoped request finds it ready. The server calls it when it @@ -338,8 +312,10 @@ var spawnIndexRebuild = func(rebuild func()) { go rebuild() } // waited for, then Warm lists again), so the index reflects the directory as // it was at the call. A directory that cannot be opened or listed leaves a // failed index (scoped callers are refused as unreadable until the directory -// changes) and the failure is returned for logging. On darwin and Windows -// there is no index and Warm is a no-op. +// changes) and the failure is returned for logging. On Windows there is no +// index of this shape (storedspellings_probe_windows.go keeps its own, +// round 13) and Warm is a no-op; darwin joined this index round 13, so Warm +// behaves for it exactly as it does for Linux/BSD. // // Warm also keeps ONLY scriptsDir's index (round 9 SHOULD): the server calls // Warm whenever the active scripts directory changes, so this is the point @@ -441,13 +417,17 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool open = func(path string) (*os.File, error) { return openatEntry(dirfd, filepath.Base(path)) } - // f and want are unused here: the SAME descriptor's own generation - // recheck (below) is what this platform can prove, and it needs - // neither the opened file nor the requested spelling — see the - // darwin/Windows counterpart in storedspellings_probe.go and - // storedspellings_probe_windows.go, which prove the spelling itself on - // f because they have no directory-generation index to recheck. - verifyUnchanged = func(_ *os.File, _ string) error { + // The SAME descriptor's own generation recheck (below) is what every + // unix platform proves; f and want are additionally threaded through + // to extraVerifyOpened, the round-13 hook darwin registers (via + // entryname_darwin.go's init) for its own belt-and-suspenders F_GETPATH + // proof on the opened descriptor — Linux/BSD leave the hook at its + // default no-op, since openat's identity binding plus the generation + // recheck is already everything they can prove. Windows has no + // directory-generation index to recheck at all — see the counterpart in + // storedspellings_probe_windows.go, which proves the spelling itself on + // f instead. + verifyUnchanged = func(f *os.File, want string) error { cur, err := fstatDirGeneration(dirfd) if err != nil { return err @@ -455,7 +435,7 @@ func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool if !cur.equal(gen) { return errIndexGenerationChanged } - return nil + return extraVerifyOpened(f, want) } return storedExactly, open, verifyUnchanged, closeSession, nil } @@ -521,12 +501,27 @@ func (idx *storedNames) scheduleRebuildLocked(key string, now time.Time) { if !idx.nextAttempt.IsZero() && now.Before(idx.nextAttempt) { return } + // Round 13 SHOULD (finding 5): a non-blocking acquire — every slot busy + // means SKIP this rebuild outright rather than queue behind one, so a + // request-triggered rebuild never blocks the request that scheduled it + // (this call itself is always off the request path already) and never + // piles up waiting goroutines of its own. The index stays exactly as + // stale as it was; the next request against this directory calls + // scheduleRebuildLocked again. + select { + case rebuildSlots <- struct{}{}: + default: + return + } idx.beginRebuildLocked() // wg.Add happens before spawnIndexRebuild hands the closure off (which // may run it synchronously, in a test that holds rebuilds back) so // idx.wg.Wait() is never called before the matching Add is visible. idx.wg.Add(1) - spawnIndexRebuild(func() { idx.rebuild(key, true, true) }) + spawnIndexRebuild(func() { + defer func() { <-rebuildSlots }() + idx.rebuild(key, true, true) + }) } // beginRebuildLocked claims the single-flight slot. diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index d7c3fe62c..ced630756 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -1,4 +1,4 @@ -//go:build !darwin && !windows +//go:build unix package codescripts @@ -766,6 +766,57 @@ func TestStoredNames_RebuildBackoffThrottlesReschedules(t *testing.T) { assert.Equal(t, 1, held.land(), "past the backoff, the still-unresolved generation mismatch schedules again") } +// TestStoredNames_RebuildSlotsBoundConcurrency (round 13 SHOULD, finding 5): +// no more than maxConcurrentRebuilds ASYNC rebuild goroutines may run at +// once, PROCESS-WIDE across every directory's index — a rebuild that cannot +// acquire a slot is SKIPPED outright, not queued behind one, so it never +// blocks the request that scheduled it and never itself piles up waiting. +// holdIndexRebuilds captures each scheduled rebuild's closure instead of +// running it, which — because scheduleRebuildLocked acquires its slot +// SYNCHRONOUSLY before handing the closure to spawnIndexRebuild — holds that +// slot consumed for exactly as long as the closure goes unlanded, without +// needing a real goroutine parked mid-listing. +func TestStoredNames_RebuildSlotsBoundConcurrency(t *testing.T) { + settleStoredNamesClock(t) + held := holdIndexRebuilds(t) + + busy := make([]string, maxConcurrentRebuilds) + for i := range busy { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + busy[i] = dir + _, _, err := ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) + } + + third := t.TempDir() + writeScript(t, third, "alpha.js", "1") + _, _, err := ResolveScoped(third, "alpha", "") + requireScopedNotFound(t, err) + + thirdIdx := storedNamesIndex(filepath.Clean(third)) + thirdIdx.mu.Lock() + building := thirdIdx.building + thirdIdx.mu.Unlock() + assert.False(t, building, "every rebuild slot is already held by another directory: this rebuild must be skipped, not queued") + + assert.Equal(t, maxConcurrentRebuilds, held.land(), + "exactly the directories that could acquire a slot were scheduled — the third was skipped, not merely deferred") + for _, dir := range busy { + waitForIndexRebuild(t, dir) + } + + // A slot is free again: the third directory's own next request finally + // schedules its rebuild, and this time it can complete. + _, _, err = ResolveScoped(third, "alpha", "") + requireScopedNotFound(t, err) // the index has not landed yet, so this request still answers fail-closed + assert.Equal(t, 1, held.land(), "the previously-skipped rebuild is scheduled now that a slot is free") + waitForIndexRebuild(t, third) + + names := lookupStoredNamesForTest(t, third) + assert.Contains(t, names, "alpha.js", "the rebuild that finally acquired a slot lands normally") +} + // TestStoredNames_WarmListsAfterAnInFlightRebuild: Warm is the server's // promise that the index reflects the directory as it was when Warm was // called, so a rebuild already in flight — which may have listed before the diff --git a/internal/codescripts/storednames_windows.go b/internal/codescripts/storednames_windows.go new file mode 100644 index 000000000..56c8db4a1 --- /dev/null +++ b/internal/codescripts/storednames_windows.go @@ -0,0 +1,603 @@ +//go:build windows + +package codescripts + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "sync" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Round 13 MUST-FIX (round-10 findings 2, 3 and 4 — unify Windows onto the +// same design every unix platform uses, darwin included as of this round): +// earlier rounds answered a scoped request from a per-path FindFirstFile +// probe (storedExactly: Lstat then, only on a hit, FindFirstFile) and opened +// the winning candidate by a FRESH, independent path lookup +// (openScriptFile) — two problems the maintainer's decision closes at once +// by giving Windows the same two things Linux/BSD/darwin have: +// +// 1. An exact-spelling INDEX (round-10 finding 3, the timing oracle): the +// directory is listed off the request path (Warm, and a single-flight +// rebuild goroutine when a request finds the index behind the +// directory's own generation), and a request answers from that index's +// map — an O(1) lookup that costs the SAME whether the name is absent or +// a differently cased variant exists, unlike Lstat-then-FindFirstFile's +// 0-vs-2-call asymmetry. +// +// 2. A directory HANDLE retained for the whole request (round-10 findings 2 +// and 4, the reparse-point/ancestor escape): winOpenScopedDir opens +// scriptsDir exactly ONCE; the candidate probe (winProbeEntry) and the +// actual open (winOpenEntry) are both performed RELATIVE TO THAT HANDLE +// via windows.NtCreateFile with OBJECT_ATTRIBUTES.RootDirectory set to +// it and ObjectName the bare basename — never a fresh path lookup that a +// retargeted reparse point on the directory itself or an ancestor could +// redirect. FILE_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW +// (opens the reparse point itself rather than following it), and +// FILE_NON_DIRECTORY_FILE refuses a directory outright, atomically — no +// check-then-open window exists for either to land in. The share mode +// (FILE_SHARE_READ|WRITE|DELETE, finding 4) matches what open_windows.go +// now also requests for the administrator's own read: a script being +// read here can still be atomically replaced by a concurrent deploy. +// +// The post-open proof (verifyUnchanged) mirrors storednames_other.go's +// gen-before/gen-after recheck on the SAME retained handle — proving the +// directory itself was not swapped mid-request — plus, belt-and-suspenders, +// winOpenedBaseName (GetFinalPathNameByHandle on the OPENED file's own +// handle, basename only: the parent is already structurally bound by the +// relative open itself, so — unlike round 11's design, which had to compare +// the full path because opens were not yet handle-relative — only the +// basename is worth re-checking here). +// +// The index's own generation (winDirGeneration) folds the directory's +// IDENTITY — VolumeSerialNumber plus FileIndexHigh/Low, GetFileInformationByHandle, +// Windows's rough counterpart to a Unix dev+ino pair — together with its +// LastWriteTime, exactly as dirGeneration folds dev+ino with mtime/ctime on +// unix: a request whose retained handle resolves to a DIFFERENT directory +// than the one the index was built from (identity mismatch) is answered +// exactly like a stale generation — a plain miss, rebuild scheduled. The +// settle window (generationSettleTime, indexclock.go — shared with unix) is +// unchanged: NTFS timestamps are fine-grained, but a scripts directory is +// not guaranteed to live on an NTFS volume, and a FAT-formatted one carries +// the identical two-second write-time coarseness vfat has on Linux. + +// winDirGeneration is Windows's counterpart to dirGeneration +// (storednames_other.go). +type winDirGeneration struct { + volumeSerial uint32 + fileIndexHigh, fileIndexLow uint32 + lastWrite time.Time +} + +func (g winDirGeneration) equal(o winDirGeneration) bool { + return g.volumeSerial == o.volumeSerial && + g.fileIndexHigh == o.fileIndexHigh && g.fileIndexLow == o.fileIndexLow && + g.lastWrite.Equal(o.lastWrite) +} + +func (g winDirGeneration) latest() time.Time { return g.lastWrite } + +// winStoredNames is storedNames's (storednames_other.go) Windows +// counterpart — see there for the full rationale behind every field; this +// struct is built from a retained directory HANDLE rather than a file +// descriptor. +type winStoredNames struct { + mu sync.Mutex + names map[string]struct{} + err error + gen winDirGeneration + settled bool + + building bool + landed chan struct{} + + refreshAfter time.Time + nextAttempt time.Time + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// winStoredIndexes holds one *winStoredNames per cleaned scripts directory — +// the Windows counterpart of storedIndexes (storednames_other.go); see there +// for the LRU/pruning rationale, identical here. +var ( + winStoredIndexesMu sync.Mutex + winStoredIndexes = map[string]*winStoredNames{} + winStoredIndexesLRU []string +) + +func winStoredNamesIndex(key string) *winStoredNames { + winStoredIndexesMu.Lock() + defer winStoredIndexesMu.Unlock() + idx, ok := winStoredIndexes[key] + if !ok { + ctx, cancel := context.WithCancel(context.Background()) + idx = &winStoredNames{ctx: ctx, cancel: cancel} + winStoredIndexes[key] = idx + } + winTouchIndexLocked(key) + winEvictExcessLocked() + return idx +} + +func winTouchIndexLocked(key string) { + for i, k := range winStoredIndexesLRU { + if k == key { + winStoredIndexesLRU = append(winStoredIndexesLRU[:i], winStoredIndexesLRU[i+1:]...) + break + } + } + winStoredIndexesLRU = append(winStoredIndexesLRU, key) +} + +func winEvictExcessLocked() { + for len(winStoredIndexesLRU) > maxStoredNameIndexes { + oldest := winStoredIndexesLRU[0] + winStoredIndexesLRU = winStoredIndexesLRU[1:] + if idx, ok := winStoredIndexes[oldest]; ok { + idx.cancel() + } + delete(winStoredIndexes, oldest) + } +} + +func winPruneOtherIndexesLocked(keep string) { + for k, idx := range winStoredIndexes { + if k != keep { + idx.cancel() + delete(winStoredIndexes, k) + } + } + kept := winStoredIndexesLRU[:0] + for _, k := range winStoredIndexesLRU { + if k == keep { + kept = append(kept, k) + } + } + winStoredIndexesLRU = kept +} + +// winForgetIndex removes one directory's index entirely; test-only (mirrors +// forgetIndex, storednames_other.go). +func winForgetIndex(key string) { + winStoredIndexesMu.Lock() + defer winStoredIndexesMu.Unlock() + if idx, ok := winStoredIndexes[key]; ok { + idx.cancel() + } + delete(winStoredIndexes, key) + for i, k := range winStoredIndexesLRU { + if k == key { + winStoredIndexesLRU = append(winStoredIndexesLRU[:i], winStoredIndexesLRU[i+1:]...) + break + } + } +} + +// winForEachIndex calls fn for every currently held index; test-only +// (mirrors forEachIndex, storednames_other.go). +func winForEachIndex(fn func(*winStoredNames)) { + winStoredIndexesMu.Lock() + idxs := make([]*winStoredNames, 0, len(winStoredIndexes)) + for _, idx := range winStoredIndexes { + idxs = append(idxs, idx) + } + winStoredIndexesMu.Unlock() + for _, idx := range idxs { + fn(idx) + } +} + +// Warm builds the stored-name index of scriptsDir on the caller's goroutine +// — the Windows counterpart of Warm (storednames_other.go); see there for +// the full rationale, identical here down to Warm's own rebuild never being +// cancelled by pruneOtherIndexesLocked's cancellation of every OTHER +// directory's index. +func Warm(scriptsDir string) error { + key := filepath.Clean(scriptsDir) + idx := winStoredNamesIndex(key) + winStoredIndexesMu.Lock() + winPruneOtherIndexesLocked(key) + winStoredIndexesMu.Unlock() + for { + idx.mu.Lock() + if !idx.building { + idx.beginRebuildLocked() + idx.mu.Unlock() + break + } + landed := idx.landed + idx.mu.Unlock() + <-landed + } + idx.wg.Add(1) + idx.rebuild(key, false, false) + idx.mu.Lock() + defer idx.mu.Unlock() + return idx.err +} + +// storedSpellingsOf answers, for one scoped request, whether scriptsDir +// holds an entry spelled exactly `want`, bound to the SINGLE directory +// handle this call opens — see this file's package doc comment above and +// storednames_other.go's identical unix contract. +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { + key := filepath.Clean(scriptsDir) + + dirHandle, err := winOpenScopedDir(key) + if err != nil { + return nil, nil, nil, nil, err + } + closeSession = func() { _ = windows.CloseHandle(dirHandle) } + + gen, err := winFstatDirGeneration(dirHandle) + if err != nil { + closeSession() + return nil, nil, nil, nil, err + } + + names, lookupErr := winStoredNamesFor(key, gen) + if lookupErr != nil { + closeSession() + return nil, nil, nil, nil, lookupErr + } + + storedExactly = func(want string) (bool, error) { + if _, ok := names[want]; !ok { + return false, nil + } + if err := winProbeEntry(dirHandle, want); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil + } + open = func(path string) (*os.File, error) { + return winOpenEntry(dirHandle, filepath.Base(path)) + } + verifyUnchanged = func(f *os.File, want string) error { + cur, err := winFstatDirGeneration(dirHandle) + if err != nil { + return err + } + if !cur.equal(gen) { + return errIndexGenerationChanged + } + // The parent is already bound by the relative open itself + // (winOpenEntry, via RootDirectory) — only the basename is worth + // re-checking here, unlike round 11's full-path comparison, which + // existed only because that round's open was still a fresh, unbound + // path lookup. + got, err := winOpenedBaseName(f) + if err != nil || got != want { + return errSpellingUnproven + } + return nil + } + return storedExactly, open, verifyUnchanged, closeSession, nil +} + +// winStoredNamesFor is storedNamesFor's (storednames_other.go) Windows +// counterpart: identical contract, one directory handle's freshly read +// generation in, the index's names (or nil, fail-closed) out. +func winStoredNamesFor(key string, gen winDirGeneration) (names map[string]struct{}, err error) { + idx := winStoredNamesIndex(key) + now := indexClock() + + idx.mu.Lock() + defer idx.mu.Unlock() + + current := (idx.names != nil || idx.err != nil) && idx.gen.equal(gen) + + switch { + case !current: + idx.scheduleRebuildLocked(key, now) + case !idx.settled && !now.Before(idx.refreshAfter): + idx.scheduleRebuildLocked(key, now) + } + + if !current || !idx.settled { + return nil, nil + } + return idx.names, idx.err +} + +// beginRebuildLocked claims the single-flight slot. +func (idx *winStoredNames) beginRebuildLocked() { + idx.building = true + idx.landed = make(chan struct{}) +} + +// scheduleRebuildLocked mirrors storedNames.scheduleRebuildLocked +// (storednames_other.go) exactly, rebuildSlots (round 13 SHOULD, finding 5) +// included: the semaphore is process-wide and shared with the unix index +// implementation (rebuildsemaphore.go), since the two never build together. +func (idx *winStoredNames) scheduleRebuildLocked(key string, now time.Time) { + idx.refreshAfter = now.Add(generationSettleTime) + if idx.building { + return + } + if !idx.nextAttempt.IsZero() && now.Before(idx.nextAttempt) { + return + } + select { + case rebuildSlots <- struct{}{}: + default: + return + } + idx.beginRebuildLocked() + idx.wg.Add(1) + spawnIndexRebuild(func() { + defer func() { <-rebuildSlots }() + idx.rebuild(key, true, true) + }) +} + +// rebuild mirrors storedNames.rebuild (storednames_other.go) exactly. +func (idx *winStoredNames) rebuild(key string, backoffAfter, cancellable bool) { + defer idx.wg.Done() + for attempt := 1; ; attempt++ { + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return + } + before, after, names, listErr := winListScopedDirOnce(key) + now := indexClock() + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return + } + if listErr == nil && attempt < maxRebuildAttempts && !before.equal(after) { + continue + } + gen := after + if listErr != nil { + gen = before + } + idx.mu.Lock() + idx.names, idx.err, idx.gen = names, listErr, gen + idx.settled = listErr == nil && now.Sub(gen.latest()) >= generationSettleTime + idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) + } + close(idx.landed) + idx.mu.Unlock() + return + } +} + +// finishRebuild mirrors storedNames.finishRebuild (storednames_other.go). +func (idx *winStoredNames) finishRebuild(backoffAfter bool) { + idx.mu.Lock() + idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) + } + close(idx.landed) + idx.mu.Unlock() +} + +// winListScopedDirOnce mirrors defaultListScopedDirOnce (storednames_other.go): +// one directory handle serves the generation read AND the listing, so a +// change during the listing is caught by the before/after reads disagreeing +// without ever resolving the path a second time. A variable so the tests +// can inject the directory-open seam's behaviour directly. +var winListScopedDirOnce = defaultWinListScopedDirOnce + +func defaultWinListScopedDirOnce(key string) (before, after winDirGeneration, names map[string]struct{}, err error) { + h, err := winOpenScopedDir(key) + if err != nil { + return winDirGeneration{}, winDirGeneration{}, nil, err + } + defer func() { _ = windows.CloseHandle(h) }() + + before, err = winFstatDirGeneration(h) + if err != nil { + return winDirGeneration{}, winDirGeneration{}, nil, err + } + entryNames, err := winListScopedDir(h, key) + if err != nil { + return before, winDirGeneration{}, nil, err + } + after, err = winFstatDirGeneration(h) + if err != nil { + return before, winDirGeneration{}, nil, err + } + names = make(map[string]struct{}, len(entryNames)) + for _, n := range entryNames { + names[n] = struct{}{} + } + return before, after, names, nil +} + +// The primitives below are variables, exactly as dirfd_other.go's are, so +// the package's tests can hook them individually. +var ( + winOpenScopedDir = defaultWinOpenScopedDir + winFstatDirGeneration = defaultWinFstatDirGeneration + winProbeEntry = defaultWinProbeEntry + winOpenEntry = defaultWinOpenEntry + winListScopedDir = defaultWinListScopedDir +) + +// defaultWinOpenScopedDir opens scriptsDir once. FILE_FLAG_BACKUP_SEMANTICS +// is required to obtain any handle on a directory at all; the share mode +// (round 13 MUST-FIX, finding 4) matches open_windows.go's own +// openScriptFile — READ|WRITE|DELETE — so holding this handle for the +// request's duration cannot itself block a concurrent write or atomic +// replace anywhere under the directory. +// +// Round 13 sibling sweep: unlike unix's O_DIRECTORY (dirfd_other.go), +// CreateFile with FILE_FLAG_BACKUP_SEMANTICS does not itself refuse a path +// that now names a plain FILE — the scripts directory replaced by a file is +// exactly the sibling class this round's sweep calls out — so the type is +// checked explicitly here, on the SAME handle everything else in the +// request is bound to, and refused (unreadable) rather than silently +// treating an ordinary file as an empty directory. +func defaultWinOpenScopedDir(path string) (windows.Handle, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + h, err := windows.CreateFile(p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0) + if err != nil { + return 0, &os.PathError{Op: "open", Path: path, Err: err} + } + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + _ = windows.CloseHandle(h) + return 0, &os.PathError{Op: "open", Path: path, Err: err} + } + if fi.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + _ = windows.CloseHandle(h) + return 0, &os.PathError{Op: "open", Path: path, Err: windows.ERROR_DIRECTORY} + } + return h, nil +} + +// defaultWinFstatDirGeneration reads a directory's generation from an +// already-open handle — GetFileInformationByHandle, never a path lookup — +// so it can be called again, after the candidate open, without re-resolving +// scriptsDir. VolumeSerialNumber + FileIndexHigh/Low is the directory's +// IDENTITY (round 13, the maintainer's exact instruction — Windows's +// counterpart to a Unix dev+ino pair); LastWriteTime is what moves whenever +// the directory's entry set changes. +func defaultWinFstatDirGeneration(h windows.Handle) (winDirGeneration, error) { + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + return winDirGeneration{}, err + } + return winDirGeneration{ + volumeSerial: fi.VolumeSerialNumber, + fileIndexHigh: fi.FileIndexHigh, + fileIndexLow: fi.FileIndexLow, + lastWrite: time.Unix(0, fi.LastWriteTime.Nanoseconds()), + }, nil +} + +// defaultWinListScopedDir lists h's entries through a DUPLICATE of the +// handle: os.File.Close on the duplicate releases only the copy, leaving +// the caller's own h untouched. DuplicateHandle, not a fresh CreateFile, +// so the listing is bound to the identical open the generation came from — +// standard library os.File.Readdirnames on Windows lists via the handle +// itself (GetFileInformationByHandleEx), never by re-resolving the path +// string os.NewFile is given for bookkeeping. +func defaultWinListScopedDir(h windows.Handle, path string) ([]string, error) { + cur := windows.CurrentProcess() + var dup windows.Handle + if err := windows.DuplicateHandle(cur, h, cur, &dup, 0, false, windows.DUPLICATE_SAME_ACCESS); err != nil { + return nil, err + } + f := os.NewFile(uintptr(dup), path) + defer func() { _ = f.Close() }() + return f.Readdirnames(-1) +} + +// defaultWinProbeEntry probes name relative to dirHandle — the candidate's +// own existence check, bound to the SAME handle the generation was just +// read from rather than a fresh path lookup (round 13 MUST-FIX, finding 2: +// exactly the second, independent path resolution that let a retargeted +// reparse point substitute a different file). Minimal access +// (FILE_READ_ATTRIBUTES) and FILE_OPEN_REPARSE_POINT: this is existence +// only, mirroring fstatatEntry (dirfd_other.go) — it does not itself decide +// regular-vs-not; the actual open plus resolve's own f.Stat() does that. +func defaultWinProbeEntry(dirHandle windows.Handle, name string) error { + h, err := ntCreateRelative(dirHandle, name, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT) + if err != nil { + return &os.PathError{Op: "open", Path: name, Err: err} + } + _ = windows.CloseHandle(h) + return nil +} + +// defaultWinOpenEntry opens name relative to dirHandle — the entry +// winProbeEntry already probed, opened relative to the SAME handle, never a +// second, independent lookup of the name (round 13 MUST-FIX, finding 2). +// FILE_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW (opens the +// reparse point itself, atomically, rather than transparently resolving it +// — the same no-check-then-open-window guarantee open_windows.go's own +// openScriptFile relies on); FILE_NON_DIRECTORY_FILE refuses a directory at +// the NtCreateFile layer itself. GetFileInformationByHandle on the opened +// handle then refuses a reparse point or (belt-and-suspenders) a directory, +// exactly as open_windows.go's openScriptFile does for the administrator. +func defaultWinOpenEntry(dirHandle windows.Handle, name string) (*os.File, error) { + h, err := ntCreateRelative(dirHandle, name, + windows.FILE_GENERIC_READ, + windows.FILE_OPEN_REPARSE_POINT|windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + _ = windows.CloseHandle(h) + return nil, err + } + if fi.FileAttributes&(windows.FILE_ATTRIBUTE_REPARSE_POINT|windows.FILE_ATTRIBUTE_DIRECTORY) != 0 { + _ = windows.CloseHandle(h) + return nil, errNonRegular + } + return os.NewFile(uintptr(h), name), nil +} + +// winOpenedBaseName is this file's counterpart to darwin's F_GETPATH +// belt-and-suspenders proof (entryname_darwin.go): the on-disk basename +// GetFinalPathNameByHandle reports for the OPENED descriptor, not a +// separate pre-open probe of the same name. +func winOpenedBaseName(f *os.File) (string, error) { + return openedBaseName(f) +} + +// ntCreateRelative opens name relative to dirHandle via windows.NtCreateFile +// — OBJECT_ATTRIBUTES.RootDirectory bound to dirHandle, ObjectName the bare +// basename — so the lookup can never traverse outside dirHandle's own +// directory: a rename of the directory itself, or a reparse point planted +// on an ancestor, cannot redirect a RELATIVE open the way it could a fresh +// path lookup (round 13 MUST-FIX, finding 2). Share mode +// READ|WRITE|DELETE matches open_windows.go's own openScriptFile (round 13 +// MUST-FIX, finding 4). +func ntCreateRelative(dirHandle windows.Handle, name string, access, options uint32) (windows.Handle, error) { + objName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, err + } + oa := &windows.OBJECT_ATTRIBUTES{ + Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), + RootDirectory: dirHandle, + ObjectName: objName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + var h windows.Handle + var iosb windows.IO_STATUS_BLOCK + ntErr := windows.NtCreateFile(&h, access, oa, &iosb, nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + options, + 0, 0) + if ntErr != nil { + if st, ok := ntErr.(windows.NTStatus); ok { + return 0, st.Errno() + } + return 0, ntErr + } + return h, nil +} diff --git a/internal/codescripts/storedspellings_probe.go b/internal/codescripts/storedspellings_probe.go deleted file mode 100644 index 8224be3ca..000000000 --- a/internal/codescripts/storedspellings_probe.go +++ /dev/null @@ -1,89 +0,0 @@ -//go:build darwin - -package codescripts - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "time" -) - -// Warm is a no-op where storedSpellingsOf is a single-entry platform call: -// there is no index to build. The Linux/BSD counterpart lists the directory -// once, off the request path. -func Warm(string) error { return nil } - -// SetIndexClockForTest is a no-op here: there is no directory-generation -// index or settle window on darwin — storedSpellingsOf proves the spelling -// directly, on the descriptor that is actually opened, rather than trusting -// a listed generation. Present so a caller outside this package (an -// internal/server fixture built for every platform) compiles and runs -// unchanged on darwin, where there is nothing to settle. -func SetIndexClockForTest(func() time.Time) (restore func()) { return func() {} } - -// storedSpellingsOf answers, for one scoped request, whether scriptsDir holds -// an entry spelled exactly `want`, by a fixed number of single-path calls and -// never a listing (Spec 105 FR-012). The default APFS/HFS+ volume is -// case-insensitive but case-PRESERVING, so the probe alone would accept -// `backdoor.JS` for `backdoor.js`; a hit is accepted only when the entry's -// stored spelling (entryName, one single-entry platform call) is -// byte-for-byte the requested one, exactly as List decides. -// -// This is the CHEAP pre-open gate only — round 9 MUST-FIX: a case-rename or -// replacement landing between this probe and openScriptFile's own open can -// leave a different, case-folded file behind the same requested spelling for -// the descriptor's entire lifetime, and neither a no-follow open nor Stat -// tells a folded spelling from an exact one. The returned verifyUnchanged -// proves the spelling AUTHORITATIVELY, on the descriptor that will actually -// be read — see below. open and closeSession are always nil here (round 11: -// darwin is unchanged beyond this shared five-return signature — see -// storednames_other.go's Linux/BSD counterpart and -// storedspellings_probe_windows.go for the platforms that need them): -// openScriptFile's own O_SYMLINK-probed, O_NOFOLLOW no-follow open already -// resolves the path exactly once for the actual read, and there is no -// per-request resource to release. -// -// A platform-call failure here is not a match (round 9 MUST-FIX): earlier -// rounds let the Lstat verdict alone stand when entryName errored, which -// fails OPEN on a probe race or platform-call failure. The pre-open probe -// need not be perfectly precise — the post-open proof is authoritative and -// would still catch a wrongly admitted candidate — but there is no reason to -// admit one on a failure this function cannot itself explain. -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { - storedExactly = func(want string) (bool, error) { - path := filepath.Join(scriptsDir, want) - if _, err := lstat(path); err != nil { - if errors.Is(err, fs.ErrNotExist) { - return false, nil - } - return false, err - } - stored, err := entryName(path) - switch { - case err != nil: - return false, nil - case stored != want && strings.EqualFold(stored, want): - // The filesystem folded the case: the entry is spelled differently - // and no discovery surface reports it under this name. Only a - // case-only difference is a fold; any other answer (a hard link's - // other name) leaves the Lstat verdict in force. - return false, nil - } - return true, nil - } - verifyUnchanged = func(f *os.File, want string) error { - stored, err := openedEntryName(f) - if err != nil || stored != want { - // Any failure of the proof call, or any mismatch, refuses — the - // pre-open probe already decided "true" and the caller is about - // to read this descriptor, so an unprovable spelling gets no - // benefit of the doubt (round 9 MUST-FIX). - return errSpellingUnproven - } - return nil - } - return storedExactly, nil, verifyUnchanged, nil, nil -} diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go index afe895885..54389d8bc 100644 --- a/internal/codescripts/storedspellings_probe_test.go +++ b/internal/codescripts/storedspellings_probe_test.go @@ -1,40 +1,74 @@ -//go:build darwin || windows +//go:build windows package codescripts import ( "errors" - "os" "path/filepath" - "runtime" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" ) -// warmStoredNames is a no-op where storedSpellingsOf is a single-entry platform -// call (darwin F_GETPATH, Windows FindFirstFile) and there is no index to -// warm; the Linux/BSD counterpart builds the index once, off the request path. -func warmStoredNames(t *testing.T, _ string) { +// Round 13 (round-10 findings 2, 3 and 4): Windows joined the same +// per-directory exact-spelling INDEX every unix platform uses +// (storednames_windows.go) instead of a per-request FindFirstFile probe, so +// these helpers now mirror storednames_other_test.go's (Linux/BSD/darwin) +// rather than being no-ops — there is real off-request-path work to +// quiesce and a real settle window to fake. + +// quiesceIndexRebuilds waits for every rebuild goroutine the tests so far +// have left in flight. +func quiesceIndexRebuilds() { + winForEachIndex(func(idx *winStoredNames) { + idx.mu.Lock() + building, landed := idx.building, idx.landed + idx.mu.Unlock() + if building { + <-landed + } + }) +} + +// settleStoredNamesClock moves the index clock far past any directory the +// test writes, so an index taken now counts as settled and is trusted until +// the directory's generation moves. Restored on cleanup. +func settleStoredNamesClock(t *testing.T) { t.Helper() + quiesceIndexRebuilds() + orig := indexClock + indexClock = func() time.Time { return orig().Add(time.Hour) } + t.Cleanup(func() { + quiesceIndexRebuilds() + indexClock = orig + }) } -// quiesceIndexRebuilds is a no-op here: nothing runs off the request path. -func quiesceIndexRebuilds() {} +// warmStoredNames builds the stored-name index of dir once, with the clock +// settled, so the shared tests that count a scoped resolution's directory +// reads start from a warm index — as the server does at construction. +func warmStoredNames(t *testing.T, dir string) { + t.Helper() + settleStoredNamesClock(t) + require.NoError(t, Warm(dir)) +} // TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor is the -// positive control for the round 9 MUST-FIX post-open proof: nothing raced -// the open, so the opened descriptor's own stored spelling still matches -// exactly what was requested and probed. +// positive control for the post-open proof: nothing raced the open, so the +// opened descriptor's own generation recheck and stored-basename proof both +// still agree with what was requested and probed. func TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor(t *testing.T) { dir := t.TempDir() - path := writeScript(t, dir, "alpha.js", "1") + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) require.NoError(t, err) - require.Nil(t, open, "darwin/Windows never bind the open to a per-request descriptor (round 11): openScriptFile's own no-follow open is already authoritative") - require.NotNil(t, verifyUnchanged, "darwin/Windows always supply the authoritative post-open check") + require.NotNil(t, open, "round 13: Windows opens the winning candidate relative to the retained directory handle, exactly as unix does") + require.NotNil(t, verifyUnchanged) if closeSession != nil { defer closeSession() } @@ -42,75 +76,78 @@ func TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor(t *testing. require.NoError(t, err) require.True(t, ok) - f, err := openScriptFile(path) + f, err := open(filepath.Join(dir, "alpha.js")) require.NoError(t, err) defer f.Close() assert.NoError(t, verifyUnchanged(f, "alpha.js")) } -// TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor (round -// 9 MUST-FIX): the pre-open probe (storedExactly) is only a cheap gate — it -// can be satisfied and the open can still succeed against a file that a race -// has since case-renamed, because a no-follow open does not compare names, -// only symlink status, and the SAME descriptor keeps reading through a -// rename of its own directory entry. The authoritative check reads the -// OPENED descriptor's own stored spelling (F_GETPATH / GetFinalPathNameByHandle) -// and must refuse once it no longer matches what was requested, wherever in -// the descriptor's lifetime the rename lands. +// TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor: +// the pre-open probe (storedExactly) is only a cheap gate — the directory's +// generation (winFstatDirGeneration on the SAME retained handle) is what +// the post-open recheck actually trusts. A rename that lands between the +// probe and the open moves the directory's own LastWriteTime, so the +// recheck must refuse even though the open itself, relative to the +// retained handle, still succeeds against whatever the winning candidate's +// name resolves to at that moment. func TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor(t *testing.T) { dir := t.TempDir() - path := writeScript(t, dir, "alpha.js", "1") + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) - _, _, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) + _, open, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) require.NoError(t, err) + require.NotNil(t, open) require.NotNil(t, verifyUnchanged) if closeSession != nil { defer closeSession() } - // The race: the file is case-renamed between the pre-open probe and the - // open. APFS and NTFS fold the requested spelling onto the renamed entry, - // so the open succeeds — and F_GETPATH / GetFinalPathNameByHandle on the - // opened descriptor report the RENAMED spelling, proving the descriptor - // is not the exact name that was requested and probed. - require.NoError(t, os.Rename(path, filepath.Join(dir, "ALPHA.JS"))) - - f, err := openScriptFile(path) - require.NoError(t, err, "a case-folding filesystem opens the renamed entry under the old spelling") + f, err := open(filepath.Join(dir, "alpha.js")) + require.NoError(t, err) defer f.Close() + // The race: another entry is written into the SAME directory between the + // open and the recheck, moving the directory's own generation — exactly + // the round-8 lookup→open race the shared generation recheck exists to + // catch, here exercised through the Windows primitives. + writeScript(t, dir, "beta.js", "2") + verifyErr := verifyUnchanged(f, "alpha.js") - require.Error(t, verifyErr, "the opened descriptor's spelling no longer matches what was requested") - assert.True(t, errors.Is(verifyErr, errSpellingUnproven)) + require.Error(t, verifyErr, "the directory's own generation moved between the open and the recheck") + assert.True(t, errors.Is(verifyErr, errIndexGenerationChanged)) } -// TestStoredSpellingsOf_PostOpenProofCatchesARenameAfterOpen is the same -// proof taken after the open: the descriptor is already reading the file when -// it is case-renamed. Windows refuses to rename a file another handle holds -// open (no FILE_SHARE_DELETE on the executed handle), so that half of the race -// cannot occur there; the test is darwin-only. -func TestStoredSpellingsOf_PostOpenProofCatchesARenameAfterOpen(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Windows refuses to rename a file held open by another handle") - } - dir := t.TempDir() - path := writeScript(t, dir, "alpha.js", "1") - - _, _, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) +// TestStoredSpellingsOf_DirectoryHandleIdentityMismatchIsAMiss (round 13, +// mirrors TestStoredNamesFor_IdentityMismatchIsAMiss and +// TestResolveScoped_DirectoryPathABA on unix): an index built from one +// directory must never authorize a request whose retained handle resolves +// to a DIFFERENT directory at the same path — the identity half of +// winDirGeneration (VolumeSerialNumber + FileIndexHigh/Low) is what this +// pins, directly at the winStoredNamesFor seam rather than through a real +// directory replacement (not reliably reproducible without an elevated +// symlink/junction on every CI runner). +func TestStoredSpellingsOf_DirectoryHandleIdentityMismatchIsAMiss(t *testing.T) { + dirA := t.TempDir() + writeScript(t, dirA, "alpha.js", "1") + warmStoredNames(t, dirA) + + key := filepath.Clean(dirA) + h, err := winOpenScopedDir(key) require.NoError(t, err) - require.NotNil(t, verifyUnchanged) - if closeSession != nil { - defer closeSession() - } - - f, err := openScriptFile(path) + defer func() { _ = windows.CloseHandle(h) }() + genA, err := winFstatDirGeneration(h) require.NoError(t, err) - defer f.Close() - require.NoError(t, os.Rename(path, filepath.Join(dir, "ALPHA.JS"))) + // A generation that shares dirA's LastWriteTime but claims a different + // identity (as if the retained handle now resolved to a different + // directory occupying the same path) must be refused exactly like a + // stale generation — a miss, never an authorized hit. + spoofed := genA + spoofed.fileIndexLow++ - verifyErr := verifyUnchanged(f, "alpha.js") - require.Error(t, verifyErr, "the opened descriptor's spelling no longer matches what was requested") - assert.True(t, errors.Is(verifyErr, errSpellingUnproven)) + names, err := winStoredNamesFor(key, spoofed) + require.NoError(t, err) + assert.Nil(t, names, "an identity mismatch is refused exactly like a never-built index") } diff --git a/internal/codescripts/storedspellings_probe_windows.go b/internal/codescripts/storedspellings_probe_windows.go deleted file mode 100644 index 691ea20bb..000000000 --- a/internal/codescripts/storedspellings_probe_windows.go +++ /dev/null @@ -1,92 +0,0 @@ -//go:build windows - -package codescripts - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "time" -) - -// Warm is a no-op where storedSpellingsOf is a single-entry platform call: -// there is no index to build. The Linux/BSD counterpart lists the directory -// once, off the request path. -func Warm(string) error { return nil } - -// SetIndexClockForTest is a no-op here: there is no directory-generation -// index or settle window on Windows — storedSpellingsOf proves the spelling -// directly, on the descriptor that is actually opened, rather than trusting -// a listed generation. Present so a caller outside this package (an -// internal/server fixture built for every platform) compiles and runs -// unchanged on Windows, where there is nothing to settle. -func SetIndexClockForTest(func() time.Time) (restore func()) { return func() {} } - -// storedSpellingsOf answers, for one scoped request, whether scriptsDir holds -// an entry spelled exactly `want`, by a fixed number of single-path calls and -// never a listing (Spec 105 FR-012). NTFS is case-insensitive but -// case-PRESERVING, so the probe alone would accept `backdoor.JS` for -// `backdoor.js`; a hit is accepted only when the entry's stored spelling -// (entryName, one single-entry platform call) is byte-for-byte the requested -// one, exactly as List decides. This is the CHEAP pre-open gate only — the -// returned verifyUnchanged is what proves the winning candidate -// AUTHORITATIVELY, on the descriptor that is actually read. -// -// Round 11 MUST-FIX: a basename-only proof (round 9's openedEntryName) is -// satisfied by ANY identically named file reachable through a reparse point -// planted on the candidate itself, or on a symlinked ancestor directory, -// between the pre-open probe and openScriptFile's own open (which round 11 -// also hardened — see open_windows.go — to never follow a reparse point at -// the final component, closing that half of the race; the ancestor half -// remains open to a plain basename check). The fix opens the scripts -// directory itself ONCE per request (dirFinalPath, FILE_FLAG_BACKUP_SEMANTICS) -// and compares the opened candidate's FULL normalized path -// (openedFinalPath) against that directory's own final path plus the exact -// basename — so the proof confirms both the name AND the parent, and a -// retargeted ancestor cannot make an outside file's basename satisfy it. -// closeSession releases the directory handle once the caller (resolve, in -// codescripts.go) is done with it. open stays nil: openScriptFile's own -// no-follow open (round 11 MUST-FIX above) is already authoritative about -// which entry it opens; there is no descriptor to bind it to beyond that. -func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { - dirPath, closeDir, err := dirFinalPath(scriptsDir) - if err != nil { - return nil, nil, nil, nil, err - } - closeSession = closeDir - baseline := strings.TrimRight(dirPath, `\`) + `\` - - storedExactly = func(want string) (bool, error) { - path := filepath.Join(scriptsDir, want) - if _, err := lstat(path); err != nil { - if errors.Is(err, fs.ErrNotExist) { - return false, nil - } - return false, err - } - stored, err := entryName(path) - switch { - case err != nil: - return false, nil - case stored != want && strings.EqualFold(stored, want): - return false, nil - } - return true, nil - } - verifyUnchanged = func(f *os.File, want string) error { - got, err := openedFinalPath(f) - if err != nil || got != baseline+want { - // Any failure of the proof call, a mismatched basename, or a - // parent directory other than the one this request opened all - // refuse alike (round 9 / round 11 MUST-FIX): the pre-open - // probe already decided "true" and the caller is about to read - // this descriptor, so an unprovable or misparented spelling - // gets no benefit of the doubt. - return errSpellingUnproven - } - return nil - } - return storedExactly, nil, verifyUnchanged, closeSession, nil -} From a58fe56ff1ea618e84f2e0401735e3b291e9648b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 17 Sep 2026 08:57:53 +0300 Subject: [PATCH 18/23] =?UTF-8?q?fix(scope):=20PR=20H0=20=E2=80=94=20a=20d?= =?UTF-8?q?irectory=20candidate=20is=20non-regular=20on=20Windows,=20never?= =?UTF-8?q?=20unreadable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without FILE_FLAG_BACKUP_SEMANTICS (deliberately not requested: it lets a SeBackupPrivilege holder read past ACLs, which os.Open never did) CreateFile refuses a directory with ERROR_ACCESS_DENIED before any attribute is visible, so TestResolve_Directory reported "unreadable" where the pre-105 administrator answer is "non-regular" (SC-005). Classify that case from the attributes; the scoped relative open maps STATUS_FILE_IS_A_DIRECTORY the same way. Co-Authored-By: Claude Opus 5 --- internal/codescripts/open_windows.go | 13 +++++++++++++ internal/codescripts/storednames_windows.go | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/internal/codescripts/open_windows.go b/internal/codescripts/open_windows.go index 4f4022f40..d6d2fc182 100644 --- a/internal/codescripts/open_windows.go +++ b/internal/codescripts/open_windows.go @@ -3,6 +3,7 @@ package codescripts import ( + "errors" "os" "golang.org/x/sys/windows" @@ -51,6 +52,18 @@ func openScriptFile(path string) (*os.File, error) { windows.FILE_FLAG_OPEN_REPARSE_POINT, 0) if err != nil { + // Without FILE_FLAG_BACKUP_SEMANTICS (deliberately not requested: it + // would let a process holding SeBackupPrivilege read past ACLs, which + // os.Open never did) CreateFile refuses a DIRECTORY with + // ERROR_ACCESS_DENIED before any attribute is visible. The + // administrator's pre-105 answer for a directory candidate is + // non-regular, not unreadable (SC-005), so classify that one case + // from the attributes. + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + if attrs, aerr := windows.GetFileAttributes(p); aerr == nil && attrs&windows.FILE_ATTRIBUTE_DIRECTORY != 0 { + return nil, errNonRegular + } + } return nil, err } var fi windows.ByHandleFileInformation diff --git a/internal/codescripts/storednames_windows.go b/internal/codescripts/storednames_windows.go index 56c8db4a1..23b6fd5cf 100644 --- a/internal/codescripts/storednames_windows.go +++ b/internal/codescripts/storednames_windows.go @@ -595,6 +595,12 @@ func ntCreateRelative(dirHandle windows.Handle, name string, access, options uin 0, 0) if ntErr != nil { if st, ok := ntErr.(windows.NTStatus); ok { + // FILE_NON_DIRECTORY_FILE refuses a directory at the kernel: + // that is the non-regular answer the Unix Fstat check gives, + // not an unreadable entry. + if st == windows.STATUS_FILE_IS_A_DIRECTORY { + return 0, errNonRegular + } return 0, st.Errno() } return 0, ntErr From 6db4f55b7ef3b0c15edb3d2e5eef2f66ff7f582a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 17 Sep 2026 20:45:03 +0300 Subject: [PATCH 19/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?14=20=E2=80=94=20Windows=20path-size=20off-by-one=20and=20fallb?= =?UTF-8?q?ack-platform=20test=20compilation=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two codex r11 SHOULD findings: 1. entryname_windows.go's finalPathOfHandle retried the GetFinalPathNameByHandle call only when n > len(buf). Windows reports the required size INCLUDING the null terminator when the buffer was too small, so a resolved path whose length makes the first call report n == len(buf) also needs a retry — that boundary previously fell through and returned a truncated/unspecified buffer, wrongly refusing a valid exact-length scoped script as not found. Fixed to n >= len(buf); GetFinalPathNameByHandle itself is now a seam (getFinalPathNameByHandle) so a table test can drive the retry at exactly that boundary (one under, exact, one over) without needing a real handle whose resolved path happens to be exactly 1024 UTF-16 units. 2. GOOS=plan9 and GOOS=js test compilation for ./internal/codescripts failed before reaching fallback_other.go's fail-closed behavior: codescripts_test.go's shared tests call warmStoredNames and quiesceIndexRebuilds, but those helpers existed only in the unix-tagged and windows-tagged test files, and open_fifo_unix_test.go was tagged !windows despite using syscall.Mkfifo (Unix-only). Added fallback_other_test.go (!unix && !windows) with trivial helpers matching the unix/windows ones' signatures, and retagged open_fifo_unix_test.go to unix. Verified: GOOS=windows/plan9(amd64)/js(wasm) go test -c -o /dev/null all compile; go vet clean on darwin/linux/freebsd/windows; both editions build; native darwin go test -race -shuffle=on and non-root golang:1.26 Docker go test -race -count=2 both green for ./internal/codescripts/; full ./internal/server/... suite (skip regex applied) green. gofmt clean; no tool-surface goldens touched. Final self-review sweep of all H0 commits found no further high-confidence issues. Co-Authored-By: Claude Sonnet 5 --- internal/codescripts/entryname_windows.go | 20 +++-- .../codescripts/entryname_windows_test.go | 82 +++++++++++++++++++ internal/codescripts/fallback_other_test.go | 32 ++++++++ internal/codescripts/open_fifo_unix_test.go | 2 +- 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 internal/codescripts/entryname_windows_test.go create mode 100644 internal/codescripts/fallback_other_test.go diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go index c0c2caafd..581af1bf2 100644 --- a/internal/codescripts/entryname_windows.go +++ b/internal/codescripts/entryname_windows.go @@ -44,6 +44,13 @@ func openedBaseName(f *os.File) (string, error) { return filepath.Base(full), nil } +// getFinalPathNameByHandle is windows.GetFinalPathNameByHandle as a seam: +// entryname_windows_test.go replaces it to drive the retry logic in +// finalPathOfHandle at exact buffer-size boundaries, which no real handle +// can be made to hit deterministically (it would need a path whose +// normalized UTF-16 length is exactly 1024 units). +var getFinalPathNameByHandle = windows.GetFinalPathNameByHandle + // finalPathOfHandle is the shared GetFinalPathNameByHandle call: the // normalized path NTFS actually resolved a handle to, unlike the path that // was requested, which merely echoes what was asked for. @@ -51,15 +58,18 @@ func finalPathOfHandle(h windows.Handle) (string, error) { flags := uint32(winFileNameNormalized | winVolumeNameDOS) buf := make([]uint16, 1024) - n, err := windows.GetFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) + n, err := getFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) if err != nil { return "", err } - if int(n) > len(buf) { - // The path did not fit; n is the required length (including the - // terminator) and the call did not error, so retry once at that size. + if int(n) >= len(buf) { + // The path did not fit; when the buffer was too small, n is the + // required length INCLUDING the terminator and the call does not + // error, so n == len(buf) also means truncation (an exact-length + // path leaves no room for the terminator), not only n > len(buf). + // Retry once at that size. buf = make([]uint16, n) - n, err = windows.GetFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) + n, err = getFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) if err != nil { return "", err } diff --git a/internal/codescripts/entryname_windows_test.go b/internal/codescripts/entryname_windows_test.go new file mode 100644 index 000000000..3a3c395f0 --- /dev/null +++ b/internal/codescripts/entryname_windows_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package codescripts + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// writeUTF16Content fills filePath (a buffer of filePathSize UTF-16 units, as +// GetFinalPathNameByHandle receives it) with n placeholder characters, +// simulating what a real call writes into the caller's buffer on success. +func writeUTF16Content(filePath *uint16, filePathSize, n uint32) { + if n == 0 { + return + } + out := unsafe.Slice(filePath, filePathSize) + for i := uint32(0); i < n && i < filePathSize; i++ { + out[i] = 'a' + uint16(i%26) + } +} + +// TestFinalPathOfHandle_RetriesAtBufferSizeBoundary pins the round-14 fix: +// GetFinalPathNameByHandle's returned size (n) INCLUDES the null terminator +// when the initial 1024-unit buffer was too small, so a path whose resolved +// length makes the first call report n == len(buf) (not just n > len(buf)) +// must also retry — a buffer that fits exactly leaves no room for that +// terminator. Before the fix, that boundary case fell through the `n > +// len(buf)` check and returned a truncated/unspecified result instead of +// retrying at the reported size. +func TestFinalPathOfHandle_RetriesAtBufferSizeBoundary(t *testing.T) { + const initialBufLen = 1024 // mirrors finalPathOfHandle's fixed initial buffer size + + cases := []struct { + name string + firstN uint32 // what the first GetFinalPathNameByHandle call reports + expectCalls int + }{ + {"one under the initial buffer size — succeeds on the first call", initialBufLen - 1, 1}, + {"exactly the initial buffer size — must retry (the fixed off-by-one)", initialBufLen, 2}, + {"one over the initial buffer size — already retried before the fix", initialBufLen + 1, 2}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + if calls == 1 { + if tc.firstN < initialBufLen { + // Succeeds on the first try: the buffer already held + // the whole string, so the real call would have + // written it and returned the string's length + // (excluding the terminator). + writeUTF16Content(filePath, filePathSize, tc.firstN) + return tc.firstN, nil + } + // Too small: Win32 reports the required size, including + // the terminator, and writes nothing usable. + return tc.firstN, nil + } + // Retry: finalPathOfHandle must size the new buffer to + // exactly what the first call reported. + require.Equal(t, tc.firstN, filePathSize, "retry must size the buffer to the reported n") + content := tc.firstN - 1 // the retry buffer has room for the terminator too + writeUTF16Content(filePath, filePathSize, content) + return content, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, tc.expectCalls, calls, "unexpected number of GetFinalPathNameByHandle calls") + assert.NotEmpty(t, got, "must have read back the resolved path") + }) + } +} diff --git a/internal/codescripts/fallback_other_test.go b/internal/codescripts/fallback_other_test.go new file mode 100644 index 000000000..66c054767 --- /dev/null +++ b/internal/codescripts/fallback_other_test.go @@ -0,0 +1,32 @@ +//go:build !unix && !windows + +package codescripts + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Round 14 SHOULD (codex r11 #2): the shared tests in codescripts_test.go +// call warmStoredNames and quiesceIndexRebuilds unconditionally +// (TestResolveScoped_NeverReadsTheDirectory and friends), but those helpers +// previously existed only in the unix-tagged (storednames_other_test.go) and +// windows-tagged (storedspellings_probe_test.go) test files. A GOOS with +// neither tag — plan9, js/wasm, wasip1 — failed to even COMPILE its test +// binary, so fallback_other.go's fail-closed behavior (Warm as a no-op, +// storedSpellingsOf/openScriptFile always "not found") was never exercised +// there. These mirror the unix/windows helpers' names and signatures with +// the trivial bodies this platform's no-index design actually needs. + +// quiesceIndexRebuilds is a no-op here: there is no index, and therefore no +// rebuild goroutine, to wait on. +func quiesceIndexRebuilds() {} + +// warmStoredNames calls the package's real Warm, which fallback_other.go +// defines as a no-op returning nil — there is nothing to build an index +// from on a platform with no directory-descriptor primitive of its own. +func warmStoredNames(t *testing.T, dir string) { + t.Helper() + require.NoError(t, Warm(dir)) +} diff --git a/internal/codescripts/open_fifo_unix_test.go b/internal/codescripts/open_fifo_unix_test.go index 17a917aa6..dca75ed25 100644 --- a/internal/codescripts/open_fifo_unix_test.go +++ b/internal/codescripts/open_fifo_unix_test.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build unix package codescripts From b57b7f1e216f7725e3994c3406b6c05a69ac53a5 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 17 Sep 2026 21:58:19 +0300 Subject: [PATCH 20/23] test(codescripts): force the directory mtime forward in the Windows race test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor wrote a second file into the directory and expected LastWriteTime to have moved by the time verifyUnchanged re-read it, but NTFS on the CI runner did not reliably surface a distinct timestamp within the test's tight window (fatal error: "An error is expected but got nil"). Explicitly force the mtime forward with os.Chtimes, the same mitigation the unix counterpart (TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses) already uses for the identical class of coarse-clock flake — the assertion is about the recheck logic, not filesystem timestamp granularity. Co-Authored-By: Claude Sonnet 5 --- internal/codescripts/storedspellings_probe_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go index 54389d8bc..77208e8b5 100644 --- a/internal/codescripts/storedspellings_probe_test.go +++ b/internal/codescripts/storedspellings_probe_test.go @@ -4,6 +4,7 @@ package codescripts import ( "errors" + "os" "path/filepath" "testing" "time" @@ -111,8 +112,16 @@ func TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor(t *tes // The race: another entry is written into the SAME directory between the // open and the recheck, moving the directory's own generation — exactly // the round-8 lookup→open race the shared generation recheck exists to - // catch, here exercised through the Windows primitives. + // catch, here exercised through the Windows primitives. NTFS is not + // guaranteed to flush a directory's LastWriteTime to a value distinct + // from what a handle opened moments earlier already observed (CI + // runners have been seen to coalesce the two within the same 100ns + // FILETIME tick) — force it forward explicitly, the same mitigation + // the unix counterpart (TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses) + // uses, so the assertion is about the recheck logic, not filesystem + // timestamp granularity. writeScript(t, dir, "beta.js", "2") + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Second))) verifyErr := verifyUnchanged(f, "alpha.js") require.Error(t, verifyErr, "the directory's own generation moved between the open and the recheck") From fc30954f06ad22a7c5a35a4c6d8c8b2314dcc59c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Thu, 17 Sep 2026 22:22:23 +0300 Subject: [PATCH 21/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?15=20=E2=80=94=20bound=20the=20Windows=20final-path=20resize=20?= =?UTF-8?q?loop=20against=20a=20growing=20path=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit finalPathOfHandle (entryname_windows.go) retried GetFinalPathNameByHandle exactly once on an oversized-result report, then unconditionally sliced buf[:n]. If the resolved path grew again between that first oversized call and the retry (e.g. another process extends the path while the delete-shareable handle stays open), the retry's own n could again exceed the just-resized buffer, and buf[:n] panicked with a slice-bounds error. Turn the single retry into a bounded loop (finalPathNameMaxAttempts = 4), slicing buf[:n] only once a call's n actually fits the buffer it was given; exhausting the bound returns a plain error instead of slicing a stale buffer. The sole caller (winOpenedBaseName -> verifyUnchanged) already folds any non-nil error here into errSpellingUnproven, the existing non-disclosing refusal (SC-005), so no new error path needed threading through. New table test TestFinalPathOfHandle_GrowingPathDoesNotPanic covers exact-fit, one-retry, forced second growth (proves no panic and a clean error when the bound is exhausted), and growth that settles on the last permitted attempt. Left the round-12 SHOULD finding (unbounded per-rebuild index memory) untouched per maintainer directive -- accepted residual, now recorded as such in research.md's new H0 section. The round's third reported MUST-FIX (SEARCH behavior deletion in internal/index/bleve.go) does not apply to this PR: that file is untouched by this branch's diff against origin/main and already carries the underscore-segment fix on the base this branch is rebased on. Co-Authored-By: Claude Sonnet 5 --- internal/codescripts/entryname_windows.go | 47 ++++++-- .../codescripts/entryname_windows_test.go | 105 ++++++++++++++++++ specs/105-agent-scope-hardening/research.md | 4 + 3 files changed, 144 insertions(+), 12 deletions(-) diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go index 581af1bf2..46fc95a91 100644 --- a/internal/codescripts/entryname_windows.go +++ b/internal/codescripts/entryname_windows.go @@ -3,6 +3,7 @@ package codescripts import ( + "fmt" "os" "path/filepath" @@ -51,6 +52,16 @@ func openedBaseName(f *os.File) (string, error) { // normalized UTF-16 length is exactly 1024 units). var getFinalPathNameByHandle = windows.GetFinalPathNameByHandle +// finalPathNameMaxAttempts bounds finalPathOfHandle's resize-and-retry loop +// (round 15 MUST-FIX): the path GetFinalPathNameByHandle resolves a handle +// to can keep growing between calls — e.g. another process renames the +// file to a longer path while this delete-shareable handle stays open — so +// a single retry sized to one stale report can still be too small. Bounded +// rather than unbounded so a pathologically fast renamer cannot spin this +// forever; four attempts is generous headroom over the one legitimate +// undersized-then-exact-fit retry this ever needs in practice. +const finalPathNameMaxAttempts = 4 + // finalPathOfHandle is the shared GetFinalPathNameByHandle call: the // normalized path NTFS actually resolved a handle to, unlike the path that // was requested, which merely echoes what was asked for. @@ -58,21 +69,33 @@ func finalPathOfHandle(h windows.Handle) (string, error) { flags := uint32(winFileNameNormalized | winVolumeNameDOS) buf := make([]uint16, 1024) - n, err := getFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) - if err != nil { - return "", err - } - if int(n) >= len(buf) { + for attempt := 0; attempt < finalPathNameMaxAttempts; attempt++ { + n, err := getFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) + if err != nil { + return "", err + } + if int(n) < len(buf) { + // The call succeeded within this buffer — n is the resolved + // length EXCLUDING the terminator here, unlike the + // undersized-buffer case below. Only now is buf[:n] safe to + // slice. + return windows.UTF16ToString(buf[:n]), nil + } // The path did not fit; when the buffer was too small, n is the - // required length INCLUDING the terminator and the call does not + // required length INCLUDING the terminator, and the call does not // error, so n == len(buf) also means truncation (an exact-length // path leaves no room for the terminator), not only n > len(buf). - // Retry once at that size. + // Resize to exactly that reported size and retry — the resize + // itself is not the last word, because the path can have grown + // again by the time the retry lands (see finalPathNameMaxAttempts). buf = make([]uint16, n) - n, err = getFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) - if err != nil { - return "", err - } } - return windows.UTF16ToString(buf[:n]), nil + // Exhausted the bound without a call ever reporting a length that fit + // the buffer it was given: the path is growing faster than we can size + // for it (or something is persistently wrong). Return a plain error + // rather than slicing a stale/undersized buffer — the caller + // (winOpenedBaseName's verifyUnchanged) already treats any non-nil + // error here the same as a spelling mismatch, folding it into + // errSpellingUnproven, a non-disclosing refusal (SC-005). + return "", fmt.Errorf("codescripts: GetFinalPathNameByHandle did not settle within %d attempts", finalPathNameMaxAttempts) } diff --git a/internal/codescripts/entryname_windows_test.go b/internal/codescripts/entryname_windows_test.go index 3a3c395f0..b061fe315 100644 --- a/internal/codescripts/entryname_windows_test.go +++ b/internal/codescripts/entryname_windows_test.go @@ -24,6 +24,111 @@ func writeUTF16Content(filePath *uint16, filePathSize, n uint32) { } } +// TestFinalPathOfHandle_GrowingPathDoesNotPanic pins the round-15 MUST-FIX: +// a single resize-and-retry is not enough when the path GetFinalPathNameByHandle +// resolves keeps growing between calls (e.g. another process extends the +// path of a file while this delete-shareable handle stays open). Before the +// fix, a second oversized report after the one retry fell straight into +// `buf[:n]` with a buffer still sized to the FIRST report, which panics with +// a slice-bounds-out-of-range whenever the second n exceeds that stale +// length. The fix loops the resize, bounded by finalPathNameMaxAttempts, and +// only slices once a call's n actually fits the buffer it was given. +func TestFinalPathOfHandle_GrowingPathDoesNotPanic(t *testing.T) { + const initialBufLen = 1024 + + t.Run("exact-fit — first call already fits, no retry", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + const n = initialBufLen - 1 + writeUTF16Content(filePath, filePathSize, n) + return n, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.NotEmpty(t, got) + }) + + t.Run("one retry — second call's report fits the resized buffer", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + switch calls { + case 1: + // Too small: reports the required size (including the + // terminator), writes nothing usable. + return initialBufLen + 500, nil + case 2: + require.Equal(t, uint32(initialBufLen+500), filePathSize, + "retry must size the buffer to the first report") + n := filePathSize - 1 + writeUTF16Content(filePath, filePathSize, n) + return n, nil + default: + t.Fatalf("unexpected call %d", calls) + return 0, nil + } + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.NotEmpty(t, got) + }) + + t.Run("forced second growth — path keeps growing past the first retry, no panic, clean error", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + // Every call reports a size larger than the buffer it was just + // given — the pathological "path keeps growing forever" case. + // Before the fix this second growth (on what used to be the + // unconditional final slice) panicked; now it must instead loop + // up to the bound and then return an error. + return filePathSize + 100, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + require.NotPanics(t, func() { + got, err := finalPathOfHandle(windows.Handle(0)) + assert.Error(t, err, "must fail closed, not return an unproven/truncated path") + assert.Empty(t, got) + }) + assert.Equal(t, finalPathNameMaxAttempts, calls, "must stop retrying at the bound, not loop forever") + }) + + t.Run("growth settles within the bound — succeeds on a later attempt", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + if calls < finalPathNameMaxAttempts { + // Keeps growing by more than the last resize, forcing + // another loop iteration, right up to (but not exceeding) + // the bound. + return filePathSize + 10, nil + } + // Settles on the last permitted attempt. + n := filePathSize - 1 + writeUTF16Content(filePath, filePathSize, n) + return n, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, finalPathNameMaxAttempts, calls) + assert.NotEmpty(t, got) + }) +} + // TestFinalPathOfHandle_RetriesAtBufferSizeBoundary pins the round-14 fix: // GetFinalPathNameByHandle's returned size (n) INCLUDES the null terminator // when the initial 1024-unit buffer was too small, so a path whose resolved diff --git a/specs/105-agent-scope-hardening/research.md b/specs/105-agent-scope-hardening/research.md index f9650d7c5..4b989a49b 100644 --- a/specs/105-agent-scope-hardening/research.md +++ b/specs/105-agent-scope-hardening/research.md @@ -88,3 +88,7 @@ Per [prior-diff-assessment.md](prior-diff-assessment.md): port hunks 1–6 and 1 ## D14 — Test harness sequencing (astra r1 finding 14) **Decision**: PRs A–G ship **standalone** tests (the per-gap tests in tasks.md) using the shared fixtures from Phase 1 (`scope_fixture_test.go`); H1 introduces `runScopeScenario` and re-registers those scenarios by US id. No PR depends on H1. Parallel PRs B/D/E/H0 share **no function**; B and E both edit `mcp.go` in disjoint regions (5613-5690 vs 5787-5803). Same-function hotspots that force serial order: A/G in `handleCallToolVariant`, C/G in `toolVisibleToSession`, F/G in `directEntryInScope`, direct catalog construction and direct describe resolution. + +## H0 — accepted residual: unbounded per-rebuild index memory (codex r12 SHOULD, finding 2) + +**Decision**: not fixed. `internal/codescripts`'s directory-index rebuild (`dirfd_other.go`, `storednames_other.go`, `storednames_windows.go`) reads a full entry listing into a slice/map per rebuild with no per-directory size or byte cap; a scripts directory with an extreme entry count could make `Warm` or an async rebuild allocate proportionally. Left unbounded because rebuild concurrency is already capped process-wide at 2 (`maxConcurrentRebuilds`, `rebuildsemaphore.go`), the scripts directory is operator-controlled (not agent-writable), and bounding it is real scope beyond H0's SC-005 disclosure fixes. Revisit only if a real deployment reports memory pressure from this path. From b3bd1230d7b343a3ad1e4d8d68b9e595c71a4537 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 18 Sep 2026 06:09:55 +0300 Subject: [PATCH 22/23] =?UTF-8?q?fix(scope):=20PR=20H0=20review=20round=20?= =?UTF-8?q?17=20=E2=80=94=20Warm=20blocks=20on=20the=20shared=20rebuildSlo?= =?UTF-8?q?ts=20semaphore=20(FR-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warm's own synchronous, population-sized directory listing bypassed the process-wide rebuildSlots semaphore that async rebuilds already respect, so the documented "at most maxConcurrentRebuilds concurrent listings, process-wide" bound did not actually hold once more than one Warm call was in flight for different directories (e.g. two active-config-path moves each spawning their own async warmStoredScripts goroutine). Warm now acquires a rebuildSlots slot too, but with a blocking acquire (it cannot skip the work like the non-blocking async scheduler does), taken outside idx.mu so it can never hold up another goroutine. Applied identically to the Windows mirror, which shares the same semaphore. A second, lower-severity half of the same finding — an older Warm(A) can be pruned by a concurrent Warm(B) and finish rebuilding an unreachable index — is an accepted, self-healing residual (wasted work only, never stale/incorrect data) and is documented in research.md rather than fixed with more locking. Co-Authored-By: Claude Sonnet 5 --- internal/codescripts/storednames_other.go | 18 +++++ .../codescripts/storednames_other_test.go | 68 +++++++++++++++++++ internal/codescripts/storednames_windows.go | 5 ++ specs/105-agent-scope-hardening/research.md | 2 + 4 files changed, 93 insertions(+) diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go index f3eba9965..f4a61a0c9 100644 --- a/internal/codescripts/storednames_other.go +++ b/internal/codescripts/storednames_other.go @@ -349,6 +349,24 @@ func Warm(scriptsDir string) error { idx.mu.Unlock() <-landed } + // Round 17 SHOULD: Warm's own population-sized listing must count + // against the SAME process-wide rebuildSlots bound the async path + // enforces (rebuildsemaphore.go) — otherwise the documented "at most + // maxConcurrentRebuilds concurrent listings, process-wide" claim + // (research.md) does not hold once more than one Warm call is in + // flight for different directories, e.g. two active-config-path moves + // each spawning their own async `go warmStoredScripts` call + // (mcp_code_execution.go). Unlike scheduleRebuildLocked's non-blocking, + // skip-if-busy acquire, Warm BLOCKS for a slot: it cannot skip the + // work the way an async, nobody's-waiting rebuild can — the caller is + // blocked on Warm and trusts the error it returns. Acquired here, + // OUTSIDE idx.mu (already released by the loop above), so a blocked + // acquire can never hold up another goroutine that needs idx.mu to + // make progress; what frees this acquire is some OTHER rebuild in the + // process finishing and releasing its slot, which never depends on + // idx.mu or on this goroutine. + rebuildSlots <- struct{}{} + defer func() { <-rebuildSlots }() // backoffAfter is false: Warm is the server's own explicit request for a // current index (at startup, or when the active scripts directory // moves), not a request-triggered rebuild guarding against runaway diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go index ced630756..282cab69d 100644 --- a/internal/codescripts/storednames_other_test.go +++ b/internal/codescripts/storednames_other_test.go @@ -849,6 +849,74 @@ func TestStoredNames_WarmListsAfterAnInFlightRebuild(t *testing.T) { assert.Equal(t, 0, held.land()) } +// TestStoredNames_WarmBlocksOnRebuildSlots (round 17 SHOULD, finding 1): +// Warm's own synchronous, population-sized listing must count against the +// SAME process-wide rebuildSlots bound the async path enforces +// (rebuildsemaphore.go) — otherwise two concurrent Warm calls for different +// directories (e.g. two active-config-path moves, each spawning its own +// async warmStoredScripts goroutine per mcp_code_execution.go) could run an +// unbounded number of listings alongside the async rebuilds the semaphore is +// meant to cap. The semaphore's capacity is temporarily reduced to 1 (a +// seam: rebuildSlots is swapped for the test and restored on cleanup) so a +// single held listing is enough to prove the second Warm call BLOCKS on the +// slot rather than racing ahead unbounded, and unblocks once the first +// Warm's slot is released — never deadlocking, since Warm never holds +// idx.mu while blocked acquiring the slot (see Warm's own comment). +func TestStoredNames_WarmBlocksOnRebuildSlots(t *testing.T) { + quiesceIndexRebuilds() + settleStoredNamesClock(t) + origSlots := rebuildSlots + rebuildSlots = make(chan struct{}, 1) + t.Cleanup(func() { rebuildSlots = origSlots }) + + dirA := t.TempDir() + writeScript(t, dirA, "alpha.js", "1") + dirB := t.TempDir() + writeScript(t, dirB, "beta.js", "1") + + // Gate dirA's listing so the test controls exactly when its rebuild — + // and with it, the sole rebuildSlots slot — completes. + gate := make(chan struct{}) + entered := make(chan struct{}, 1) + origList := listScopedDirOnce + t.Cleanup(func() { listScopedDirOnce = origList }) + listScopedDirOnce = func(key string) (dirGeneration, dirGeneration, map[string]struct{}, error) { + if key == filepath.Clean(dirA) { + entered <- struct{}{} + <-gate + } + return origList(key) + } + + doneA := make(chan error, 1) + go func() { doneA <- Warm(dirA) }() + <-entered // dirA now holds the sole rebuildSlots slot, blocked mid-listing + + doneB := make(chan error, 1) + go func() { doneB <- Warm(dirB) }() + + // dirB's Warm claims its OWN index's building flag immediately (it does + // not contend with dirA on idx.mu — different indexes) but must block + // acquiring the shared slot, so it must not return yet. + select { + case err := <-doneB: + t.Fatalf("Warm(dirB) returned (err=%v) while the sole rebuildSlots slot was held by dirA's in-flight rebuild — the semaphore did not bound it", err) + case <-time.After(100 * time.Millisecond): + } + idxB := storedNamesIndex(filepath.Clean(dirB)) + idxB.mu.Lock() + buildingB := idxB.building + idxB.mu.Unlock() + assert.True(t, buildingB, "dirB's Warm has claimed its own index's building flag while waiting on the slot") + + close(gate) // release dirA's listing; its slot frees once its Warm returns + require.NoError(t, <-doneA) + require.NoError(t, <-doneB, "dirB's Warm proceeds once dirA's slot is released") + + namesB := lookupStoredNamesForTest(t, dirB) + assert.Contains(t, namesB, "beta.js", "dirB's rebuild ran and landed once it finally acquired the slot") +} + // TestStoredNames_UnlistableDirectoryRefusesScopedCallers: a scripts // directory the process cannot read is refused with the non-disclosing // unreadable form — no path, no OS error — on the very first request, cold diff --git a/internal/codescripts/storednames_windows.go b/internal/codescripts/storednames_windows.go index 23b6fd5cf..ca037c56d 100644 --- a/internal/codescripts/storednames_windows.go +++ b/internal/codescripts/storednames_windows.go @@ -218,6 +218,11 @@ func Warm(scriptsDir string) error { idx.mu.Unlock() <-landed } + // Round 17 SHOULD: same process-wide rebuildSlots bound as the unix + // Warm (storednames_other.go) — see there for the full rationale. + // Acquired here, OUTSIDE idx.mu, already released by the loop above. + rebuildSlots <- struct{}{} + defer func() { <-rebuildSlots }() idx.wg.Add(1) idx.rebuild(key, false, false) idx.mu.Lock() diff --git a/specs/105-agent-scope-hardening/research.md b/specs/105-agent-scope-hardening/research.md index 4b989a49b..de9723d73 100644 --- a/specs/105-agent-scope-hardening/research.md +++ b/specs/105-agent-scope-hardening/research.md @@ -92,3 +92,5 @@ Per [prior-diff-assessment.md](prior-diff-assessment.md): port hunks 1–6 and 1 ## H0 — accepted residual: unbounded per-rebuild index memory (codex r12 SHOULD, finding 2) **Decision**: not fixed. `internal/codescripts`'s directory-index rebuild (`dirfd_other.go`, `storednames_other.go`, `storednames_windows.go`) reads a full entry listing into a slice/map per rebuild with no per-directory size or byte cap; a scripts directory with an extreme entry count could make `Warm` or an async rebuild allocate proportionally. Left unbounded because rebuild concurrency is already capped process-wide at 2 (`maxConcurrentRebuilds`, `rebuildsemaphore.go`), the scripts directory is operator-controlled (not agent-writable), and bounding it is real scope beyond H0's SC-005 disclosure fixes. Revisit only if a real deployment reports memory pressure from this path. + +Round 17 SHOULD (codex r16, finding 1) also noted that `Warm` now shares `rebuildSlots` with the async path, so an older, still-uncancelled `Warm(A)` can occasionally finish rebuilding an index that a newer, concurrent `Warm(B)` has already pruned from `storedIndexes` (`pruneOtherIndexesLocked`) — an accepted residual, not fixed: the wasted listing installs into an index nothing can reach any more (`A`'s key is gone from the map, so no request or later `Warm(A)` sees it), the process simply pays for a listing it throws away, and the very next request or `Warm` call against `A` triggers a fresh rebuild against the current directory state — never serving stale or incorrect data, only wasting one rebuild's worth of work. From 1f352c234826080c11fce6c2fa3578456461d111 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 18 Sep 2026 21:39:55 +0300 Subject: [PATCH 23/23] fix(scope): LanguageMismatchError was the one refusal that ignored disclose (FR-012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex gpt-5.6-sol cross-model review round 1 (post-main-merge) found that resolve()'s LanguageMismatchError branch was the only refusal in codescripts.go that never checked the disclose flag: NotFoundError, AmbiguousError and InvalidError all thread it through to their own NonDisclosing() form, but an explicit `language` argument that contradicts a found script's real extension went straight to the administrator-detail error unconditionally. A scoped caller who always sends a language no real script could satisfy could use the resulting type split (LanguageMismatchError vs the non-disclosing NotFoundError) as a per-guess exists/not-exists oracle, cheaper and side-effect-free compared to the already-accepted probe of trying to execute a guessed name. Fix mirrors the existing AmbiguousError/InvalidError pattern exactly: add Undisclosed + NonDisclosing() to LanguageMismatchError (withholding the host filesystem facts — Extension, Derived — while keeping the caller's own Requested input and the distinct INVALID_LANGUAGE classification, consistent with how Ambiguous/Invalid keep their own SCRIPT_UNUSABLE classification rather than collapsing into SCRIPT_NOT_FOUND), and gate the resolve() call site on `disclose` the same way the sibling branches already are. Other review findings evaluated and not actioned in this PR: - tool-calls activity log exposing code_execution Arguments to a "*"-scoped token (internal/runtime/runtime.go) — pre-existing, outside files this PR touches, and the REST management API is explicitly out of scope for Spec 105 (spec.md "Transport and caller scope"; tracked separately as the #1166 entitlement follow-up already named in GetToolCalls's own doc comment). - anonymous /mcp caller under the default require_mcp_auth=false reaching the administrator path — the named, spec-blessed FR-001 caller-kind-first exception (spec.md amendment 6, SC-005), matching this repo's documented security model for the /mcp surface generally, not something this PR introduces. - storednames rebuild scheduling as a cache-timing side channel — real (probeCandidates alone uses the index; the administrator path still reads the directory directly) but only signals "the directory changed recently," not which names exist; consistent with the accepted residuals research.md already documents for this same subsystem (rebuildSlots contention, Warm/prune race). Co-Authored-By: Claude Sonnet 5 --- internal/codescripts/codescripts.go | 33 ++++++++++++++++++++++++ internal/codescripts/codescripts_test.go | 30 +++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index e3fe7faba..a2b89fa1b 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -267,14 +267,41 @@ func (e *InvalidError) Error() string { // LanguageMismatchError reports an explicit `language` that contradicts the // script's extension (the extension is authoritative). +// +// Extension and Derived are host filesystem facts about the script (its real +// extension is what a directory listing would show), so — like AmbiguousError +// and InvalidError — the scoped form withholds them: see NonDisclosing(). Every +// other refusal `resolve` can return (NotFoundError, AmbiguousError, +// InvalidError) already threads the `disclose` flag through to its own +// NonDisclosing() form; this type was the one omission, always returning the +// full administrator detail regardless of caller kind. type LanguageMismatchError struct { Name string Extension string Requested string Derived string + + // Undisclosed marks the agent-token form: the message names the caller's + // own requested language (its own input, not host information) but + // withholds the script's actual extension and derived language — host + // filesystem facts a directory listing would show (Spec 105 FR-012). + Undisclosed bool +} + +// NonDisclosing returns a copy stripped of the extension/derived language +// details, for delivery to a scoped (agent-token) caller. The typed identity +// is preserved, so the REST surface still classifies it as INVALID_LANGUAGE — +// same as AmbiguousError/InvalidError keeping their own distinct classification +// as SCRIPT_UNUSABLE rather than being folded into SCRIPT_NOT_FOUND. +func (e *LanguageMismatchError) NonDisclosing() *LanguageMismatchError { + return &LanguageMismatchError{Name: e.Name, Requested: e.Requested, Undisclosed: true} } func (e *LanguageMismatchError) Error() string { + if e.Undisclosed { + return fmt.Sprintf("stored script %q does not accept requested language %q — omit 'language' and let it be derived automatically", + e.Name, e.Requested) + } return fmt.Sprintf("stored script %q is a %s file (%s) but language %q was requested — omit 'language' or set it to %q", e.Name, e.Extension, e.Derived, e.Requested, e.Derived) } @@ -419,6 +446,12 @@ func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source [ path := found[0] lang, err := DeriveLanguage(name, filepath.Ext(path), explicitLanguage) if err != nil { + if !disclose { + var mismatch *LanguageMismatchError + if errors.As(err, &mismatch) { + return nil, "", mismatch.NonDisclosing() + } + } return nil, "", err } diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index 28cf2519e..b0ecdacb3 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -526,6 +526,36 @@ func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { assert.Contains(t, adminErr.Error(), dir) assert.Contains(t, adminErr.Error(), "permission denied", "the administrator keeps the OS error") }) + + // This is the one refusal `resolve` returns without ever consulting + // `disclose` before the fix: DeriveLanguage's *LanguageMismatchError went + // straight out unconditionally, so a scoped caller received the same + // Extension/Derived detail an administrator does — and, because the + // error's TYPE differs from the non-disclosing NotFoundError's, a caller + // who always sends an explicit language no real script could have could + // use the type split alone as a found/not-found oracle per guessed name + // (a codex round-1 review finding on PR H0's merge with main). + t.Run("language mismatch", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "typed.ts", "1") + warmStoredNames(t, dir) + + _, _, err := ResolveScoped(dir, "typed", LanguageJavaScript) + var mismatch *LanguageMismatchError + require.True(t, errors.As(fmt.Errorf("wrap: %w", err), &mismatch), "want *LanguageMismatchError, got %T: %v", err, err) + assert.True(t, mismatch.Undisclosed) + assert.Empty(t, mismatch.Extension, "the scoped form withholds the real extension") + assert.Empty(t, mismatch.Derived, "the scoped form withholds the derived language") + assert.Equal(t, LanguageJavaScript, mismatch.Requested, "the caller's own input is not host information") + assert.NotContains(t, err.Error(), extTS) + assert.NotContains(t, err.Error(), LanguageTypeScript) + + _, _, adminErr := Resolve(dir, "typed", LanguageJavaScript) + var adminMismatch *LanguageMismatchError + require.True(t, errors.As(adminErr, &adminMismatch)) + assert.Equal(t, extTS, adminMismatch.Extension, "the administrator keeps the extension") + assert.Equal(t, LanguageTypeScript, adminMismatch.Derived, "the administrator keeps the derived language") + }) } func TestResolve_Ambiguous(t *testing.T) {