Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 10 additions & 2 deletions docs/agent_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions src/Agent/AgentInstructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` (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
Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions src/Agent/ToolCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;

namespace MCEControl;
Expand Down Expand Up @@ -43,6 +44,58 @@ public static bool TryGet(string name, out ToolDescriptor descriptor) {
return false;
}

/// <summary>
/// The one-line recovery note shared by every <c>commandAccess</c> 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 <c>initialize</c> and <c>session-status</c>.
/// </summary>
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.";

/// <summary>
/// The connect-time command-access map (#324): which agent tools a freshly provisioned session enables
/// vs leaves gated, derived from <see cref="ToolDescriptor.ProvisionedByDefault"/> so it can never drift
/// from the actual provisioning defaults (today only <c>launch</c> is gated). Served on the MCP
/// <c>initialize</c> result so an agent can batch a single <c>request-command-access</c> for everything it
/// needs before its first actuation, instead of discovering the gated set one <c>command-disabled</c>
/// refusal at a time. No raw <c>send_command</c> built-in is provisioned by default, so
/// <c>enabledRawCommands</c> is empty here; <see cref="AgentToolExecutor.LiveCommandAccess"/> reports the
/// live table (which raw commands a grant has since enabled) for a re-check.
/// </summary>
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: []);

/// <summary>
/// Builds the <c>commandAccess</c> block shared by <c>initialize</c> (the provisioning defaults) and
/// <c>session-status</c> (the live table): the enabled/gated tool split, the list of enabled raw
/// <c>send_command</c> built-ins, and the shared recovery <see cref="CommandAccessNote"/>. One builder so
/// both surfaces render the exact same shape and guidance. <paramref name="enabledRawCommands"/> is an
/// explicit list (not a boolean) so a PARTIAL raw grant — e.g. the operator enabled only <c>chars:</c> —
/// reports exactly that one and never implies <c>winr</c>/<c>mouse:</c>/VK/chord commands are open too
/// (#340 CR).
/// </summary>
internal static JsonObject CommandAccessMap(IEnumerable<string> enabledTools, IEnumerable<string> gatedTools, IEnumerable<string> enabledRawCommands) => new() {
["enabledTools"] = NamesArray(enabledTools),
["gatedTools"] = NamesArray(gatedTools),
["enabledRawCommands"] = NamesArray(enabledRawCommands),
["note"] = CommandAccessNote,
};

private static JsonArray NamesArray(IEnumerable<string> names) {
JsonArray arr = [];
foreach (string name in names) {
arr.Add(name);
}
return arr;
}

private static Dictionary<string, ToolDescriptor> BuildIndex() {
Dictionary<string, ToolDescriptor> index = new(StringComparer.Ordinal);
foreach (ToolDescriptor d in All) {
Expand Down
45 changes: 43 additions & 2 deletions src/Services/AgentToolExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>
/// The LIVE command-access map (#324) for the connected instance: the same shape as
/// <see cref="ToolCatalog.CommandAccessDefaults"/>, but read from the actual loaded command table so it
/// reflects any command the operator has since granted (via <c>request-command-access</c>) as enabled.
/// Stamped onto <c>session-start</c>/<c>session-status</c> results so an agent can re-check the gated set
/// after a grant, where <c>initialize</c> only ever shows the provisioning defaults. A catalog tool whose
/// table entry is missing or disabled is gated; the raw <c>send_command</c> built-ins that are enabled
/// right now are enumerated explicitly into <c>enabledRawCommands</c>, so a PARTIAL grant (e.g. only
/// <c>chars:</c>) reports exactly that command and never implies the rest of the raw surface is open
/// (#340 CR).
/// </summary>
public JsonObject LiveCommandAccess() {
CommandInvoker? invoker = _invoker();
List<string> enabledTools = [];
List<string> 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<string> 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);
}

/// <summary>Adds the live <c>commandAccess</c> block (#324) to a session-status/start snapshot.</summary>
private JsonObject WithCommandAccess(JsonObject status) {
status["commandAccess"] = LiveCommandAccess();
return status;
}

/// <summary>
Expand All @@ -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));
}

/// <summary>
Expand Down
34 changes: 22 additions & 12 deletions src/Services/JsonRpcDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,28 @@ public JsonRpcDispatcher(AgentToolExecutor executor, Func<string> 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
Expand Down
Loading
Loading