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
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using CodeSpace.Messages.Agents;

namespace CodeSpace.Core.Services.Agents;

/// <summary>
/// The calling run's hold on a repository its tool call names (<see cref="AgentRunPosture.Repositories"/>). Team scope
/// alone let an agent reach every repository of its team at any ref it chose; the run's own binding is narrower. A
/// repository outside it gets the "not found" a missing or foreign one gets, so a refusal never confirms that a guessed
/// id exists. A writable repository is the run's own to work in, at any ref. Read-only context is something the run
/// reads: a command checks out only its bound branch or its default branch, and an agent writes nothing to it — no pull
/// request opened, merged, reviewed or commented on. Pure — consulted by <c>NodeAgentTool</c> for every
/// manifest-declared repository input and by <c>RunCommandService</c> for an agent's command.
///
/// <para>The ref pin holds what a command CHECKS OUT, so the working tree an agent builds and runs from read-only
/// context is the content the operator bound, never a branch it named. It does not hide what the provider publishes
/// about a bound repository: its pull requests — their list, diffs and checks — are readable through the git read tools
/// like the rest of the repository, whatever ref it is bound at. A repository whose open pull requests must stay out of
/// a run's reach is one not to bind to it.</para>
/// </summary>
public static class AgentRepositoryBinding
{
/// <summary>
/// The refusal for a repository outside the run's binding — the same answer whether the id is this team's, another
/// team's or nobody's, and byte-identical to <c>RunCommandService</c>'s own tenant-filter miss.
/// </summary>
public static string NotFound(Guid repositoryId) => $"Repository {repositoryId} not found.";

/// <summary>The refusal for a pull-request write to a repository the run is bound to only as read-only context.</summary>
public static string ReadOnlyContextWrite(Guid repositoryId) =>
$"Repository {repositoryId} is bound to this run as read-only context, so an agent may not open, merge, review or comment on its pull requests.";

/// <summary>The refusal for a command checking out a ref of read-only context outside its binding.</summary>
public static string RefOutsideBinding(Guid repositoryId, WorkspaceRepositorySpec bound, string? requestedRef, string defaultBranch) =>
$"Repository {repositoryId} is bound to this run as read-only context at '{bound.Ref ?? defaultBranch}', so a command may check out only that branch or the default branch '{defaultBranch}', not '{requestedRef}'.";

/// <summary>The run's binding of <paramref name="repositoryId"/>, or null when the run is not bound to it.</summary>
public static WorkspaceRepositorySpec? Find(AgentRunPosture caller, Guid repositoryId) =>
caller.Repositories.FirstOrDefault(repository => repository.RepositoryId == repositoryId);

/// <summary>Whether an agent may write to a bound repository through its provider: only a writable one. An access this code does not know is held like read-only context.</summary>
public static bool AllowsWrite(WorkspaceRepositorySpec bound) => bound.Access == WorkspaceAccess.Write;

/// <summary>
/// Whether a command may check out <paramref name="requestedRef"/> of a bound repository: any ref of a writable one;
/// of read-only context, its bound branch (else the default branch) or the default branch — compared exactly, as git
/// names refs. A blank ref is the default branch, as the clone reads it. An access this code does not know is held
/// like read-only context.
/// </summary>
public static bool AllowsRef(WorkspaceRepositorySpec bound, string? requestedRef, string defaultBranch)
{
if (AllowsWrite(bound) || string.IsNullOrWhiteSpace(requestedRef)) return true;

return requestedRef == defaultBranch || requestedRef == (bound.Ref ?? defaultBranch);
}
}
19 changes: 16 additions & 3 deletions backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3601,9 +3601,18 @@
/// selects <see cref="McpCatalogMode.Full"/> — the whole registry incl. the side-effecting fabric, byte-identical to
/// before. OFF (the default) selects <see cref="McpCatalogMode.ReadOnly"/> — only read-only tools (e.g.
/// <c>get_context</c> + the git reads) are served, so a default run still reaches the safe read tools without
/// exposing any side effect. Pure + internal so it's unit-pinned.
/// exposing any side effect. An opted-in run whose write scope is not <see cref="AgentWriteScope.Workspace"/> — an
/// author's <c>readOnly</c>, a Confined tier, or a scope this code does not know, read as read-only like
/// <see cref="ApplyWriteScope"/> reads it — is served <see cref="McpCatalogMode.NonDestructive"/>: "analysis-only (no
/// writes)" must hold for the tools it is handed, not only for its own sandbox, yet it keeps every tool that does not
/// write, the ask (<c>decision.request</c>) among them. Pure + internal so it's unit-pinned.
/// </summary>
internal static McpCatalogMode ResolveMcpCatalogMode(AgentTask task) => UsesFullToolCatalog(task) ? McpCatalogMode.Full : McpCatalogMode.ReadOnly;
internal static McpCatalogMode ResolveMcpCatalogMode(AgentTask task)
{
if (!UsesFullToolCatalog(task)) return McpCatalogMode.ReadOnly;

return task.Permissions.WriteScope == AgentWriteScope.Workspace ? McpCatalogMode.Full : McpCatalogMode.NonDestructive;
}

