diff --git a/AGENTS.md b/AGENTS.md index 351b32e..7b6d8be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,14 @@ Three safety features layer on top (see [`docs/safety-emergency-stop-and-provisi as never-a-target. Do not weaken any of the three, do not persist grants, and do not replace the dialog with MCP elicitation (that routes consent through the agent's own client; the party being constrained). Grants land on the key `CommandInvoker.ResolveGateKey` resolves (the entry Enqueue - actually gates on); route any new name-resolution through that one resolver. + actually gates on); route any new name-resolution through that one resolver. To spare the agent + discovering the gated set one `command-disabled` at a time (#324), the `initialize` result carries a + `commandAccess` map (`enabledTools`/`gatedTools`/`enabledRawCommands`) derived from + `ToolCatalog.CommandAccessDefaults` (the `ProvisionedByDefault` flags — single source of truth, so it + never drifts as defaults change), and `session-status` stamps the LIVE table via + `AgentToolExecutor.LiveCommandAccess`; keep both derived, never a hand-typed list. `enabledRawCommands` + is an explicit list, not a canary boolean, so a partial raw grant (only `chars:`) can't read as the whole + raw `send_command` surface being open (#340 CR). ## Dogfood: test MCEC using MCEC (mcec drives mcec) diff --git a/docs/agent_control.md b/docs/agent_control.md index 5c48552..b981bb1 100644 --- a/docs/agent_control.md +++ b/docs/agent_control.md @@ -116,7 +116,13 @@ command whose `Enabled=false` is refused (`error.code: command-disabled`) even w `AgentCommandsEnabled=true`. An agent can recover from `command-disabled` mid-session with the `request-command-access` tool, which asks **you** on-screen and enables the command (in-memory, that instance only) only if you allow it; see -[command-access consent](safety-emergency-stop-and-provisioning.md). +[command-access consent](safety-emergency-stop-and-provisioning.md). So the agent need not discover the +gated set by trial and error, the MCP `initialize` result carries a `commandAccess` map +(`enabledTools`/`gatedTools`/`enabledRawCommands`) derived from the tool catalog's provisioning defaults, +and `session-status` reports the same shape live; an agent reads it at connect time and batches one +`request-command-access` for everything it needs up front (#324). `enabledRawCommands` is an explicit list +(not a boolean), so a partial raw grant — say the operator enables only `chars:` — reports exactly that +command and never implies `winr`/`mouse:`/VK/chord commands are open too. **`send_command` is transport-sensitive.** It is a raw pass-through to the existing command engine, so it is a command-injection surface. Over the **local stdio** transport (`mcec.exe --mcp`, launched by its @@ -287,7 +293,9 @@ tools: echoed back on the result. - **`session-status`** returns a session's remembered state (active target, last observation/action/error, artifact dir, any emergency stop). Pass `sessionId` to inspect a - specific session, or omit it for the default. + specific session, or omit it for the default. Its result also carries a live `commandAccess` + block (`enabledTools`/`gatedTools`/`enabledRawCommands`) reflecting the current command table, so + an agent can confirm a grant landed (see [Command access](#command-access)). - **`session-end`** frees a session's server-side state. It is idempotent (ending an unknown or already-ended id reports `ended: false` rather than erroring). Afterward a tool call that still echoes that id is refused with `error.code: unknown-session` (category `invalid-argument`); start diff --git a/src/Agent/AgentInstructions.md b/src/Agent/AgentInstructions.md index 4b9a622..52e2cbb 100644 --- a/src/Agent/AgentInstructions.md +++ b/src/Agent/AgentInstructions.md @@ -202,8 +202,10 @@ Recommended path: the operator enables "Allow agents to provision disposable ins > Agent, clicks "Provision new…", and hands you a disposable instance's directory, launch line, and token; connect to THAT copy's `mcec.exe --mcp` (or its HTTP endpoint when enabled) and do all work there. A provisioned session enables the standard observation/actuation tool set; `launch` and most -`send_command` built-ins (e.g. `chars:`) start disabled — batch `request-command-access` for what you'll -need up front (see COMMAND ACCESS), never by editing the session's files. The `token` is the +`send_command` built-ins (e.g. `chars:`) start disabled — the `initialize` result's `commandAccess` field +spells out the enabled vs gated set (and flags that raw `send_command` built-ins start gated), so read it +and batch `request-command-access` for what you'll need up front (see COMMAND ACCESS), never by editing the +session's files. The `token` is the session credential; keep it: every HTTP request to the session's `mcpEndpoint` must send the header `Authorization: Bearer ` (stdio needs no header), and `end-session` requires it when you tear down via the bootstrap server. When your task is done, tell the operator (they delete the instance from the Agent tab; you cannot remove @@ -223,7 +225,13 @@ while `error.category` stays `internal`), the operator has not opted in; tell th on the Agent tab, then retry; do not retry blindly before they do. You own teardown: `end-session` every instance you provision. -COMMAND ACCESS: any tool or raw command refused with `error.code:command-disabled` can be requested from +COMMAND ACCESS: you do not have to discover the gated set by trial and error. The `initialize` result's +`commandAccess` field lists `enabledTools` vs `gatedTools` and `enabledRawCommands` (the raw `send_command` +built-ins enabled right now; empty until granted) at connect time — and `session-status` reports the LIVE +set, reflecting any grant already made — so read it and batch one request for everything your plan needs +before the first actuation. A raw command NOT in `enabledRawCommands` still needs a request; a partial grant +does not open the whole raw surface. Any tool or raw command refused with +`error.code:command-disabled` can be requested from the OPERATOR with the `request-command-access` tool: pass the command name(s) the refusal reported (e.g. `launch`, `chars:`) and a one-line, honest `reason`; MCEC shows the operator a consent dialog on their screen and your call BLOCKS (up to ~2 minutes) for their answer. On a grant the commands are immediately diff --git a/src/Agent/ToolCatalog.cs b/src/Agent/ToolCatalog.cs index 8e48741..06356e8 100644 --- a/src/Agent/ToolCatalog.cs +++ b/src/Agent/ToolCatalog.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json.Nodes; namespace MCEControl; @@ -43,6 +44,58 @@ public static bool TryGet(string name, out ToolDescriptor descriptor) { return false; } + /// + /// The one-line recovery note shared by every commandAccess block (#324): how to turn a gated + /// command into an enabled one. Held here (not typed into each surface) so the guidance can never drift + /// between initialize and session-status. + /// + internal const string CommandAccessNote = + "Gated commands need the operator's consent before first use: call request-command-access with the " + + "names you need, batched in ONE call, then actuate. Raw send_command built-ins (chars:, winr, mouse:, " + + "VK_* keys, key chords) start gated and are requested the same way; enabledRawCommands lists the ones " + + "enabled RIGHT NOW (empty until granted), and any raw command not in that list still needs a request " + + "— a partial grant does NOT open the whole raw surface. Never edit mcec.commands / mcec.settings to " + + "self-grant."; + + /// + /// The connect-time command-access map (#324): which agent tools a freshly provisioned session enables + /// vs leaves gated, derived from so it can never drift + /// from the actual provisioning defaults (today only launch is gated). Served on the MCP + /// initialize result so an agent can batch a single request-command-access for everything it + /// needs before its first actuation, instead of discovering the gated set one command-disabled + /// refusal at a time. No raw send_command built-in is provisioned by default, so + /// enabledRawCommands is empty here; reports the + /// live table (which raw commands a grant has since enabled) for a re-check. + /// + public static JsonObject CommandAccessDefaults() => CommandAccessMap( + enabledTools: All.Where(d => d.ProvisionedByDefault).Select(d => d.Name), + gatedTools: All.Where(d => !d.ProvisionedByDefault).Select(d => d.Name), + enabledRawCommands: []); + + /// + /// Builds the commandAccess block shared by initialize (the provisioning defaults) and + /// session-status (the live table): the enabled/gated tool split, the list of enabled raw + /// send_command built-ins, and the shared recovery . One builder so + /// both surfaces render the exact same shape and guidance. is an + /// explicit list (not a boolean) so a PARTIAL raw grant — e.g. the operator enabled only chars: — + /// reports exactly that one and never implies winr/mouse:/VK/chord commands are open too + /// (#340 CR). + /// + internal static JsonObject CommandAccessMap(IEnumerable enabledTools, IEnumerable gatedTools, IEnumerable enabledRawCommands) => new() { + ["enabledTools"] = NamesArray(enabledTools), + ["gatedTools"] = NamesArray(gatedTools), + ["enabledRawCommands"] = NamesArray(enabledRawCommands), + ["note"] = CommandAccessNote, + }; + + private static JsonArray NamesArray(IEnumerable names) { + JsonArray arr = []; + foreach (string name in names) { + arr.Add(name); + } + return arr; + } + private static Dictionary BuildIndex() { Dictionary index = new(StringComparer.Ordinal); foreach (ToolDescriptor d in All) { diff --git a/src/Services/AgentToolExecutor.cs b/src/Services/AgentToolExecutor.cs index f0b1aeb..5a86e9a 100644 --- a/src/Services/AgentToolExecutor.cs +++ b/src/Services/AgentToolExecutor.cs @@ -272,7 +272,48 @@ public JsonObject CallTool(JsonObject? prms, AgentTransport transport) { private JsonObject RunSessionStart() { AgentSession session = _startSession(); AgentRuntime.Audit("session-start", session.SessionId); - return McpResult(AgentToolResult.Success(session.ToStatusJson(), session.SessionId)); + return McpResult(AgentToolResult.Success(WithCommandAccess(session.ToStatusJson()), session.SessionId)); + } + + /// + /// The LIVE command-access map (#324) for the connected instance: the same shape as + /// , but read from the actual loaded command table so it + /// reflects any command the operator has since granted (via request-command-access) as enabled. + /// Stamped onto session-start/session-status results so an agent can re-check the gated set + /// after a grant, where initialize only ever shows the provisioning defaults. A catalog tool whose + /// table entry is missing or disabled is gated; the raw send_command built-ins that are enabled + /// right now are enumerated explicitly into enabledRawCommands, so a PARTIAL grant (e.g. only + /// chars:) reports exactly that command and never implies the rest of the raw surface is open + /// (#340 CR). + /// + public JsonObject LiveCommandAccess() { + CommandInvoker? invoker = _invoker(); + List enabledTools = []; + List gatedTools = []; + foreach (ToolDescriptor descriptor in ToolCatalog.All) { + bool on = (invoker?[descriptor.Name] as Command)?.Enabled == true; + (on ? enabledTools : gatedTools).Add(descriptor.Name); + } + // The raw send_command built-ins currently enabled: every enabled table entry whose key is NOT a + // catalog tool (those are already reported above). Enumerating the actual table — instead of probing + // a single canary — is what makes a partial raw grant honest: `winr` disabled while `chars:` is + // granted shows chars: present and winr absent, so the agent knows winr still needs a request. + List enabledRawCommands = []; + if (invoker is not null) { + foreach (System.Collections.DictionaryEntry entry in invoker) { + if (entry.Key is string key && entry.Value is Command { Enabled: true } && !ToolCatalog.Contains(key)) { + enabledRawCommands.Add(key); + } + } + enabledRawCommands.Sort(StringComparer.Ordinal); + } + return ToolCatalog.CommandAccessMap(enabledTools, gatedTools, enabledRawCommands); + } + + /// Adds the live commandAccess block (#324) to a session-status/start snapshot. + private JsonObject WithCommandAccess(JsonObject status) { + status["commandAccess"] = LiveCommandAccess(); + return status; } /// @@ -289,7 +330,7 @@ private JsonObject RunSessionStatus(string? sessionId) { "unknown-session", AgentErrorCategory.InvalidArgument, sessionId!); } AgentRuntime.Audit("session-status", session.SessionId); - return McpResult(AgentToolResult.Success(session.ToStatusJson(), session.SessionId)); + return McpResult(AgentToolResult.Success(WithCommandAccess(session.ToStatusJson()), session.SessionId)); } /// diff --git a/src/Services/JsonRpcDispatcher.cs b/src/Services/JsonRpcDispatcher.cs index fcb5a5c..bb77786 100644 --- a/src/Services/JsonRpcDispatcher.cs +++ b/src/Services/JsonRpcDispatcher.cs @@ -87,18 +87,28 @@ public JsonRpcDispatcher(AgentToolExecutor executor, Func instructions) "the full connect-time instructions. When finished, stop the instance's mcec.exe, then call " + "end-session here with the sessionId AND token to delete it."; - private JsonObject BuildInitializeResult() => new() { - ["protocolVersion"] = ProtocolVersion, - ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() }, - ["serverInfo"] = new JsonObject { - ["name"] = "MCEC", - ["version"] = Application.ProductVersion, - }, - // Built-in agent guidance: surfaced to the model by the MCP client so it knows how to drive - // MCEC effectively (the observe -> target -> act loop) and understands the security model. In - // bootstrap mode (#296) the playbook would teach refused tools, so serve the handoff recipe. - ["instructions"] = Program.ProvisioningBootstrapOnly ? BootstrapInstructions : _instructions(), - }; + private JsonObject BuildInitializeResult() { + JsonObject result = new() { + ["protocolVersion"] = ProtocolVersion, + ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() }, + ["serverInfo"] = new JsonObject { + ["name"] = "MCEC", + ["version"] = Application.ProductVersion, + }, + // Built-in agent guidance: surfaced to the model by the MCP client so it knows how to drive + // MCEC effectively (the observe -> target -> act loop) and understands the security model. In + // bootstrap mode (#296) the playbook would teach refused tools, so serve the handoff recipe. + ["instructions"] = Program.ProvisioningBootstrapOnly ? BootstrapInstructions : _instructions(), + }; + // #324: the connect-time command-access map — which tools a provisioned session enables vs gates + // (launch + raw send_command built-ins start gated), derived from the ToolCatalog so an agent can + // batch ONE request-command-access up front instead of probing command-disabled one call at a time. + // Omitted in bootstrap mode, whose only tools are provision-session/end-session (no gated surface). + if (!Program.ProvisioningBootstrapOnly) { + result["commandAccess"] = ToolCatalog.CommandAccessDefaults(); + } + return result; + } // ------------------------------------------------------------------------------------------- // tools/list diff --git a/tests/MCEControl.xUnit/Services/AgentServerTests.cs b/tests/MCEControl.xUnit/Services/AgentServerTests.cs index e10c266..03bb7fc 100644 --- a/tests/MCEControl.xUnit/Services/AgentServerTests.cs +++ b/tests/MCEControl.xUnit/Services/AgentServerTests.cs @@ -74,6 +74,42 @@ public void Dispatch_Initialize_IncludesTheInstructions() { Assert.Equal(AgentServer.Instructions, result["instructions"]!.GetValue()); } + [Fact] + public void Dispatch_Initialize_IncludesCommandAccessDefaults() { + // #324: connect-time discovery of the gated set, so an agent can batch one request-command-access + // instead of probing command-disabled per command. The map is derived from the ToolCatalog's + // ProvisionedByDefault flags (single source of truth), so today only `launch` is gated among tools. + JsonObject result = AgentServer.Dispatch(Request(1, "initialize"))!["result"]!.AsObject(); + JsonObject access = result["commandAccess"]!.AsObject(); + + List enabled = Names(access["enabledTools"]!.AsArray()); + List gated = Names(access["gatedTools"]!.AsArray()); + Assert.Contains("capture", enabled); + Assert.Contains("invoke", enabled); + Assert.Contains("launch", gated); + Assert.DoesNotContain("launch", enabled); + // No raw send_command built-in (chars:, winr, …) is provisioned by default, so the enabled-raw list + // is empty at connect time. + Assert.Empty(Names(access["enabledRawCommands"]!.AsArray())); + Assert.False(string.IsNullOrWhiteSpace(access["note"]!.GetValue())); + + // Every gated tool the map names is a real advertised tool the agent could request (#324): the + // list must be derived, never a hand-typed set that could name a tool that no longer exists. + foreach (string name in gated) { + Assert.True(ToolCatalog.Contains(name), $"gated tool '{name}' should be a real catalog tool"); + } + } + + private static List Names(JsonArray arr) { + List names = []; + foreach (JsonNode? n in arr) { + if (n?.GetValue() is { } s) { + names.Add(s); + } + } + return names; + } + [Fact] public void StdioLoop_DispatchesRequestsConcurrently_NotOneAtATime() { // #113: the stdio transport must dispatch each request on its own worker, or a slow call blocks @@ -847,6 +883,49 @@ public void Dispatch_SessionStatus_ReturnsSessionState() { } } + [Fact] + public void Dispatch_SessionStatus_IncludesLiveCommandAccess_ReflectingTheTable() { + // #324: unlike initialize (the provisioning DEFAULTS), session-status reports the LIVE table, so a + // command the operator has since granted shows as enabled. Here `launch` is enabled in the table (a + // grant would look the same), so it must appear enabled and NOT gated; `invoke` absent from the table + // stays gated. + // + // #340 CR: `chars:` is granted but `mouse:` is not. A partial raw grant must report ONLY chars: in + // enabledRawCommands — never imply the whole raw send_command surface (mouse:, winr, …) is open, which + // a single canary boolean did. `launch` is a catalog tool, so it belongs in enabledTools, NOT the raw + // list. + AgentTestSupport.EnsureTelemetry(); + AgentRuntime.Settings = new AppSettings { AgentCommandsEnabled = true }; + AgentRuntime.ArtifactRoot = Path.Combine(Path.GetTempPath(), "mcec-session-test", Path.GetRandomFileName()); + AgentRuntime.ResetSession(); + AgentRuntime.Invoker = new CommandInvoker { + ["launch"] = new LaunchCommand { Cmd = "launch", Enabled = true }, + ["chars:"] = new CharsCommand { Cmd = "chars:", Enabled = true }, + ["mouse:"] = new MouseCommand { Cmd = "mouse:", Enabled = false }, + }; + try { + JsonObject access = CallEnvelope(55, "session-status")["result"]!.AsObject()["commandAccess"]!.AsObject(); + List enabled = Names(access["enabledTools"]!.AsArray()); + List gated = Names(access["gatedTools"]!.AsArray()); + List enabledRaw = Names(access["enabledRawCommands"]!.AsArray()); + + Assert.Contains("launch", enabled); // enabled in the table (a grant would read the same) + Assert.DoesNotContain("launch", gated); + Assert.Contains("invoke", gated); // absent from the table → gated + + // The partial raw grant is honest: chars: present, mouse: (disabled) absent, and the catalog + // launch is NOT double-reported as a raw command. + Assert.Contains("chars:", enabledRaw); + Assert.DoesNotContain("mouse:", enabledRaw); + Assert.DoesNotContain("launch", enabledRaw); + } + finally { + AgentRuntime.Settings = null; + AgentRuntime.Invoker = null; + AgentRuntime.ResetSession(); + } + } + [Fact] public void Dispatch_SessionEnd_RemovesSession_ThenRoutingItIsUnknown_AndReEndIsIdempotent() { AgentTestSupport.EnsureTelemetry();