Skip to content

Commit 22031af

Browse files
committed
Bind agent tool calls to the run's repositories
agent.run_command and the git.* pull-request tools resolved a model-supplied repositoryId by id and team only, so an agent could clone, read, merge or comment on any repository of its team at any ref it named, including repositories its run was never bound to and unmerged branches, using that repository's connection credential. Network Off and a read-only write scope did not stop it. The run's bound repositories (its admitted task's workspace, with each repository's access and ref) are now stamped server-side onto the posture every tool call carries. NodeAgentTool holds every node that declares a repository input (NodeManifest.RepositoryInput) to that set: any other id gets the same "not found" a missing or foreign one gets. Read-only context is the run's to read, not to write: an agent opens, merges, reviews and comments on none of its pull requests, and a command checks it out only at its bound or default branch. The pin covers what a command checks out; the repository's pull requests stay readable through the git read tools. A patch-only repository refuses agent pull-request writes through the guard chain an agent's pushed branch meets. Each of these refusals is answered before a call is parked for approval (IAgentTool.RefusalAsync), so a Standard or Trusted run never asks a human to approve a call that could only be refused; the tool checks again when the call runs, since a repository can change while a card waits. RunCommandService keeps its own ref pin for a caller that reaches it directly. A supervisor-spawned child's related repositories carry the operator's bound ref, not one the supervisor model authored: that ref is what the child clones and what its read-only binding pins commands to. A run whose write scope is read-only (an agent.run with readOnly, or a Confined tier) is served a NonDestructive catalog: every tool that does not write, so it can still ask a human through decision.request. The fabric opt-out's ReadOnly slice is unchanged. Workflow-node calls carry no calling run and are unchanged.
1 parent 59fb645 commit 22031af

38 files changed

Lines changed: 1752 additions & 70 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using CodeSpace.Messages.Agents;
2+
3+
namespace CodeSpace.Core.Services.Agents;
4+
5+
/// <summary>
6+
/// The calling run's hold on a repository its tool call names (<see cref="AgentRunPosture.Repositories"/>). Team scope
7+
/// alone let an agent reach every repository of its team at any ref it chose; the run's own binding is narrower. A
8+
/// repository outside it gets the "not found" a missing or foreign one gets, so a refusal never confirms that a guessed
9+
/// id exists. A writable repository is the run's own to work in, at any ref. Read-only context is something the run
10+
/// reads: a command checks out only its bound branch or its default branch, and an agent writes nothing to it — no pull
11+
/// request opened, merged, reviewed or commented on. Pure — consulted by <c>NodeAgentTool</c> for every
12+
/// manifest-declared repository input and by <c>RunCommandService</c> for an agent's command.
13+
///
14+
/// <para>The ref pin holds what a command CHECKS OUT, so the working tree an agent builds and runs from read-only
15+
/// context is the content the operator bound, never a branch it named. It does not hide what the provider publishes
16+
/// about a bound repository: its pull requests — their list, diffs and checks — are readable through the git read tools
17+
/// like the rest of the repository, whatever ref it is bound at. A repository whose open pull requests must stay out of
18+
/// a run's reach is one not to bind to it.</para>
19+
/// </summary>
20+
public static class AgentRepositoryBinding
21+
{
22+
/// <summary>
23+
/// The refusal for a repository outside the run's binding — the same answer whether the id is this team's, another
24+
/// team's or nobody's, and byte-identical to <c>RunCommandService</c>'s own tenant-filter miss.
25+
/// </summary>
26+
public static string NotFound(Guid repositoryId) => $"Repository {repositoryId} not found.";
27+
28+
/// <summary>The refusal for a pull-request write to a repository the run is bound to only as read-only context.</summary>
29+
public static string ReadOnlyContextWrite(Guid repositoryId) =>
30+
$"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.";
31+
32+
/// <summary>The refusal for a command checking out a ref of read-only context outside its binding.</summary>
33+
public static string RefOutsideBinding(Guid repositoryId, WorkspaceRepositorySpec bound, string? requestedRef, string defaultBranch) =>
34+
$"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}'.";
35+
36+
/// <summary>The run's binding of <paramref name="repositoryId"/>, or null when the run is not bound to it.</summary>
37+
public static WorkspaceRepositorySpec? Find(AgentRunPosture caller, Guid repositoryId) =>
38+
caller.Repositories.FirstOrDefault(repository => repository.RepositoryId == repositoryId);
39+
40+
/// <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>
41+
public static bool AllowsWrite(WorkspaceRepositorySpec bound) => bound.Access == WorkspaceAccess.Write;
42+
43+
/// <summary>
44+
/// Whether a command may check out <paramref name="requestedRef"/> of a bound repository: any ref of a writable one;
45+
/// of read-only context, its bound branch (else the default branch) or the default branch — compared exactly, as git
46+
/// names refs. A blank ref is the default branch, as the clone reads it. An access this code does not know is held
47+
/// like read-only context.
48+
/// </summary>
49+
public static bool AllowsRef(WorkspaceRepositorySpec bound, string? requestedRef, string defaultBranch)
50+
{
51+
if (AllowsWrite(bound) || string.IsNullOrWhiteSpace(requestedRef)) return true;
52+
53+
return requestedRef == defaultBranch || requestedRef == (bound.Ref ?? defaultBranch);
54+
}
55+
}