/// <summary>
/// A BOOT diagnostic the worker host calls once at startup so a mis-configured tool fabric is VISIBLE at deploy time,
Expand Down Expand Up @@ -3672,9 +3681,13 @@
// tools by default and the whole fabric only when the run opted in.
var catalogMode = ResolveMcpCatalogMode(task);

// The repositories the admitted task bound the run to — the workspace it cloned — are the only ones its tool calls
// may name, stamped server-side here and never read from the model's arguments. A no-repository run binds none.
var repositories = RepositoryWorkspaceResolver.CanonicalWorkspace(task)?.Repositories ?? [];

try
{
return new AgentMcpEndpoint(runId, registry, autonomy, teamId, redactor, socketPath, token, connects, scope, ct, _logger, fenceEpoch, governanceEnabled, approvalConversationId, catalogMode, task.Permissions);
return new AgentMcpEndpoint(runId, registry, autonomy, teamId, redactor, socketPath, token, connects, scope, ct, _logger, fenceEpoch, governanceEnabled, approvalConversationId, catalogMode, task.Permissions, repositories);
}
// An over-length socket path throws ArgumentOutOfRangeException (UDS endpoint ctor); CreateDirectory can throw
// IOException / UnauthorizedAccessException. The endpoint is optional infra, not the run, so any of these is a
Expand Down Expand Up @@ -5061,10 +5074,10 @@
/// <summary>The same reconstruction from a payload that came from somewhere other than the row — an offloaded one fetched back out of the artifact store.</summary>
private static AgentEvent ReplayedEvent(AgentEventKind kind, string? text, string? dataJson)
{
if (dataJson is not { Length: > 0 } json) return new AgentEvent { Kind = kind, Text = text };

Check warning on line 5077 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

Possible null reference assignment.

Check warning on line 5077 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 5077 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 5077 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

Possible null reference assignment.

Check warning on line 5077 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.

try { using var doc = JsonDocument.Parse(json); return new AgentEvent { Kind = kind, Text = text, Data = doc.RootElement.Clone() }; }

Check warning on line 5079 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

Possible null reference assignment.

Check warning on line 5079 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 5079 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 5079 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

Possible null reference assignment.

Check warning on line 5079 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.
catch (JsonException) { return new AgentEvent { Kind = kind, Text = text }; }

Check warning on line 5080 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

Possible null reference assignment.

Check warning on line 5080 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 5080 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 5080 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

Possible null reference assignment.

Check warning on line 5080 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.
}

/// <summary>Ask the row, on a token of its own, whether the run actually reached a terminal state — the only honest answer to "did the landing take?" once an exception has been raised somewhere after the fenced write.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public async Task<SandboxResult> RunAsync(RunCommandRequest request, Cancellatio
// Repo-scoped → clone into a fresh per-run workspace the command runs in; ephemeral → no checkout.
// The same runnerKind selects the matching workspace provider, so a future docker/k8s pair composes here.
var workspace = request.RepositoryId is { } repositoryId
? await _workspaces.Resolve(runnerKind).PrepareAsync(WorkspaceProvisionRequest.FromSingle(await BuildWorkspaceRequestAsync(repositoryId, request.Ref, request.TeamId, cancellationToken).ConfigureAwait(false)), cancellationToken).ConfigureAwait(false)
? await _workspaces.Resolve(runnerKind).PrepareAsync(WorkspaceProvisionRequest.FromSingle(await BuildWorkspaceRequestAsync(repositoryId, request, cancellationToken).ConfigureAwait(false)), cancellationToken).ConfigureAwait(false)
: null;