‎backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3601,9 +3601,18 @@ internal static string BuildBranchName(Guid runId, long fenceEpoch = 1) =>
36013601
/// selects <see cref="McpCatalogMode.Full"/> — the whole registry incl. the side-effecting fabric, byte-identical to
36023602
/// before. OFF (the default) selects <see cref="McpCatalogMode.ReadOnly"/> — only read-only tools (e.g.
36033603
/// <c>get_context</c> + the git reads) are served, so a default run still reaches the safe read tools without
3604-
/// exposing any side effect. Pure + internal so it's unit-pinned.
3604+
/// exposing any side effect. An opted-in run whose write scope is not <see cref="AgentWriteScope.Workspace"/> — an
3605+
/// author's <c>readOnly</c>, a Confined tier, or a scope this code does not know, read as read-only like
3606+
/// <see cref="ApplyWriteScope"/> reads it — is served <see cref="McpCatalogMode.NonDestructive"/>: "analysis-only (no
3607+
/// writes)" must hold for the tools it is handed, not only for its own sandbox, yet it keeps every tool that does not
3608+
/// write, the ask (<c>decision.request</c>) among them. Pure + internal so it's unit-pinned.
36053609
/// </summary>
3606-
internal static McpCatalogMode ResolveMcpCatalogMode(AgentTask task) => UsesFullToolCatalog(task) ? McpCatalogMode.Full : McpCatalogMode.ReadOnly;
3610+
internal static McpCatalogMode ResolveMcpCatalogMode(AgentTask task)
3611+
{
3612+
if (!UsesFullToolCatalog(task)) return McpCatalogMode.ReadOnly;
3613+
3614+
return task.Permissions.WriteScope == AgentWriteScope.Workspace ? McpCatalogMode.Full : McpCatalogMode.NonDestructive;
3615+
}
36073616

36083617
/// <summary>
36093618
/// A BOOT diagnostic the worker host calls once at startup so a mis-configured tool fabric is VISIBLE at deploy time,
@@ -3672,9 +3681,13 @@ private static (string SocketPath, string Token) MintMcpConnect(Guid runId) =>
36723681
// tools by default and the whole fabric only when the run opted in.
36733682
var catalogMode = ResolveMcpCatalogMode(task);
36743683

3684+
// The repositories the admitted task bound the run to — the workspace it cloned — are the only ones its tool calls
3685+
// may name, stamped server-side here and never read from the model's arguments. A no-repository run binds none.
3686+
var repositories = RepositoryWorkspaceResolver.CanonicalWorkspace(task)?.Repositories ?? [];
3687+
36753688
try
36763689
{
3677-
return new AgentMcpEndpoint(runId, registry, autonomy, teamId, redactor, socketPath, token, connects, scope, ct, _logger, fenceEpoch, governanceEnabled, approvalConversationId, catalogMode, task.Permissions);
3690+
return new AgentMcpEndpoint(runId, registry, autonomy, teamId, redactor, socketPath, token, connects, scope, ct, _logger, fenceEpoch, governanceEnabled, approvalConversationId, catalogMode, task.Permissions, repositories);
36783691
}
36793692
// An over-length socket path throws ArgumentOutOfRangeException (UDS endpoint ctor); CreateDirectory can throw
36803693
// IOException / UnauthorizedAccessException. The endpoint is optional infra, not the run, so any of these is a

‎backend/src/CodeSpace.Core/Services/Agents/Commands/RunCommandService.cs‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public async Task<SandboxResult> RunAsync(RunCommandRequest request, Cancellatio
4444
// Repo-scoped → clone into a fresh per-run workspace the command runs in; ephemeral → no checkout.
4545
// The same runnerKind selects the matching workspace provider, so a future docker/k8s pair composes here.
4646
var workspace = request.RepositoryId is { } repositoryId
47-
? await _workspaces.Resolve(runnerKind).PrepareAsync(WorkspaceProvisionRequest.FromSingle(await BuildWorkspaceRequestAsync(repositoryId, request.Ref, request.TeamId, cancellationToken).ConfigureAwait(false)), cancellationToken).ConfigureAwait(false)
47+
? await _workspaces.Resolve(runnerKind).PrepareAsync(WorkspaceProvisionRequest.FromSingle(await BuildWorkspaceRequestAsync(repositoryId, request, cancellationToken).ConfigureAwait(false)), cancellationToken).ConfigureAwait(false)
4848
: null;
4949

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

172+
var bound = CallerBinding(request.CallerPosture, repositoryId);
173+
172174
var repo = await _db.Repository
173175
.Include(r => r.ProviderInstance)
174176
.Include(r => r.Credential)
@@ -178,17 +180,47 @@ private async Task<WorkspaceRequest> BuildWorkspaceRequestAsync(Guid repositoryI
178180
if (string.IsNullOrWhiteSpace(repo.CloneUrlHttps))
179181
throw new WorkspaceException($"Repository {repositoryId} has no HTTPS clone URL to clone from.");
180182

183+
EnsureRefWithinBinding(bound, request.Ref, repo);
184+
181185
var token = await ResolveTokenAsync(repo, cancellationToken).ConfigureAwait(false);
182186

183187
return new WorkspaceRequest
184188
{
185189
RepositoryUrl = repo.CloneUrlHttps,
186-
Ref = string.IsNullOrWhiteSpace(gitRef) ? repo.DefaultBranch : gitRef,
190+
Ref = string.IsNullOrWhiteSpace(request.Ref) ? repo.DefaultBranch : request.Ref,
187191
Token = token,
188192
TokenUsername = token is null ? null : RepositoryWorkspaceResolver.TokenUsernameFor(repo.ProviderInstance.Provider),
189193
};
190194
}
191195

196+
/// <summary>
197+
/// The calling run's binding of the repository an agent's command names — null for a workflow node's command, which
198+
/// has no calling run and resolves its authored repository within its team as before. A repository the run is not
199+
/// bound to is refused with the same "not found" the tenant filter gives, before it is ever loaded.
200+
/// </summary>
201+
private static WorkspaceRepositorySpec? CallerBinding(AgentRunPosture? caller, Guid repositoryId)
202+
{
203+
if (caller is null) return null;
204+
205+
return AgentRepositoryBinding.Find(caller, repositoryId) ?? throw new WorkspaceException(AgentRepositoryBinding.NotFound(repositoryId));
206+
}
207+
208+
/// <summary>
209+
/// An agent's command checks out read-only context only at its bound branch or its default branch, so the tree it
210+
/// builds and runs is the content the operator bound, never a branch the agent named (what the pin does and does not
211+
/// cover is on <see cref="AgentRepositoryBinding"/>). A refusal rather than an approval card: the binding is the
212+
/// operator's own narrowing, which a single approver's click should not widen mid-run, and the card cannot show the
213+
/// ref it would be consenting to. The agent tool refuses it before the call is ever parked for approval too
214+
/// (<c>NodeAgentTool</c>); this holds a caller that reaches the service directly. A writable repository, and a
215+
/// workflow node's command (<paramref name="bound"/> null), take any ref.
216+
/// </summary>
217+
private static void EnsureRefWithinBinding(WorkspaceRepositorySpec? bound, string? requestedRef, Repository repo)
218+
{
219+
if (bound is null || AgentRepositoryBinding.AllowsRef(bound, requestedRef, repo.DefaultBranch)) return;
220+
221+
throw new WorkspaceException(AgentRepositoryBinding.RefOutsideBinding(repo.Id, bound, requestedRef, repo.DefaultBranch));
222+
}
223+
192224
private async Task<string?> ResolveTokenAsync(Repository repo, CancellationToken cancellationToken)
193225
{
194226
if (repo.Credential is null) return null;

‎backend/src/CodeSpace.Core/Services/Agents/Eval/Benchmark/BenchmarkRunner.cs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ namespace CodeSpace.Core.Services.Agents.Eval.Benchmark;
2020
/// are GENUINELY differentiated within a SINGLE process: the runner stamps the per-run opt-in
2121
/// <c>AgentTask.EnableMcpEndpoint</c> from the mode, so the cli-mcp run opens the run-scoped MCP tool-fabric endpoint
2222
/// while the cli run does not — they execute observably differently, not merely under different scorecard labels. The
23-
/// resolved gate state (the SAME <c>AgentRunExecutor.UsesFullToolCatalog</c> the executor consults) is recorded on
23+
/// resolved gate state (the SAME <c>AgentRunExecutor.ResolveMcpCatalogMode</c> the executor consults) is recorded on
2424
/// <see cref="BenchmarkResult.McpFullCatalog"/>, so a row can never be mislabeled relative to what the executor did.
2525
/// <see cref="BenchmarkMode.WorkflowMap"/> is RESERVED, not wired in this slice: it would run through the composed
2626
/// planner→<c>flow.map</c>→synthesizer ENGINE path (a workflow, not a single agent run), which this single-run runner
@@ -67,9 +67,10 @@ public async Task<BenchmarkResult> RunAsync(BenchmarkTask task, BenchmarkMode mo
6767

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

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

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

‎backend/src/CodeSpace.Core/Services/Agents/Mcp/AgentMcpEndpoint.cs‎

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

5859
private bool _disposed;
5960

60-
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)
61+
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)
6162
{
6263
_runId = runId;
6364
_registry = registry;
@@ -73,6 +74,7 @@ public AgentMcpEndpoint(Guid runId, IAgentToolRegistry registry, AgentAutonomyLe
7374
_approvalConversationId = approvalConversationId;
7475
_catalogMode = catalogMode;
7576
_permissions = permissions;
77+
_repositories = repositories;
7678
_logger = logger;
7779
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
7880
_counters = new McpFabricCounters();
@@ -102,11 +104,11 @@ public AgentMcpEndpoint(Guid runId, IAgentToolRegistry registry, AgentAutonomyLe
102104
/// with a restricted author tool list still reaches the governed codespace tools the open endpoint serves. Computed
103105
/// from the SAME registry + autonomy this endpoint serves with (and the SAME server name the handler advertises), so
104106
/// the allow-list and the endpoint gate agree by construction. A tool the tier is Denied is omitted (never offered a
105-
/// name it would be refused). In ReadOnly catalog mode only read-only tools are projected — the SAME slice the
106-
/// handler lists + serves, so the allow-list never names a tool this run's mode would refuse.
107+
/// name it would be refused). Only the catalog mode's slice is projected (<see cref="McpRequestHandler.Serves(McpCatalogMode, IAgentTool)"/>)
108+
/// — the SAME slice the handler lists + serves, so the allow-list never names a tool this run's mode would refuse.
107109
/// </summary>
108110
public IReadOnlyList<string> AllowedToolNames() =>
109-
McpAllowedTools.QualifiedNames(_registry.All.Where(t => _catalogMode == McpCatalogMode.Full || t.IsReadOnly), _autonomy, McpRequestHandler.ServerName).ToArray();
111+
McpAllowedTools.QualifiedNames(_registry.All.Where(t => McpRequestHandler.Serves(_catalogMode, t)), _autonomy, McpRequestHandler.ServerName).ToArray();
110112

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

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

194196
var handler = new AuthorizedMcpRequestHandler(protocol, authorityContext);
195197

0 commit comments

Comments
 (0)