try
Expand Down Expand Up @@ -161,14 +161,16 @@ private static SandboxSpec WithinCallerEgress(SandboxSpec spec, AgentPermissions
/// token through the same provider auth layer the resolver uses, and reuse its provider→username table so
/// there's one source of truth. A repo with no bound credential clones anonymously (public / local repo).
/// </summary>
private async Task<WorkspaceRequest> BuildWorkspaceRequestAsync(Guid repositoryId, string? gitRef, Guid? teamId, CancellationToken cancellationToken)
private async Task<WorkspaceRequest> BuildWorkspaceRequestAsync(Guid repositoryId, RunCommandRequest request, CancellationToken cancellationToken)
{
// Fail-closed tenant scope: the repo is resolved ONLY within the run's team, so a model-supplied /
// untrusted repositoryId can never clone another tenant's repo. No team context with a repo requested is
// refused outright. A repo in another team falls out of the filter → the same non-leaking "not found".
if (teamId is not { } team)
if (request.TeamId is not { } team)
throw new WorkspaceException("Cannot clone a repository without a team context for the run.");

var bound = CallerBinding(request.CallerPosture, repositoryId);

var repo = await _db.Repository
.Include(r => r.ProviderInstance)
.Include(r => r.Credential)
Expand All @@ -178,17 +180,47 @@ private async Task<WorkspaceRequest> BuildWorkspaceRequestAsync(Guid repositoryI
if (string.IsNullOrWhiteSpace(repo.CloneUrlHttps))
throw new WorkspaceException($"Repository {repositoryId} has no HTTPS clone URL to clone from.");

EnsureRefWithinBinding(bound, request.Ref, repo);

var token = await ResolveTokenAsync(repo, cancellationToken).ConfigureAwait(false);

return new WorkspaceRequest
{
RepositoryUrl = repo.CloneUrlHttps,
Ref = string.IsNullOrWhiteSpace(gitRef) ? repo.DefaultBranch : gitRef,
Ref = string.IsNullOrWhiteSpace(request.Ref) ? repo.DefaultBranch : request.Ref,
Token = token,
TokenUsername = token is null ? null : RepositoryWorkspaceResolver.TokenUsernameFor(repo.ProviderInstance.Provider),
};
}

/// <summary>
/// The calling run's binding of the repository an agent's command names — null for a workflow node's command, which
/// has no calling run and resolves its authored repository within its team as before. A repository the run is not
/// bound to is refused with the same "not found" the tenant filter gives, before it is ever loaded.
/// </summary>
private static WorkspaceRepositorySpec? CallerBinding(AgentRunPosture? caller, Guid repositoryId)
{
if (caller is null) return null;

return AgentRepositoryBinding.Find(caller, repositoryId) ?? throw new WorkspaceException(AgentRepositoryBinding.NotFound(repositoryId));
}

/// <summary>
/// An agent's command checks out read-only context only at its bound branch or its default branch, so the tree it
/// builds and runs is the content the operator bound, never a branch the agent named (what the pin does and does not
/// cover is on <see cref="AgentRepositoryBinding"/>). A refusal rather than an approval card: the binding is the
/// operator's own narrowing, which a single approver's click should not widen mid-run, and the card cannot show the
/// ref it would be consenting to. The agent tool refuses it before the call is ever parked for approval too
/// (<c>NodeAgentTool</c>); this holds a caller that reaches the service directly. A writable repository, and a
/// workflow node's command (<paramref name="bound"/> null), take any ref.
/// </summary>
private static void EnsureRefWithinBinding(WorkspaceRepositorySpec? bound, string? requestedRef, Repository repo)
{
if (bound is null || AgentRepositoryBinding.AllowsRef(bound, requestedRef, repo.DefaultBranch)) return;

throw new WorkspaceException(AgentRepositoryBinding.RefOutsideBinding(repo.Id, bound, requestedRef, repo.DefaultBranch));
}

private async Task<string?> ResolveTokenAsync(Repository repo, CancellationToken cancellationToken)
{
if (repo.Credential is null) return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ namespace CodeSpace.Core.Services.Agents.Eval.Benchmark;
/// are GENUINELY differentiated within a SINGLE process: the runner stamps the per-run opt-in
/// <c>AgentTask.EnableMcpEndpoint</c> from the mode, so the cli-mcp run opens the run-scoped MCP tool-fabric endpoint
/// while the cli run does not — they execute observably differently, not merely under different scorecard labels. The
/// resolved gate state (the SAME <c>AgentRunExecutor.UsesFullToolCatalog</c> the executor consults) is recorded on
/// resolved gate state (the SAME <c>AgentRunExecutor.ResolveMcpCatalogMode</c> the executor consults) is recorded on
/// <see cref="BenchmarkResult.McpFullCatalog"/>, so a row can never be mislabeled relative to what the executor did.
/// <see cref="BenchmarkMode.WorkflowMap"/> is RESERVED, not wired in this slice: it would run through the composed
/// planner→<c>flow.map</c>→synthesizer ENGINE path (a workflow, not a single agent run), which this single-run runner
Expand Down Expand Up @@ -67,9 +67,10 @@ public async Task<BenchmarkResult> RunAsync(BenchmarkTask task, BenchmarkMode mo

var agentTask = BuildAgentTask(task, mode, workspaceDirectory, selection);

// The SAME gate the executor will consult to decide whether to open the run's MCP endpoint — recorded on the
// result so the cli vs cli-mcp rows can never be mislabeled relative to what the run actually did.
var mcpFullCatalog = AgentRunExecutor.UsesFullToolCatalog(agentTask);
// The SAME gate the executor will consult to decide which slice of the catalog the run's MCP endpoint serves —
// recorded on the result so the cli vs cli-mcp rows can never be mislabeled relative to what the run actually did
// (a read-only cell is served only the tools that do not write, even when its mode opts into the fabric).
var mcpFullCatalog = AgentRunExecutor.ResolveMcpCatalogMode(agentTask) == McpCatalogMode.Full;

var attempts = await RunWithFormatFaultRespawnAsync(task, agentTask, context, cancellationToken).ConfigureAwait(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace CodeSpace.Core.Services.Agents.Mcp;
/// One run's live MCP endpoint over a PER-RUN Unix-domain socket: it binds + listens on the run's socket path, accepts
/// connections in a loop, and for each connection validates the per-run <c>CODESPACE_RUN_TOKEN</c> on the FIRST line
/// before serving — then pumps one <see cref="McpFramingLoop"/> (a fresh <see cref="McpRequestHandler"/> bound to the
/// run's tool registry + autonomy + permissions + team + secret redactor) over the socket's <see cref="NetworkStream"/>. Every
/// run's tool registry + autonomy + permissions + bound repositories + team + secret redactor) over the socket's <see cref="NetworkStream"/>. Every
/// tool-result text the handler returns is run through the run's <see cref="SecretRedactor"/>, so an echoed model key
/// never reaches the model. The connect descriptor
/// (socket path + token) is registered with the <see cref="IAgentMcpConnectRegistry"/> under the run id so a consumer
Expand Down Expand Up @@ -49,6 +49,7 @@ public sealed class AgentMcpEndpoint : IAsyncDisposable
private readonly Guid? _approvalConversationId;
private readonly McpCatalogMode _catalogMode;
private readonly AgentPermissions? _permissions;
private readonly IReadOnlyList<WorkspaceRepositorySpec>? _repositories;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts;
private readonly Socket _listener;
Expand All @@ -57,7 +58,7 @@ public sealed class AgentMcpEndpoint : IAsyncDisposable

private bool _disposed;

public AgentMcpEndpoint(Guid runId, IAgentToolRegistry registry, AgentAutonomyLevel autonomy, Guid teamId, SecretRedactor redactor, string socketPath, string token, IAgentMcpConnectRegistry connects, IServiceScope scope, CancellationToken ct, ILogger logger, long fenceEpoch = 0, bool governanceEnabled = false, Guid? approvalConversationId = null, McpCatalogMode catalogMode = McpCatalogMode.Full, AgentPermissions? permissions = null)
public AgentMcpEndpoint(Guid runId, IAgentToolRegistry registry, AgentAutonomyLevel autonomy, Guid teamId, SecretRedactor redactor, string socketPath, string token, IAgentMcpConnectRegistry connects, IServiceScope scope, CancellationToken ct, ILogger logger, long fenceEpoch = 0, bool governanceEnabled = false, Guid? approvalConversationId = null, McpCatalogMode catalogMode = McpCatalogMode.Full, AgentPermissions? permissions = null, IReadOnlyList<WorkspaceRepositorySpec>? repositories = null)
{
_runId = runId;
_registry = registry;
Expand All @@ -73,6 +74,7 @@ public AgentMcpEndpoint(Guid runId, IAgentToolRegistry registry, AgentAutonomyLe
_approvalConversationId = approvalConversationId;
_catalogMode = catalogMode;
_permissions = permissions;
_repositories = repositories;
_logger = logger;
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_counters = new McpFabricCounters();
Expand Down Expand Up @@ -102,11 +104,11 @@ public AgentMcpEndpoint(Guid runId, IAgentToolRegistry registry, AgentAutonomyLe
/// with a restricted author tool list still reaches the governed codespace tools the open endpoint serves. Computed
/// from the SAME registry + autonomy this endpoint serves with (and the SAME server name the handler advertises), so
/// the allow-list and the endpoint gate agree by construction. A tool the tier is Denied is omitted (never offered a
/// name it would be refused). In ReadOnly catalog mode only read-only tools are projected — the SAME slice the
/// handler lists + serves, so the allow-list never names a tool this run's mode would refuse.
/// name it would be refused). Only the catalog mode's slice is projected (<see cref="McpRequestHandler.Serves(McpCatalogMode, IAgentTool)"/>)
/// — the SAME slice the handler lists + serves, so the allow-list never names a tool this run's mode would refuse.
/// </summary>
public IReadOnlyList<string> AllowedToolNames() =>
McpAllowedTools.QualifiedNames(_registry.All.Where(t => _catalogMode == McpCatalogMode.Full || t.IsReadOnly), _autonomy, McpRequestHandler.ServerName).ToArray();
McpAllowedTools.QualifiedNames(_registry.All.Where(t => McpRequestHandler.Serves(_catalogMode, t)), _autonomy, McpRequestHandler.ServerName).ToArray();

/// <summary>P0-B2: an authenticated client served the MCP <c>initialize</c> handshake at least once on this endpoint.</summary>
public bool HandshakeObserved => _counters.Handshakes > 0;
Expand Down Expand Up @@ -189,7 +191,7 @@ private async Task ServeConnectionAsync(Socket conn, CancellationToken ct)

var authorityContext = new McpAuthorityContext(_runId, _teamId, connectionScope.ServiceProvider.GetRequiredService<IAgentAuthorityCallGuard>(), _counters);
var authorizedRegistry = new AuthorityCheckedToolRegistry(_registry, authorityContext);
var protocol = new McpRequestHandler(authorizedRegistry, _autonomy, _teamId, _redactor, _runId, ledger, _fenceEpoch, _governanceEnabled, _approvalConversationId, bot, waiters, components, _catalogMode, _counters, _logger, _permissions);
var protocol = new McpRequestHandler(authorizedRegistry, _autonomy, _teamId, _redactor, _runId, ledger, _fenceEpoch, _governanceEnabled, _approvalConversationId, bot, waiters, components, _catalogMode, _counters, _logger, _permissions, _repositories);

var handler = new AuthorizedMcpRequestHandler(protocol, authorityContext);

Expand Down
Loading
Loading