diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRepositoryBinding.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRepositoryBinding.cs
new file mode 100644
index 000000000..3b291597a
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRepositoryBinding.cs
@@ -0,0 +1,55 @@
+using CodeSpace.Messages.Agents;
+
+namespace CodeSpace.Core.Services.Agents;
+
+///
+/// The calling run's hold on a repository its tool call names (). 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 NodeAgentTool for every
+/// manifest-declared repository input and by RunCommandService for an agent's command.
+///
+/// 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.
+///
+public static class AgentRepositoryBinding
+{
+ ///
+ /// 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 RunCommandService's own tenant-filter miss.
+ ///
+ public static string NotFound(Guid repositoryId) => $"Repository {repositoryId} not found.";
+
+ /// The refusal for a pull-request write to a repository the run is bound to only as read-only context.
+ 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.";
+
+ /// The refusal for a command checking out a ref of read-only context outside its binding.
+ 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}'.";
+
+ /// The run's binding of , or null when the run is not bound to it.
+ public static WorkspaceRepositorySpec? Find(AgentRunPosture caller, Guid repositoryId) =>
+ caller.Repositories.FirstOrDefault(repository => repository.RepositoryId == repositoryId);
+
+ /// 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.
+ public static bool AllowsWrite(WorkspaceRepositorySpec bound) => bound.Access == WorkspaceAccess.Write;
+
+ ///
+ /// Whether a command may check out 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.
+ ///
+ 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);
+ }
+}
diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
index b57618eef..f7bce4fd5 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
@@ -3601,9 +3601,18 @@ internal static string BuildBranchName(Guid runId, long fenceEpoch = 1) =>
/// selects — the whole registry incl. the side-effecting fabric, byte-identical to
/// before. OFF (the default) selects — only read-only tools (e.g.
/// get_context + 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 — an
+ /// author's readOnly, a Confined tier, or a scope this code does not know, read as read-only like
+ /// reads it — is served : "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 (decision.request) among them. Pure + internal so it's unit-pinned.
///
- 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;
+ }
///
/// 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) =>
// 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
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Commands/RunCommandService.cs b/backend/src/CodeSpace.Core/Services/Agents/Commands/RunCommandService.cs
index a2c5de193..009bd3be3 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Commands/RunCommandService.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Commands/RunCommandService.cs
@@ -44,7 +44,7 @@ public async Task 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
@@ -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).
///
- private async Task BuildWorkspaceRequestAsync(Guid repositoryId, string? gitRef, Guid? teamId, CancellationToken cancellationToken)
+ private async Task 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)
@@ -178,17 +180,47 @@ private async Task 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),
};
}
+ ///
+ /// 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.
+ ///
+ 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));
+ }
+
+ ///
+ /// 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 ). 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
+ /// (NodeAgentTool); this holds a caller that reaches the service directly. A writable repository, and a
+ /// workflow node's command ( null), take any ref.
+ ///
+ 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 ResolveTokenAsync(Repository repo, CancellationToken cancellationToken)
{
if (repo.Credential is null) return null;
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Eval/Benchmark/BenchmarkRunner.cs b/backend/src/CodeSpace.Core/Services/Agents/Eval/Benchmark/BenchmarkRunner.cs
index 2440f9ce7..9ed59b282 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Eval/Benchmark/BenchmarkRunner.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Eval/Benchmark/BenchmarkRunner.cs
@@ -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
/// AgentTask.EnableMcpEndpoint 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 AgentRunExecutor.UsesFullToolCatalog the executor consults) is recorded on
+/// resolved gate state (the SAME AgentRunExecutor.ResolveMcpCatalogMode the executor consults) is recorded on
/// , so a row can never be mislabeled relative to what the executor did.
/// is RESERVED, not wired in this slice: it would run through the composed
/// planner→flow.map→synthesizer ENGINE path (a workflow, not a single agent run), which this single-run runner
@@ -67,9 +67,10 @@ public async Task 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);
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Mcp/AgentMcpEndpoint.cs b/backend/src/CodeSpace.Core/Services/Agents/Mcp/AgentMcpEndpoint.cs
index a4ea6f3f3..cdbf3c281 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Mcp/AgentMcpEndpoint.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Mcp/AgentMcpEndpoint.cs
@@ -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 CODESPACE_RUN_TOKEN on the FIRST line
/// before serving — then pumps one (a fresh bound to the
-/// run's tool registry + autonomy + permissions + team + secret redactor) over the socket's . Every
+/// run's tool registry + autonomy + permissions + bound repositories + team + secret redactor) over the socket's . Every
/// tool-result text the handler returns is run through the run's , so an echoed model key
/// never reaches the model. The connect descriptor
/// (socket path + token) is registered with the under the run id so a consumer
@@ -49,6 +49,7 @@ public sealed class AgentMcpEndpoint : IAsyncDisposable
private readonly Guid? _approvalConversationId;
private readonly McpCatalogMode _catalogMode;
private readonly AgentPermissions? _permissions;
+ private readonly IReadOnlyList? _repositories;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts;
private readonly Socket _listener;
@@ -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? repositories = null)
{
_runId = runId;
_registry = registry;
@@ -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();
@@ -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 ()
+ /// — the SAME slice the handler lists + serves, so the allow-list never names a tool this run's mode would refuse.
///
public IReadOnlyList 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();
/// P0-B2: an authenticated client served the MCP initialize handshake at least once on this endpoint.
public bool HandshakeObserved => _counters.Handshakes > 0;
@@ -189,7 +191,7 @@ private async Task ServeConnectionAsync(Socket conn, CancellationToken ct)
var authorityContext = new McpAuthorityContext(_runId, _teamId, connectionScope.ServiceProvider.GetRequiredService(), _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);
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Mcp/AuthorizedMcpRequestHandler.cs b/backend/src/CodeSpace.Core/Services/Agents/Mcp/AuthorizedMcpRequestHandler.cs
index 1c39247a1..37abd0f50 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Mcp/AuthorizedMcpRequestHandler.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Mcp/AuthorizedMcpRequestHandler.cs
@@ -66,6 +66,7 @@ private sealed class CheckedTool : IAgentTool
public bool RequiresApproval => _inner.RequiresApproval;
public bool AlwaysRequiresApproval => _inner.AlwaysRequiresApproval;
public AgentToolValidation ValidateInput(JsonElement input) => _inner.ValidateInput(input);
+ public Task RefusalAsync(AgentToolCall call, CancellationToken cancellationToken) => _inner.RefusalAsync(call with { RunId = _context.AgentRunId, TeamId = _context.TeamId }, cancellationToken);
public async Task CallAsync(AgentToolCall call, CancellationToken cancellationToken)
{
if (await _context.Guard.CheckAsync(_context.AgentRunId, _context.TeamId, Kind, cancellationToken).ConfigureAwait(false) is { } failure) return AgentToolResult.Fail($"{failure.Code}: {failure.Message}");
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Mcp/McpRequestHandler.cs b/backend/src/CodeSpace.Core/Services/Agents/Mcp/McpRequestHandler.cs
index c5abaa722..47e7a3106 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Mcp/McpRequestHandler.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Mcp/McpRequestHandler.cs
@@ -32,7 +32,8 @@ namespace CodeSpace.Core.Services.Agents.Mcp;
/// which NodeAgentTool writes to the synthetic scope's sys.team_id so a repo-touching tool resolves
/// the run's tenant (a foreign repository id still fail-closes; a null team → no team → fail-closed). The run's sandbox
/// posture rides every call the same way (), so a tool that starts a sandbox of
-/// its own (agent.run_command) runs it no wider than the run. EVERY
+/// its own (agent.run_command) runs it no wider than the run, and a repository-taking tool reaches only the
+/// repositories the run is bound to. EVERY
/// tool-result text the model receives — success output, tool error, AND the caught-exception message — is run
/// through the run's at the single choke point, so an echoed
/// model key can never reach the model through a tool call.
@@ -106,18 +107,21 @@ public sealed class McpRequestHandler : IMcpRequestHandler
// Which slice of the catalog this connection serves. The endpoint opens for every run; ReadOnly (the default for a
// run that did NOT opt into the side-effecting fabric) serves only read-only tools — they are the only ones listed,
// allow-listed, and callable. Full (the existing opt-in) serves the whole registry, byte-identical to before.
+ // NonDestructive (an opted-in run whose write scope is read-only) serves every tool that does not write, so the run
+ // still reads and can still ask a human (decision.request).
private readonly McpCatalogMode _catalogMode;
// The posture of the run this connection serves, stamped onto every tool call so a tool that starts a sandbox of
- // its own runs it no wider than the run. The run's own permissions when the endpoint passed them; a handler built
- // without them (tests) serves its tier's derived permissions.
+ // its own runs it no wider than the run, and a repository-taking tool reaches only the run's bound repositories. The
+ // run's own permissions when the endpoint passed them; a handler built without them (tests) serves its tier's
+ // derived permissions. A handler built without a binding binds no repository (fail-closed).
private readonly AgentRunPosture _posture;
private readonly ILogger _logger;
- public McpRequestHandler(IAgentToolRegistry registry, AgentAutonomyLevel autonomy, Guid? teamId = null, SecretRedactor? redactor = null, Guid runId = default, IToolCallLedgerService? ledger = null, long fenceEpoch = 0, bool governanceEnabled = false, Guid? approvalConversationId = null, IChatBotService? bot = null, IToolApprovalWaiterRegistry? waiters = null, IInteractionComponentRegistry? components = null, McpCatalogMode catalogMode = McpCatalogMode.Full, McpFabricCounters? counters = null, ILogger? logger = null, AgentPermissions? permissions = null)
+ public McpRequestHandler(IAgentToolRegistry registry, AgentAutonomyLevel autonomy, Guid? teamId = null, SecretRedactor? redactor = null, Guid runId = default, IToolCallLedgerService? ledger = null, long fenceEpoch = 0, bool governanceEnabled = false, Guid? approvalConversationId = null, IChatBotService? bot = null, IToolApprovalWaiterRegistry? waiters = null, IInteractionComponentRegistry? components = null, McpCatalogMode catalogMode = McpCatalogMode.Full, McpFabricCounters? counters = null, ILogger? logger = null, AgentPermissions? permissions = null, IReadOnlyList? repositories = null)
{
_registry = registry;
_autonomy = autonomy;
- _posture = new AgentRunPosture { RunId = runId, Autonomy = autonomy, Permissions = permissions ?? AgentAutonomyPolicy.Derive(autonomy) };
+ _posture = new AgentRunPosture { RunId = runId, Autonomy = autonomy, Permissions = permissions ?? AgentAutonomyPolicy.Derive(autonomy), Repositories = repositories ?? [] };
_counters = counters;
_teamId = teamId;
_redactor = redactor ?? SecretRedactor.None;
@@ -133,8 +137,21 @@ public McpRequestHandler(IAgentToolRegistry registry, AgentAutonomyLevel autonom
_logger = logger ?? NullLogger.Instance;
}
- /// True when this run's catalog mode serves : Full serves the whole registry; ReadOnly serves only read-only tools. The ONE predicate every catalog surface (tools/list, tools/call resolve, the allow-list) consults so they agree by construction.
- private bool Serves(IAgentTool tool) => _catalogMode == McpCatalogMode.Full || tool.IsReadOnly;
+ /// True when this run's catalog mode serves (see ).
+ private bool Serves(IAgentTool tool) => Serves(_catalogMode, tool);
+
+ ///
+ /// True when serves : Full serves the whole registry; NonDestructive
+ /// every tool that does not write (the read-only tools and an ask, which is not destructive); ReadOnly, and a mode
+ /// this code does not know, only read-only tools. The ONE predicate every catalog surface (tools/list, tools/call
+ /// resolve, the endpoint's allow-list) consults so they agree by construction.
+ ///
+ internal static bool Serves(McpCatalogMode mode, IAgentTool tool) => mode switch
+ {
+ McpCatalogMode.Full => true,
+ McpCatalogMode.NonDestructive => !tool.IsDestructive,
+ _ => tool.IsReadOnly,
+ };
/// The effective bounded-block window (seconds): the env override when positive + parseable, else (Rule 8 — read only here).
public static int ApprovalBoundSeconds()
@@ -231,9 +248,9 @@ private async Task HandleToolCallAsync(JsonElement id, JsonElem
if (tool == null) return JsonRpcResponse.Fail(id, Error(JsonRpcError.InvalidParams, $"Unknown tool '{name}'."));
- // A side-effecting tool is not part of a ReadOnly run's catalog (it is absent from tools/list too) — refuse it
- // at call time so a stale/guessed name can't reach the gate or a side effect. Fail-closed, before the gate.
- if (!Serves(tool)) return JsonRpcResponse.Ok(id, ToolResult(isError: true, $"Tool '{name}' is not available: this run serves only read-only tools. The side-effecting tool fabric is opt-in."));
+ // A tool outside the run's catalog slice (it is absent from tools/list too) is refused at call time so a stale or
+ // guessed name can't reach the gate or a side effect. Fail-closed, before the gate.
+ if (!Serves(tool)) return JsonRpcResponse.Ok(id, ToolResult(isError: true, NotServedMessage(name)));
// decision.request is an ASK, not a gated side effect — intercept it BEFORE the autonomy gate (a Confined tier
// must never DENY a question) and drive the durable decision flow on the SAME tool-ledger spine the approval
@@ -274,6 +291,12 @@ private async Task HandleToolCallAsync(JsonElement id, JsonElem
if (!validation.IsValid) return JsonRpcResponse.Ok(id, ToolResult(isError: true, validation.Error ?? "Invalid tool input."));
+ // A call the tool will refuse whatever a human decides (a repository outside the run's binding, a write to
+ // read-only context or to a patch-only repository) is answered now — before it is parked for approval or claimed
+ // in the ledger, so no card asks a human to approve a call that could only be refused. The tool still enforces
+ // it when it runs.
+ if (await tool.RefusalAsync(CallFor(arguments), cancellationToken).ConfigureAwait(false) is { } refusal) return JsonRpcResponse.Ok(id, ToolResult(isError: true, refusal));
+
// RequireApproval + a servable approval surface → park the call: record AwaitingApproval, post the card, and
// BLOCK until a human decides (or the bound elapses → pending-ticket). The side effect runs through the SAME
// exactly-once ledger path the Allow case uses, gated on the approval decision (see RunApprovalFlowAsync).
@@ -1087,6 +1110,11 @@ private JsonElement RedactStructured(JsonElement structured)
private static JsonRpcError Error(int code, string message) => new() { Code = code, Message = message };
+ /// The refusal for a tool outside the run's catalog slice, naming why it is withheld.
+ private string NotServedMessage(string tool) => _catalogMode == McpCatalogMode.NonDestructive
+ ? $"Tool '{tool}' is not available: this run is read-only, so it is served no tool that writes."
+ : $"Tool '{tool}' is not available: this run serves only read-only tools. The side-effecting tool fabric is opt-in.";
+
private static string GateMessage(AgentToolGateDecision decision, string tool) => decision == AgentToolGateDecision.RequireApproval
? $"Tool '{tool}' requires human approval, which this run's autonomy level cannot grant on its own."
: $"Tool '{tool}' is not permitted at this run's autonomy level.";
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Tools/AgentRepositoryPolicy.cs b/backend/src/CodeSpace.Core/Services/Agents/Tools/AgentRepositoryPolicy.cs
new file mode 100644
index 000000000..17a05352e
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Agents/Tools/AgentRepositoryPolicy.cs
@@ -0,0 +1,65 @@
+using Autofac;
+using CodeSpace.Core.DependencyInjection;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Agents.Publish;
+using CodeSpace.Messages.Agents;
+using Microsoft.EntityFrameworkCore;
+
+namespace CodeSpace.Core.Services.Agents.Tools;
+
+///
+/// The repository's own say over an agent's use of it, once the run's binding has admitted the repository: whether a
+/// command may check out the ref it names (read-only context only at its bound or default branch, which takes the
+/// repository's default branch to judge), and whether it takes a pull-request write — open, merge, review, comment.
+/// A write meets the SAME guard chain an agent's pushed branch meets (), so a repository whose
+/// policy refuses agent-pushed branches (RepositoryPublishMode.PatchOnly, the protected / compliance-sensitive
+/// marker) takes no agent-opened, -merged, -reviewed or -commented pull request either — the reach the supervisor's own
+/// pull-request opener already gives that policy.
+///
+public interface IAgentRepositoryPolicy
+{
+ /// The refusal the model reads, or null when the repository takes the use (or did not resolve in the use's team — the tool then reports it not found itself).
+ Task RefusalAsync(AgentRepositoryUse use, CancellationToken cancellationToken);
+}
+
+///
+/// The guard chain is walked as the integration push walks it: in , first verdict wins,
+/// over a neutral task — a tool write has no per-run push opt-out, so only the repository-scoped guards can speak. The
+/// repository is read in a child of the owning scope, because one run's tool catalog serves concurrent calls and must not
+/// share a DbContext between them (the reason NodeInvocationExecutor gives each node invocation its own).
+///
+public sealed class AgentRepositoryPolicy(ILifetimeScope owner) : IAgentRepositoryPolicy, IScopedDependency
+{
+ private static readonly AgentTask NeutralTask = new() { Goal = "", Harness = "" };
+
+ public async Task RefusalAsync(AgentRepositoryUse use, CancellationToken cancellationToken)
+ {
+ await using var scope = owner.BeginLifetimeScope();
+
+ var repository = await LoadRepositoryAsync(scope.Resolve(), use.Bound.RepositoryId, use.TeamId, cancellationToken).ConfigureAwait(false);
+
+ return Refusal(repository, use, scope.Resolve>());
+ }
+
+ /// The verdict over for , worded for the agent — or null when it may proceed or the repository did not resolve. Pure, so the mapping is pinned over the production guards.
+ internal static string? Refusal(Repository? repository, AgentRepositoryUse use, IEnumerable guards)
+ {
+ if (repository is null) return null;
+
+ if (!AgentRepositoryBinding.AllowsRef(use.Bound, use.Ref, repository.DefaultBranch)) return AgentRepositoryBinding.RefOutsideBinding(repository.Id, use.Bound, use.Ref, repository.DefaultBranch);
+
+ return use.Writes ? WriteRefusal(repository, guards) : null;
+ }
+
+ /// The first publish guard's verdict over , worded for the agent — or null when no guard blocks.
+ private static string? WriteRefusal(Repository repository, IEnumerable guards)
+ {
+ var verdict = guards.OrderBy(guard => guard.Order).Select(guard => guard.Evaluate(NeutralTask, repository)).FirstOrDefault(found => found is not null);
+
+ return verdict is null ? null : $"Repository {repository.Id} does not take pull-request writes from an agent: {verdict.Reason}.";
+ }
+
+ private static Task LoadRepositoryAsync(CodeSpaceDbContext db, Guid repositoryId, Guid teamId, CancellationToken cancellationToken) =>
+ db.Repository.AsNoTracking().SingleOrDefaultAsync(r => r.Id == repositoryId && r.TeamId == teamId && r.DeletedDate == null, cancellationToken);
+}
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Tools/AgentToolRegistry.cs b/backend/src/CodeSpace.Core/Services/Agents/Tools/AgentToolRegistry.cs
index 3ce082830..ce19af52e 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Tools/AgentToolRegistry.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Tools/AgentToolRegistry.cs
@@ -17,11 +17,11 @@ public sealed class AgentToolRegistry : IAgentToolRegistry, IScopedDependency
{
private readonly IReadOnlyDictionary _byKind;
- public AgentToolRegistry(IEnumerable nodes, IEnumerable firstPartyTools, INodeInvocationExecutor nodeInvocations, ILoggerFactory loggerFactory)
+ public AgentToolRegistry(IEnumerable nodes, IEnumerable firstPartyTools, INodeInvocationExecutor nodeInvocations, IAgentRepositoryPolicy repositoryPolicy, ILoggerFactory loggerFactory)
{
var nodeTools = nodes
.Where(n => n.Manifest.IsAgentToolEligible)
- .Select(IAgentTool (n) => new NodeAgentTool(n, nodeInvocations, loggerFactory.CreateLogger($"AgentTool.{n.TypeKey}")));
+ .Select(IAgentTool (n) => new NodeAgentTool(n, nodeInvocations, repositoryPolicy, loggerFactory.CreateLogger($"AgentTool.{n.TypeKey}")));
var tools = nodeTools.Concat(firstPartyTools).ToList();
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Tools/IAgentTool.cs b/backend/src/CodeSpace.Core/Services/Agents/Tools/IAgentTool.cs
index 7fa6da656..7fb2da697 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Tools/IAgentTool.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Tools/IAgentTool.cs
@@ -54,6 +54,14 @@ public interface IAgentTool
/// Pure, I/O-free validation of the input shape/values (e.g. a blocked-path check) — the first gate, before any permission check or side effect.
AgentToolValidation ValidateInput(JsonElement input);
+ ///
+ /// The refusal for a call this tool will not run whatever a human decides — judged on its arguments and its calling
+ /// run (e.g. a repository outside the run's binding) — or null to admit it. Consulted before an approval-gated call is
+ /// parked, so no human is asked to approve a call that would only be refused; still enforces
+ /// it, since what it judges can change while a card waits. Default: admit.
+ ///
+ Task RefusalAsync(AgentToolCall call, CancellationToken cancellationToken) => Task.FromResult(null);
+
/// Execute the (already-validated, already-permitted) call to a structured result. Errors come back as a typed , not a thrown exception.
Task CallAsync(AgentToolCall call, CancellationToken cancellationToken);
}
diff --git a/backend/src/CodeSpace.Core/Services/Agents/Tools/NodeAgentTool.cs b/backend/src/CodeSpace.Core/Services/Agents/Tools/NodeAgentTool.cs
index 500466be1..10d313373 100644
--- a/backend/src/CodeSpace.Core/Services/Agents/Tools/NodeAgentTool.cs
+++ b/backend/src/CodeSpace.Core/Services/Agents/Tools/NodeAgentTool.cs
@@ -19,6 +19,12 @@ namespace CodeSpace.Core.Services.Agents.Tools;
/// runs against a minimal synthetic context (the tool input as its inputs, no upstream scope, no-op
/// observability, the calling run's ); the agent loop / MCP layer owns its own
/// auditing around the call.
+///
+/// A node that declares a repository input () is held, when a run calls
+/// it, to the repositories that run is bound to — once, here, for every such node: a write only to one bound writable,
+/// a ref of read-only context only at its bound or default branch, and a write also meets the repository's publish
+/// policy (). The same check answers , so the MCP layer
+/// refuses such a call before it parks it for a human's approval, and runs again when the call executes.
///
public sealed class NodeAgentTool : IAgentTool
{
@@ -26,12 +32,14 @@ public sealed class NodeAgentTool : IAgentTool
private readonly INodeRuntime _node;
private readonly INodeInvocationExecutor _invocations;
+ private readonly IAgentRepositoryPolicy _repositoryPolicy;
private readonly ILogger _logger;
- public NodeAgentTool(INodeRuntime node, INodeInvocationExecutor invocations, ILogger logger)
+ public NodeAgentTool(INodeRuntime node, INodeInvocationExecutor invocations, IAgentRepositoryPolicy repositoryPolicy, ILogger logger)
{
_node = node;
_invocations = invocations;
+ _repositoryPolicy = repositoryPolicy;
_logger = logger;
}
@@ -53,11 +61,11 @@ public NodeAgentTool(INodeRuntime node, INodeInvocationExecutor invocations, ILo
public AgentToolValidation ValidateInput(JsonElement input) =>
input.ValueKind == JsonValueKind.Object ? AgentToolValidation.Valid : AgentToolValidation.Invalid("Tool input must be a JSON object.");
+ public Task RefusalAsync(AgentToolCall call, CancellationToken cancellationToken) => RepositoryRefusalAsync(call, ReadInputs(call), cancellationToken);
+
public async Task CallAsync(AgentToolCall call, CancellationToken cancellationToken)
{
- var inputs = call.Input.ValueKind == JsonValueKind.Object
- ? call.Input.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone())
- : new Dictionary();
+ var inputs = ReadInputs(call);
// Strip the act-as-user actor key from model-controlled input. ActsAsUser ("act as this CodeSpace user's
// own linked provider identity", Model B) is an ENGINE-RESPOND-PATH feature: it is only safe because
@@ -69,6 +77,8 @@ public async Task CallAsync(AgentToolCall call, CancellationTok
// via the manifest, so every present + future act-as-user node is covered without naming a key here.
if (_node.Manifest.ActsAsUser is { } actsAsUser) inputs.Remove(actsAsUser.ActorInputKey);
+ if (await RepositoryRefusalAsync(call, inputs, cancellationToken).ConfigureAwait(false) is { } refusal) return AgentToolResult.Fail(refusal);
+
// Stamp the run's team onto the synthetic scope's sys.team_id so repo-touching nodes resolve within it.
// The Guid is serialized as a JSON STRING element, byte-for-byte like WorkflowEngine.BuildSysScope, so
// NodeScopeReader.TryReadTeamId (which requires ValueKind==String + Guid.TryParse) reads it back.
@@ -100,6 +110,75 @@ public async Task CallAsync(AgentToolCall call, CancellationTok
};
}
+ private static Dictionary ReadInputs(AgentToolCall call) =>
+ call.Input.ValueKind == JsonValueKind.Object
+ ? call.Input.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone())
+ : new Dictionary();
+
+ ///
+ /// The calling run's hold on the repository the model named in the node's declared repository input. A repository the
+ /// run is not bound to — in its team or not, existing or not — is refused as not found before the node runs; a write
+ /// to read-only context is refused on the binding's own access; a ref of read-only context and a write to a bound
+ /// repository meet the repository's policy. Null when the call may proceed: no calling run (a workflow node,
+ /// unchanged), a node that names no repository, or no repository id a node would act on.
+ ///
+ private async Task RepositoryRefusalAsync(AgentToolCall call, IReadOnlyDictionary inputs, CancellationToken cancellationToken)
+ {
+ if (call.CallerPosture is not { } caller || _node.Manifest.RepositoryInput is not { } input) return null;
+
+ if (!TryReadRepositoryId(inputs, input.InputKey, out var repositoryId)) return null;
+
+ if (AgentRepositoryBinding.Find(caller, repositoryId) is not { } bound) return RefuseUnbound(caller, repositoryId);
+
+ if (input.WritesRepository && !AgentRepositoryBinding.AllowsWrite(bound)) return AgentRepositoryBinding.ReadOnlyContextWrite(repositoryId);
+
+ if (call.TeamId is not { } teamId || UseOf(bound, input, inputs, teamId) is not { } use) return null;
+
+ return await _repositoryPolicy.RefusalAsync(use, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// The use the repository's own policy must judge, or null when there is none: a write (its publish guards), or a ref
+ /// named on read-only context (its default branch decides). A read at the default branch, or any ref of a writable
+ /// repository, needs no look at the repository.
+ ///
+ private static AgentRepositoryUse? UseOf(WorkspaceRepositorySpec bound, RepositoryInputSpec input, IReadOnlyDictionary inputs, Guid teamId)
+ {
+ var requestedRef = ReadRef(inputs, input.RefInputKey);
+ var pinsRef = requestedRef is not null && !AgentRepositoryBinding.AllowsWrite(bound);
+
+ return input.WritesRepository || pinsRef ? new AgentRepositoryUse { TeamId = teamId, Bound = bound, Ref = requestedRef, Writes = input.WritesRepository } : null;
+ }
+
+ /// The ref exactly as a node reads it — a JSON string, trimmed — or null for none (the default branch).
+ private static string? ReadRef(IReadOnlyDictionary inputs, string? key)
+ {
+ if (key is null || !inputs.TryGetValue(key, out var value) || value.ValueKind != JsonValueKind.String) return null;
+
+ var trimmed = (value.GetString() ?? "").Trim();
+
+ return trimmed.Length > 0 ? trimmed : null;
+ }
+
+ ///
+ /// The repository id exactly as every repository-taking node reads it — a JSON string that parses as a uuid — so the
+ /// binding sees every value a node would act on. Anything else a node treats as absent (a command with no checkout)
+ /// or rejects as invalid, so no repository is reached and there is nothing to hold.
+ ///
+ private static bool TryReadRepositoryId(IReadOnlyDictionary inputs, string key, out Guid repositoryId)
+ {
+ repositoryId = Guid.Empty;
+
+ return inputs.TryGetValue(key, out var value) && value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out repositoryId);
+ }
+
+ private string RefuseUnbound(AgentRunPosture caller, Guid repositoryId)
+ {
+ _logger.LogWarning("Agent run {RunId}: tool {Tool} named repository {RepositoryId}, which the run is not bound to; refused as not found", caller.RunId, _node.TypeKey, repositoryId);
+
+ return AgentRepositoryBinding.NotFound(repositoryId);
+ }
+
private static AgentToolResult OkFromOutputs(IReadOnlyDictionary outputs)
{
var json = JsonSerializer.SerializeToElement(outputs);
diff --git a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorRepoClamp.cs b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorRepoClamp.cs
index a651959b1..fb121a6ab 100644
--- a/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorRepoClamp.cs
+++ b/backend/src/CodeSpace.Core/Services/Supervisor/SupervisorRepoClamp.cs
@@ -44,10 +44,20 @@ public static IReadOnlyList IntersectWithBoundRepos(Jso
// S1: the launch base pin is SERVER truth, never a model authoring — each granted entry takes the BOUND
// spec's pin (a dispatched agent's mounts materialize the same base as its homogeneous siblings), and a
// model-authored pinnedSha on the subset is discarded outright (a dispatch must not point a bound mount at
- // an arbitrary commit).
+ // an arbitrary commit). The clone ref is the same kind of truth: it is what the child checks out, and for
+ // read-only context it is the ref its tool calls are then pinned to, so it is the operator's binding too — a
+ // model-authored ref, soft fallback or recovery anchor is discarded for the bound spec's own.
var boundPins = boundRelated.Where(b => !string.IsNullOrWhiteSpace(b.PinnedSha)).ToDictionary(b => b.RepositoryId, b => b.PinnedSha);
- return authored.Select(repo => repo with { PinnedSha = boundPins.TryGetValue(repo.RepositoryId, out var pin) ? pin : null }).ToList();
+ return authored.Select(repo => WithBoundRef(repo, boundRelated) with { PinnedSha = boundPins.TryGetValue(repo.RepositoryId, out var pin) ? pin : null }).ToList();
+ }
+
+ /// The authored entry carrying the operator's bound ref for its repository — none (the default branch) when the operator bound none, as for its primary.
+ private static WorkspaceRepositorySpec WithBoundRef(WorkspaceRepositorySpec authored, IReadOnlyList boundRelated)
+ {
+ var bound = boundRelated.FirstOrDefault(b => b.RepositoryId == authored.RepositoryId);
+
+ return authored with { Ref = bound?.Ref, RefSoftFallback = bound?.RefSoftFallback ?? false, RefRecoverySha = bound?.RefRecoverySha };
}
///
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentRunCommandNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentRunCommandNode.cs
index c241964d8..f201cf674 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentRunCommandNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentRunCommandNode.cs
@@ -69,6 +69,9 @@ public AgentRunCommandNode(IRunCommandService runCommand, IArtifactStore artifac
IsSideEffecting = true,
// Synchronous + standalone → exposable as an agent tool (a destructive, approval-gated one).
IsAgentToolEligible = true,
+ // Called by an agent, the repository must be one its run is bound to, and read-only context is checked out only at
+ // its bound or default branch. Not a repository write: the clone carries no push credential.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId", RefInputKey = "branch" },
ConfigSchema = SchemaBuilder.EmptyObject(),
InputSchema = SchemaBuilder.Parse("""
{
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrChecksNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrChecksNode.cs
index 90817a7a4..c31accdb5 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrChecksNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrChecksNode.cs
@@ -41,6 +41,8 @@ public GitFetchPrChecksNode(IPullRequestService prService)
Description = "Fetches a pull/merge request's CI checks and a green/pending/failed summary — wire allPassed into an If/else to gate on CI.",
// Synchronous + read-only → exposable as an agent tool (a non-destructive one).
IsAgentToolEligible = true,
+ // Called by an agent, only a repository its run is bound to.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId" },
// x-intent: always-first plain-language summary composed from the live inputs (repositoryId → repo
// NAME; a bound {{ref}} → chip; unset → the x-intentPlaceholders prompt). Display-only metadata.
ConfigSchema = SchemaBuilder.Parse("""
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrDiffNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrDiffNode.cs
index afbb7d781..b1abc7eaa 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrDiffNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitFetchPrDiffNode.cs
@@ -34,6 +34,8 @@ public GitFetchPrDiffNode(IPullRequestService prService)
Description = "Fetches the unified diff for a pull/merge request.",
// Synchronous + read-only → exposable as an agent tool (a non-destructive one).
IsAgentToolEligible = true,
+ // Called by an agent, only a repository its run is bound to.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId" },
// x-intent: always-first plain-language summary composed from the live inputs (repositoryId → repo
// NAME; a bound {{ref}} → chip; unset → the x-intentPlaceholders prompt). Display-only metadata.
ConfigSchema = SchemaBuilder.Parse("""
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitListPullRequestsNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitListPullRequestsNode.cs
index 09d22dc00..8348d21f5 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitListPullRequestsNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitListPullRequestsNode.cs
@@ -38,6 +38,8 @@ public GitListPullRequestsNode(IPullRequestService prService)
Description = "Lists the pull/merge requests on a repository, optionally filtered by state.",
// Synchronous + read-only → exposable as an agent tool (a non-destructive one).
IsAgentToolEligible = true,
+ // Called by an agent, only a repository its run is bound to.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId" },
// x-intent: always-first plain-language summary composed from the live inputs (repositoryId → repo
// NAME; a bound {{ref}} → chip; unset → the x-intentPlaceholders prompt). Display-only metadata.
ConfigSchema = SchemaBuilder.Parse("""
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs
index 81dc20694..57fa57a47 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs
@@ -50,6 +50,8 @@ public GitMergePullRequestNode(IPullRequestService prService)
// tool-invoked merge acts as the repo CONNECTION credential, never a specific user. The ledger's
// agent_run_id provides traceability.
IsAgentToolEligible = true,
+ // Called by an agent, only a repository its run is bound to — and a write, so a patch-only repository refuses it.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId", WritesRepository = true },
AlwaysRequiresApproval = true,
ActsAsUser = new ActsAsUserSpec { ActorInputKey = "actAsUserId", ProviderInputKey = "repositoryId", ProviderSource = ActorProviderSource.Repository, CapabilityType = typeof(IPullRequestWriteCapability) },
// x-intent: always-first plain-language summary composed from the live inputs (repositoryId → repo
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs
index 81b410d30..ff3d73bed 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs
@@ -50,6 +50,8 @@ public GitOpenPullRequestNode(IPullRequestService prService)
// tool-invoked open acts as the repo CONNECTION credential, never a specific user. The ledger's
// agent_run_id provides traceability.
IsAgentToolEligible = true,
+ // Called by an agent, only a repository its run is bound to — and a write, so a patch-only repository refuses it.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId", WritesRepository = true },
// Acts AS the actor's own identity (Model B), same generic gating as git.pr_review: when this node
// sits downstream of an interactive wait feeding actAsUserId, the engine gates the responder's
// linked identity — no chat/engine changes for future act-as-user nodes.
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPostPrCommentNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPostPrCommentNode.cs
index 87317b3e6..e779daaeb 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPostPrCommentNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPostPrCommentNode.cs
@@ -43,6 +43,8 @@ public GitPostPrCommentNode(IPullRequestService prService)
// tool-invoked comment acts as the repo CONNECTION credential, not a specific user. The ledger's
// agent_run_id provides traceability.
IsAgentToolEligible = true,
+ // Called by an agent, only a repository its run is bound to — and a write, so a patch-only repository refuses it.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId", WritesRepository = true },
// x-intent: always-first plain-language summary composed from the live inputs (repositoryId → repo
// NAME; a bound {{ref}} → chip; unset → the x-intentPlaceholders prompt). Display-only metadata.
ConfigSchema = SchemaBuilder.Parse("""
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs
index c31385816..238aa0dc3 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs
@@ -50,6 +50,8 @@ public GitPrReviewNode(IPullRequestService prService)
// tool-invoked review acts as the repo CONNECTION credential, never a specific user (so a model can't
// forge an APPROVE review as a teammate). The ledger's agent_run_id provides traceability.
IsAgentToolEligible = true,
+ // Called by an agent, only a repository its run is bound to — and a write, so a patch-only repository refuses it.
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId", WritesRepository = true },
// Acts AS the actor's own identity (Model B). Declaring this lets the engine generically gate
// the responder's linked identity when this node sits downstream of an interactive wait whose
// responder feeds actAsUserId — no chat/engine changes needed for future act-as-user nodes.
diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeManifest.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeManifest.cs
index a7ad08cd3..d51563009 100644
--- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeManifest.cs
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeManifest.cs
@@ -116,6 +116,17 @@ public sealed record NodeManifest
///
public ActsAsUserSpec? ActsAsUser { get; init; }
+ ///
+ /// Opt-in marker for a node that acts on a repository named by one of its inputs. Declaring it holds the value to the
+ /// calling run's bound repositories when the node runs as an agent tool (NodeAgentTool): a repository the run
+ /// is not bound to reads as "not found" before the node runs, a node that writes the repository reaches only one bound
+ /// writable and also meets the repository's own publish policy, and a ref it checks out of read-only context is held
+ /// to the bound or default branch. Generic, like — a new repository-taking node is covered
+ /// by declaring the spec, without its key being named anywhere else. Null ⇒ the node names no repository. Off the
+ /// agent-tool path it changes nothing: a workflow node resolves its authored repository within its team, as before.
+ ///
+ public RepositoryInputSpec? RepositoryInput { get; init; }
+
///
/// Optional author-facing starter templates for this node type. Each preset is a named, ready-to-use
/// (Config, Inputs) pair the editor offers as "start from a template" — a friendly surface over the
@@ -230,6 +241,28 @@ public sealed record ActsAsUserSpec
public Type? CapabilityType { get; init; }
}
+/// Declares the input naming the repository a node acts on — see .
+public sealed record RepositoryInputSpec
+{
+ /// Input key whose value is the id of the repository the node acts on.
+ public required string InputKey { get; init; }
+
+ ///
+ /// True when the node writes to that repository through its provider — opens, merges, reviews or comments on a pull
+ /// request — rather than only reading it. Such a write from an agent reaches only a repository its run is bound to
+ /// with write access, and meets the same publish policy an agent's pushed branch does, so a patch-only repository
+ /// refuses it. False for a reader, and for a command that only clones.
+ ///
+ public bool WritesRepository { get; init; }
+
+ ///
+ /// Input key whose value is the ref the node checks out of that repository, when it takes one. Called by an agent,
+ /// read-only context is checked out only at its bound or default branch. Null ⇒ the node checks out no ref the
+ /// caller picks.
+ ///
+ public string? RefInputKey { get; init; }
+}
+
/// How an act-as-user node's provider-input value resolves to a provider instance.
public enum ActorProviderSource
{
diff --git a/backend/src/CodeSpace.Messages/Agents/AgentRepositoryUse.cs b/backend/src/CodeSpace.Messages/Agents/AgentRepositoryUse.cs
new file mode 100644
index 000000000..bddb06972
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Agents/AgentRepositoryUse.cs
@@ -0,0 +1,21 @@
+namespace CodeSpace.Messages.Agents;
+
+///
+/// One agent tool call's use of a repository its run is bound to, as the repository's own policy judges it before the
+/// call runs: the run's team, the run's binding of the repository, the ref a command would check out of it, and whether
+/// the call writes to it through its provider. Built by NodeAgentTool from the node's declared repository input.
+///
+public sealed record AgentRepositoryUse
+{
+ /// The calling run's team — the only team the repository is resolved in.
+ public required Guid TeamId { get; init; }
+
+ /// The run's binding of the repository the call names: its id, access and bound ref.
+ public required WorkspaceRepositorySpec Bound { get; init; }
+
+ /// The ref a command would check out of the repository, as the node reads it — null for the default branch, or for a node that checks out nothing.
+ public string? Ref { get; init; }
+
+ /// True when the call writes to the repository through its provider — opens, merges, reviews or comments on a pull request.
+ public bool Writes { get; init; }
+}
diff --git a/backend/src/CodeSpace.Messages/Agents/AgentRunPosture.cs b/backend/src/CodeSpace.Messages/Agents/AgentRunPosture.cs
index 69a4b560b..38a3b0e37 100644
--- a/backend/src/CodeSpace.Messages/Agents/AgentRunPosture.cs
+++ b/backend/src/CodeSpace.Messages/Agents/AgentRunPosture.cs
@@ -1,11 +1,12 @@
namespace CodeSpace.Messages.Agents;
///
-/// The sandbox posture of the agent run a tool call serves: its autonomy tier and the permissions its own sandbox was
-/// launched with. A tool that starts a sandbox of its own on the run's behalf (agent.run_command) runs it no
-/// wider than this, so a tool call can never reach a network, a host or a resource ceiling the calling agent was
-/// denied. Stamped by the run's MCP endpoint from the run's task; absent off the agent-tool path, where a workflow node
-/// keeps its own authored posture.
+/// The sandbox posture of the agent run a tool call serves: its autonomy tier, the permissions its own sandbox was
+/// launched with, and the repositories it is bound to. A tool that starts a sandbox of its own on the run's behalf
+/// (agent.run_command) runs it no wider than this, so a tool call can never reach a network, a host or a resource
+/// ceiling the calling agent was denied — nor a repository, or a ref of read-only context, its run was never bound to.
+/// Stamped by the run's MCP endpoint from the run's task; absent off the agent-tool path, where a workflow node keeps its
+/// own authored posture.
///
public sealed record AgentRunPosture
{
@@ -17,4 +18,11 @@ public sealed record AgentRunPosture
/// The run's effective permissions — its network and egress allowlist.
public required AgentPermissions Permissions { get; init; }
+
+ ///
+ /// The repositories the run is bound to — its workspace's repositories, each with its access and ref — and the only
+ /// ones a tool call may name. Empty (the default) binds none: a posture stamped without a binding reaches no
+ /// repository, never every repository of the team.
+ ///
+ public IReadOnlyList Repositories { get; init; } = [];
}
diff --git a/backend/src/CodeSpace.Messages/Agents/McpCatalogMode.cs b/backend/src/CodeSpace.Messages/Agents/McpCatalogMode.cs
index 641502255..224b01225 100644
--- a/backend/src/CodeSpace.Messages/Agents/McpCatalogMode.cs
+++ b/backend/src/CodeSpace.Messages/Agents/McpCatalogMode.cs
@@ -10,6 +10,10 @@ namespace CodeSpace.Messages.Agents;
/// - — the whole registry, exactly as before. Selected only by the existing opt-in
/// (the per-run AgentTask.EnableMcpEndpoint, else the committed default), so a
/// run that opted into the side-effecting fabric is byte-identical to the pre-default-read-only behavior.
+/// - — every tool that does not write: the read-only tools plus an ask
+/// (decision.request). Selected for a run that opted into the fabric but whose write scope is read-only (an
+/// agent.run with readOnly, or a Confined tier): "analysis only, no writes" holds for the tools it is
+/// handed, while it can still ask a human.
///
/// The split is purely about WHICH tools the catalog serves; the per-call autonomy gate + governance still apply on
/// top (a side-effecting tool in mode is still tier-gated and ledger-tracked as before).
@@ -18,4 +22,5 @@ public enum McpCatalogMode
{
ReadOnly = 0,
Full = 1,
+ NonDestructive = 2,
}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentMcpEndpointFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentMcpEndpointFlowTests.cs
index 4aff302fd..b4efe62e9 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentMcpEndpointFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentMcpEndpointFlowTests.cs
@@ -10,6 +10,7 @@
using CodeSpace.Core.Services.Agents.Mcp;
using CodeSpace.Core.Services.Agents.Sandbox;
using CodeSpace.Core.Services.Agents.Sandbox.Runners;
+using CodeSpace.Core.Services.Agents.Tools;
using CodeSpace.Core.Services.Agents.Workspace;
using CodeSpace.Core.Services.Decisions;
using CodeSpace.IntegrationTests.Infrastructure;
@@ -89,9 +90,9 @@ public async Task Real_execute_opens_the_endpoint_serves_initialize_tools_list_a
await SeedLocalRepoAsync(origin.Path, "README.md", "hello-from-team-a");
var repoId = await SeedRepositoryAsync(teamId, new Uri(origin.Path).AbsoluteUri, "main");
- // A run with team A's autonomy at Unleashed (so the destructive agent.run_command is gated-Allow), sleeping so
- // the endpoint stays open while we drive JSON-RPC over it.
- var runId = await CreateRunAsync(teamId, AgentAutonomyLevel.Unleashed);
+ // A run with team A's autonomy at Unleashed (so the destructive agent.run_command is gated-Allow), bound to the
+ // repository it reads, sleeping so the endpoint stays open while we drive JSON-RPC over it.
+ var runId = await CreateRunAsync(teamId, AgentAutonomyLevel.Unleashed, workspace: WorkspaceSpec.FromRepository(repoId));
using var connects = ConnectRegistryFromFixture();
var run = RunExecutorInBackground(runId, new ScriptedHarness("sleep 6"));
@@ -150,10 +151,10 @@ public async Task A_real_codespace_mcp_proxy_process_drives_a_full_session_over_
await SeedLocalRepoAsync(originB.Path, "README.md", "secret-of-team-b");
var teamBRepoId = await SeedRepositoryAsync(teamB, new Uri(originB.Path).AbsoluteUri, "main");
- // Unleashed so the destructive agent.run_command is gated-Allow. A LONG-sleeping harness keeps the endpoint open;
- // we cancel the worker the instant the session asserts pass (the cancel-decouple pattern), so the test is bounded
- // by the choreography, not the sleep.
- var runId = await CreateRunAsync(teamId, AgentAutonomyLevel.Unleashed);
+ // Unleashed so the destructive agent.run_command is gated-Allow; bound to team A's repository, the one it reads.
+ // A LONG-sleeping harness keeps the endpoint open; we cancel the worker the instant the session asserts pass (the
+ // cancel-decouple pattern), so the test is bounded by the choreography, not the sleep.
+ var runId = await CreateRunAsync(teamId, AgentAutonomyLevel.Unleashed, workspace: WorkspaceSpec.FromRepository(repoId));
using var connects = ConnectRegistryFromFixture();
using var workerCts = new CancellationTokenSource();
@@ -420,10 +421,11 @@ public async Task Endpoint_at_Confined_denies_a_destructive_tool_before_executio
var connect = await WaitForConnectAsync(connects, runId);
await using var client = await McpClient.ConnectAsync(connect);
- // agent.run_command is destructive → gated. At Confined the autonomy gate denies it before any clone is tried.
+ // agent.run_command is destructive. A Confined run is admitted with a read-only write scope, so the endpoint does
+ // not even serve it — refused before the gate, before any clone is tried.
var call = await client.CallToolAsync(1, "agent.run_command", new { command = "true" });
call.GetProperty("isError").GetBoolean().ShouldBeTrue();
- Text(call).ShouldContain("not permitted", customMessage: "a destructive tool at Confined is denied before execution");
+ Text(call).ShouldContain("served no tool that writes", customMessage: "a destructive tool at Confined is refused before execution");
await run;
}
@@ -471,6 +473,9 @@ public async Task A_team_A_endpoint_naming_team_Bs_repo_fails_closed_without_lea
await SeedLocalRepoAsync(origin.Path, "README.md", "secret-of-team-b");
var teamBRepoId = await SeedRepositoryAsync(teamB, new Uri(origin.Path).AbsoluteUri, "main");
+ // No admitted run can be bound to another team's repository (its workspace would not even clone), so what refuses
+ // team B's id over a real endpoint is the run's binding. The tenant filter behind it is pinned where a binding can
+ // be forced: McpToolTeamScopeFlowTests (through the handler) and AgentToolRepositoryBindingFlowTests (the service).
var runId = await CreateRunAsync(teamA, AgentAutonomyLevel.Unleashed);
using var connects = ConnectRegistryFromFixture();
@@ -481,12 +486,117 @@ public async Task A_team_A_endpoint_naming_team_Bs_repo_fails_closed_without_lea
var call = await client.CallToolAsync(1, "agent.run_command", new { repositoryId = teamBRepoId.ToString(), command = "cat", args = new[] { "README.md" } });
call.GetProperty("isError").GetBoolean().ShouldBeTrue(customMessage: "a cross-team repo id must fail closed, never clone");
- Text(call).ShouldContain("not found", customMessage: "a cross-team repo is indistinguishable from a missing one");
+ Text(call).ShouldBe(AgentRepositoryBinding.NotFound(teamBRepoId), "a cross-team repo is indistinguishable from a missing or unbound one");
Text(call).ShouldNotContain("secret-of-team-b");
await run;
}
+ // ── The run's repository binding, end to end (Tier 🟢 high-fidelity) ───────────────────────────────────────────
+ //
+ // The audit probe's chain, driven from the producer: the bound set is stamped by the REAL executor from the run's
+ // ADMITTED task (OpenMcpEndpoint), served over the real per-run socket, held by the real NodeAgentTool, and the
+ // command clones through the real RunCommandService → LocalGitWorkspaceProvider → LocalProcessRunner from file://
+ // remotes on real Postgres. A handler built by hand would prove nothing about what production stamps.
+
+ [Fact]
+ public async Task A_runs_own_endpoint_reaches_only_the_repositories_its_task_bound_at_the_refs_it_bound_them()
+ {
+ if (OperatingSystem.IsWindows()) return;
+ if (!Socket.OSSupportsUnixDomainSockets) return;
+ if (!await GitAvailableAsync()) return;
+
+ var teamId = await SeedTeamAsync();
+ using var primaryOrigin = new TempDir();
+ using var contextOrigin = new TempDir();
+ using var unboundOrigin = new TempDir();
+ await SeedLocalRepoAsync(primaryOrigin.Path, "README.md", "primary-readme");
+ await SeedLocalRepoAsync(contextOrigin.Path, "README.md", "context-readme");
+ await SeedUnmergedBranchAsync(contextOrigin.Path, "SECRET.txt", "CONTEXT-UNMERGED-SECRET");
+ await SeedLocalRepoAsync(unboundOrigin.Path, "README.md", "unbound-readme");
+ await SeedUnmergedBranchAsync(unboundOrigin.Path, "SECRET.txt", "UNBOUND-UNMERGED-SECRET");
+ var primary = await SeedRepositoryAsync(teamId, new Uri(primaryOrigin.Path).AbsoluteUri, "main");
+ var context = await SeedRepositoryAsync(teamId, new Uri(contextOrigin.Path).AbsoluteUri, "main");
+ var unbound = await SeedRepositoryAsync(teamId, new Uri(unboundOrigin.Path).AbsoluteUri, "main"); // SAME team, never bound
+
+ // Network off and write scope read-only would not have stopped the audit's read; only the binding does. The run
+ // works in `primary` and reads `context` as read-only context at its default branch.
+ var workspace = WorkspaceSpec.FromAuthoredRepos(primary, null, [new WorkspaceRepositorySpec { Alias = "ctx", RepositoryId = context, Access = WorkspaceAccess.Read }]);
+ var runId = await CreateRunAsync(teamId, AgentAutonomyLevel.Unleashed, permissions: new AgentPermissions { Network = AgentNetworkAccess.Off }, workspace: workspace);
+
+ using var connects = ConnectRegistryFromFixture();
+ using var workerCts = new CancellationTokenSource();
+ var run = Task.Run(() => ExecuteAsync(runId, new ScriptedHarness("sleep 120"), cancellationToken: workerCts.Token));
+
+ try
+ {
+ var connect = await WaitForConnectAsync(connects, runId, run);
+ await using var client = await McpClient.ConnectAsync(connect);
+
+ var own = await client.CallToolAsync(1, "agent.run_command", new { repositoryId = primary.ToString(), command = "cat", args = new[] { "README.md" } });
+ var contextDefault = await client.CallToolAsync(2, "agent.run_command", new { repositoryId = context.ToString(), command = "cat", args = new[] { "README.md" } });
+ var contextUnmerged = await client.CallToolAsync(3, "agent.run_command", new { repositoryId = context.ToString(), branch = "secret-branch", command = "cat", args = new[] { "SECRET.txt" } });
+ var unboundUnmerged = await client.CallToolAsync(4, "agent.run_command", new { repositoryId = unbound.ToString(), branch = "secret-branch", command = "cat", args = new[] { "SECRET.txt" } });
+ var unboundDiff = await client.CallToolAsync(5, "git.fetch_pr_diff", new { repositoryId = unbound.ToString(), number = 1 });
+
+ Text(own).ShouldContain("primary-readme", customMessage: $"the run's own repository is reachable: {own.GetRawText()}");
+ Text(contextDefault).ShouldContain("context-readme", customMessage: $"read-only context at its bound (default) branch is reachable: {contextDefault.GetRawText()}");
+
+ contextUnmerged.GetProperty("isError").GetBoolean().ShouldBeTrue(customMessage: "read-only context is pinned to its bound or default branch");
+ contextUnmerged.GetRawText().ShouldNotContain("CONTEXT-UNMERGED-SECRET");
+
+ foreach (var refused in new[] { unboundUnmerged, unboundDiff })
+ {
+ refused.GetProperty("isError").GetBoolean().ShouldBeTrue(customMessage: $"a same-team repository the task never bound must be refused: {refused.GetRawText()}");
+ Text(refused).ShouldBe(AgentRepositoryBinding.NotFound(unbound), "refused as not found — the same answer a missing or foreign repository gets");
+ refused.GetRawText().ShouldNotContain("UNBOUND-UNMERGED-SECRET");
+ }
+ }
+ finally
+ {
+ workerCts.Cancel();
+ try { await run; } catch (OperationCanceledException) { /* worker death — expected, decouples from the 120s sleep */ }
+ }
+ }
+
+ [Theory]
+ [InlineData(AgentAutonomyLevel.Unleashed, AgentWriteScope.ReadOnly)] // readOnly=true on an agent.run node: only the write scope can withhold the writes
+ [InlineData(AgentAutonomyLevel.Confined, AgentWriteScope.Workspace)] // a Confined tier is admitted with a read-only write scope whatever it asked for
+ public async Task A_read_only_run_is_served_no_side_effecting_tool_over_its_endpoint_yet_can_still_ask_a_human(AgentAutonomyLevel autonomy, AgentWriteScope requestedScope)
+ {
+ if (OperatingSystem.IsWindows()) return;
+ if (!Socket.OSSupportsUnixDomainSockets) return;
+
+ // "Analysis-only (no writes), regardless of the autonomy level" — but an ask is not a write, and a Confined tier
+ // must never be denied a question: decision.request is the run's only way to ask a human mid-run.
+ var teamId = await SeedTeamAsync();
+ var runId = await CreateRunAsync(teamId, autonomy, permissions: new AgentPermissions { Network = AgentNetworkAccess.Off, WriteScope = requestedScope });
+
+ using var connects = ConnectRegistryFromFixture();
+ var run = Task.Run(() => ExecuteAsync(runId, new ScriptedHarness("sleep 6")));
+
+ var connect = await WaitForConnectAsync(connects, runId, run);
+ await using var client = await McpClient.ConnectAsync(connect);
+
+ var tools = ToolNames(await client.ExchangeAsync(1, "tools/list"));
+ tools.ShouldContain("git.list_prs", customMessage: "a read-only run still reads");
+ tools.ShouldContain(DecisionRequestTool.ToolKind, customMessage: "a read-only run can still ask a human");
+ tools.ShouldNotContain("agent.run_command");
+ tools.ShouldNotContain("git.open_pr");
+ tools.ShouldNotContain("git.merge_pr", customMessage: "a read-only run must not be handed an irreversible write");
+
+ var merge = await client.CallToolAsync(2, "git.merge_pr", new { repositoryId = Guid.NewGuid().ToString(), number = 1 });
+ merge.GetProperty("isError").GetBoolean().ShouldBeTrue();
+ Text(merge).ShouldContain("read-only", customMessage: "a guessed write name is refused before the gate could park it for approval");
+
+ // A question with no text is the cheapest call that proves the ask reached the decision flow — past the catalog and
+ // the decision substrate's own check — without parking a real decision and blocking this test on a human.
+ var ask = await client.CallToolAsync(3, DecisionRequestTool.ToolKind, new { });
+ Text(ask).ShouldBe("decision.request requires a non-empty 'question'.", "the ask is served and reaches the decision flow, not a read-only refusal");
+
+ await run;
+ }
+
[Fact]
public async Task Connecting_with_a_wrong_token_is_refused()
{
@@ -898,8 +1008,9 @@ public async Task A_side_effecting_governed_call_writes_a_ledger_row_a_read_only
await SeedLocalRepoAsync(origin.Path, "README.md", "hello-from-team-a");
var repoId = await SeedRepositoryAsync(teamId, new Uri(origin.Path).AbsoluteUri, "main");
- // Unleashed so the destructive agent.run_command is gated-Allow (runs once through the ledger, not parked).
- var runId = await CreateRunAsync(teamId, AgentAutonomyLevel.Unleashed);
+ // Unleashed so the destructive agent.run_command is gated-Allow (runs once through the ledger, not parked); bound to
+ // the repository both calls name.
+ var runId = await CreateRunAsync(teamId, AgentAutonomyLevel.Unleashed, workspace: WorkspaceSpec.FromRepository(repoId));
using var connects = ConnectRegistryFromFixture();
// Endpoint AND governance ON → the side-effecting path routes through the exactly-once ToolCallLedger.
@@ -1504,12 +1615,12 @@ private static string[] ToolNames(JsonElement listResponse) =>
// ── Seeding (mirrors McpToolTeamScopeFlowTests + AgentRunExecutorTests) ──
- /// is the per-run catalog choice — null takes the committed default (full), false narrows the run to the read-only slice. It replaced the ambient env flag the helpers used to set.
- private async Task CreateRunAsync(Guid teamId, AgentAutonomyLevel autonomy, IReadOnlyList? tools = null, bool? enableMcp = null, string harnessKind = "scripted", string? model = "test-model", AgentPermissions? permissions = null)
+ /// is the per-run catalog choice — null takes the committed default (full), false narrows the run to the read-only slice. It replaced the ambient env flag the helpers used to set. is the repositories the run is bound to — the only ones its tools may reach.
+ private async Task CreateRunAsync(Guid teamId, AgentAutonomyLevel autonomy, IReadOnlyList? tools = null, bool? enableMcp = null, string harnessKind = "scripted", string? model = "test-model", AgentPermissions? permissions = null, WorkspaceSpec? workspace = null)
{
using var scope = _fixture.BeginScopeAs(_operators[teamId], teamId);
var run = await scope.Resolve().CreateAsync(
- new AgentTask { Goal = "scripted", Harness = harnessKind, Model = model, TimeoutSeconds = 1800, Autonomy = autonomy, Permissions = permissions ?? new(), Tools = tools, EnableMcpEndpoint = enableMcp },
+ new AgentTask { Goal = "scripted", Harness = harnessKind, Model = model, TimeoutSeconds = 1800, Autonomy = autonomy, Permissions = permissions ?? new(), Tools = tools, EnableMcpEndpoint = enableMcp, Workspace = workspace },
teamId, null, null, iterationKey: "", cancellationToken: CancellationToken.None);
return run.Id;
}
@@ -1537,7 +1648,8 @@ private async Task SeedRepositoryAsync(Guid teamId, string cloneUrlHttps,
var db = scope.Resolve();
var instanceId = Guid.NewGuid();
- db.ProviderInstance.Add(new ProviderInstance { Id = instanceId, TeamId = teamId, Provider = ProviderKind.Git, DisplayName = "local", BaseUrl = "https://local" });
+ // One instance per repository, so a team that holds several needs a distinct base URL for each (the instance is unique per team + provider + URL).
+ db.ProviderInstance.Add(new ProviderInstance { Id = instanceId, TeamId = teamId, Provider = ProviderKind.Git, DisplayName = "local", BaseUrl = $"https://local-{instanceId:N}" });
var repoId = Guid.NewGuid();
db.Repository.Add(new Repository
@@ -1568,6 +1680,16 @@ private static async Task SeedLocalRepoAsync(string dir, string file, string con
await RunGitInAsync(dir, "commit", "-m", "seed");
}
+ /// Commit on a secret-branch that is never merged, then return the origin to main.
+ private static async Task SeedUnmergedBranchAsync(string dir, string file, string content)
+ {
+ await RunGitInAsync(dir, "checkout", "-b", "secret-branch");
+ await File.WriteAllTextAsync(Path.Combine(dir, file), content);
+ await RunGitInAsync(dir, "add", ".");
+ await RunGitInAsync(dir, "commit", "-m", "unmerged work");
+ await RunGitInAsync(dir, "checkout", "main");
+ }
+
private static async Task RunGitInAsync(string workdir, params string[] args)
{
var result = await new LocalProcessRunner().RunAsync(
diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRegistryFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRegistryFlowTests.cs
index 2e9340f15..d25199388 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRegistryFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRegistryFlowTests.cs
@@ -2,6 +2,7 @@
using Autofac;
using CodeSpace.Core.Services.Agents;
using CodeSpace.Core.Services.Agents.Tools;
+using CodeSpace.Core.Services.Workflows.Nodes;
using CodeSpace.IntegrationTests.Infrastructure;
using CodeSpace.Messages.Agents;
using Shouldly;
@@ -118,6 +119,30 @@ public void Git_merge_pr_is_projected_by_the_real_container_as_an_always_approve
.ShouldBe(AgentToolGateDecision.RequireApproval, "git.merge_pr at Unleashed escalates to RequireApproval — never Allow");
}
+ [Fact]
+ public void Every_tool_eligible_node_that_offers_a_repository_input_declares_it_so_a_run_is_held_to_its_bound_repositories()
+ {
+ // Forward-looking guard over the REAL node set (plugins included): the run-binding is enforced generically off
+ // NodeManifest.RepositoryInput, so an eligible node that offers the model a repository selector but does not
+ // declare it would reach any repository of the team again. Detected from the schema itself, not a hand-list.
+ using var scope = _fixture.BeginScope();
+
+ var offenders = scope.Resolve>()
+ .Where(node => node.Manifest.IsAgentToolEligible)
+ .SelectMany(node => RepositorySelectorKeys(node.Manifest.InputSchema).Select(key => (node.TypeKey, Key: key, Declared: node.Manifest.RepositoryInput?.InputKey)))
+ .Where(offer => offer.Declared != offer.Key)
+ .Select(offer => $"{offer.TypeKey}.{offer.Key} (declared: {offer.Declared ?? "none"})")
+ .ToList();
+
+ offenders.ShouldBeEmpty("every repository selector a tool offers must be its declared RepositoryInput");
+ scope.Resolve>().Count(node => node.Manifest.IsAgentToolEligible && node.Manifest.RepositoryInput is not null).ShouldBeGreaterThanOrEqualTo(8, "fixture check: the eight builtin repository tools are in the graph this guard walks");
+ }
+
+ private static IEnumerable RepositorySelectorKeys(JsonElement inputSchema) =>
+ inputSchema.TryGetProperty("properties", out var properties)
+ ? properties.EnumerateObject().Where(p => p.Value.TryGetProperty("x-selector", out var selector) && selector.GetString() == "repository").Select(p => p.Name)
+ : [];
+
// Forward-looking guard: every currently-eligible repo-resolving tool MUST refuse a repositoryId when the
// call carries no team (no sys.team_id). If a future eligible node forgets the NodeScopeReader.TryReadTeamId
// check, this fails — catching a silently-reintroduced cross-tenant hole. The eligible repo-resolving builtins
diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRepositoryBindingFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRepositoryBindingFlowTests.cs
new file mode 100644
index 000000000..8e0faf2c0
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRepositoryBindingFlowTests.cs
@@ -0,0 +1,474 @@
+using System.Text.Json;
+using Autofac;
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Agents;
+using CodeSpace.Core.Services.Agents.Commands;
+using CodeSpace.Core.Services.Agents.Mcp;
+using CodeSpace.Core.Services.Agents.Sandbox.Runners;
+using CodeSpace.Core.Services.Agents.Tools;
+using CodeSpace.Core.Services.Agents.Workspace;
+using CodeSpace.Core.Services.Chat;
+using CodeSpace.Core.Services.Chat.Interactions;
+using CodeSpace.IntegrationTests.Infrastructure;
+using CodeSpace.IntegrationTests.Workflows.Infrastructure;
+using CodeSpace.Messages.Agents;
+using CodeSpace.Messages.Enums;
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+
+namespace CodeSpace.IntegrationTests.Agents;
+
+///
+/// An agent's tool call reaches only the repositories its run is bound to, over the production path end to end:
+/// McpRequestHandler (stamped with the run's bound repositories) → the real DI IAgentToolRegistry →
+/// NodeAgentTool → the real builtin node → RunCommandService / PullRequestService on real Postgres →
+/// a real git clone of a file:// remote. The team holds a SECOND repository the run is not bound to, carrying a
+/// secret on an unmerged branch: the shape the audit probe used to read it.
+///
+/// Covers the boundary on every repository-taking tool (an unbound same-team repository reads exactly like a
+/// foreign or missing one), the read-only ref pin, the read-only-context and patch-only refusals of every agent
+/// pull-request write, that each refusal is answered before a Standard-tier call is parked for a human's approval, that
+/// read-only context keeps its pull requests readable (the pin holds what a command checks out, nothing more), and that
+/// a workflow node's call — no calling run — is unchanged. Skips on Windows / without git so a cross-host
+/// dotnet test stays clean.
+///
+[Collection(PostgresCollection.Name)]
+[Trait("Category", "Integration")]
+public sealed class AgentToolRepositoryBindingFlowTests(PostgresFixture fixture)
+{
+ private const string BoundContent = "bound-repository-readme";
+ private const string UnboundSecret = "UNBOUND-SECRET-ON-UNMERGED-BRANCH";
+ private const string BoundSecret = "BOUND-REPO-UNMERGED-BRANCH-CONTENT";
+
+ public static TheoryData RepositoryReadTools => new() { "agent.run_command", "git.fetch_pr_diff", "git.fetch_pr_checks", "git.list_prs" };
+
+ public static TheoryData PullRequestWriteTools => new() { "git.open_pr", "git.merge_pr", "git.pr_review", "git.post_pr_comment" };
+
+ [Theory]
+ [MemberData(nameof(RepositoryReadTools))]
+ public async Task An_unbound_repository_of_the_runs_own_team_reads_exactly_like_a_foreign_or_missing_one(string kind)
+ {
+ if (!await GitReadyAsync()) return;
+
+ await using var world = await SeedWorldAsync();
+ var handler = HandlerBoundTo(world, Bound(world.BoundRepositoryId, WorkspaceAccess.Write));
+ var missing = Guid.NewGuid();
+
+ var unbound = await CallToolAsync(handler, kind, ArgumentsFor(world.UnboundRepositoryId, branch: "secret-branch", command: "cat", "SECRET.txt"));
+ var foreign = await CallToolAsync(handler, kind, ArgumentsFor(world.ForeignRepositoryId, branch: "secret-branch", command: "cat", "SECRET.txt"));
+ var absent = await CallToolAsync(handler, kind, ArgumentsFor(missing, branch: "secret-branch", command: "cat", "SECRET.txt"));
+
+ foreach (var result in new[] { unbound, foreign, absent }) result.GetProperty("isError").GetBoolean().ShouldBeTrue($"{kind}: {result.GetRawText()}");
+ Text(unbound).ShouldBe(AgentRepositoryBinding.NotFound(world.UnboundRepositoryId), $"{kind} must refuse a repository its run is not bound to as not found, even though the team owns it");
+ Text(unbound).Replace(world.UnboundRepositoryId.ToString(), "id").ShouldBe(Text(foreign).Replace(world.ForeignRepositoryId.ToString(), "id"), "same-team-unbound and foreign must be indistinguishable");
+ Text(unbound).Replace(world.UnboundRepositoryId.ToString(), "id").ShouldBe(Text(absent).Replace(missing.ToString(), "id"), "same-team-unbound and missing must be indistinguishable — no existence oracle");
+ unbound.GetRawText().ShouldNotContain(UnboundSecret, customMessage: "the unbound repository's content must never reach the model");
+ }
+
+ [Theory]
+ [MemberData(nameof(RepositoryReadTools))]
+ public async Task A_bound_repository_passes_the_binding_and_reaches_the_tool(string kind)
+ {
+ if (!await GitReadyAsync()) return;
+
+ await using var world = await SeedWorldAsync();
+ var handler = HandlerBoundTo(world, Bound(world.BoundRepositoryId, WorkspaceAccess.Write));
+
+ var result = await CallToolAsync(handler, kind, ArgumentsFor(world.BoundRepositoryId, branch: null, command: "cat", "README.md"));
+
+ // run_command reads the clone; the PR reads reach PullRequestService, which refuses this credential-less local
+ // repository on its own terms — either way the call got past the binding to the repository itself.
+ Text(result).ShouldNotBe(AgentRepositoryBinding.NotFound(world.BoundRepositoryId), $"{kind} must reach a repository its run is bound to");
+ if (kind == "agent.run_command") Text(result).ShouldContain(BoundContent);
+ }
+
+ [Theory]
+ // access branch allowed
+ [InlineData(WorkspaceAccess.Read, null, true)]
+ [InlineData(WorkspaceAccess.Read, "main", true)]
+ [InlineData(WorkspaceAccess.Read, "secret-branch", false)]
+ [InlineData(WorkspaceAccess.Write, "secret-branch", true)]
+ public async Task A_read_only_context_repository_checks_out_only_its_bound_or_default_branch(WorkspaceAccess access, string? branch, bool allowed)
+ {
+ if (!await GitReadyAsync()) return;
+
+ await using var world = await SeedWorldAsync();
+ var handler = HandlerBoundTo(world, Bound(world.BoundRepositoryId, access));
+ var file = branch == "secret-branch" ? "SECRET.txt" : "README.md";
+
+ var result = await CallToolAsync(handler, "agent.run_command", ArgumentsFor(world.BoundRepositoryId, branch, command: "cat", file));
+
+ result.GetProperty("isError").GetBoolean().ShouldBe(!allowed, $"{access} at '{branch ?? "(default)"}': {result.GetRawText()}");
+ if (allowed) Text(result).ShouldContain(branch == "secret-branch" ? BoundSecret : BoundContent);
+ else
+ {
+ Text(result).ShouldContain("read-only context", customMessage: "the refusal tells the agent why, so it can retry on the bound branch");
+ result.GetRawText().ShouldNotContain(BoundSecret, customMessage: "a branch outside the binding is never cloned");
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(PullRequestWriteTools))]
+ public async Task A_patch_only_repository_refuses_every_agent_pull_request_write_before_the_provider(string kind)
+ {
+ await using var world = await SeedWorldAsync(withGit: false);
+ var patchOnly = await SeedCredentialedRepositoryAsync(world.TeamId, world.OwnerUserId, RepositoryPublishMode.PatchOnly);
+ var branchMode = await SeedCredentialedRepositoryAsync(world.TeamId, world.OwnerUserId, RepositoryPublishMode.Branch);
+ using var scope = fixture.BeginScope();
+ var tool = scope.Resolve().Resolve(kind).ShouldNotBeNull();
+ var caller = Posture(Bound(patchOnly, WorkspaceAccess.Write), Bound(branchMode, WorkspaceAccess.Write));
+
+ // The tool itself, as the MCP dispatch invokes it once the gate (and, for a merge, a human) let the call through.
+ var refused = await OutcomeAsync(tool, new AgentToolCall { Input = WriteArguments(patchOnly), TeamId = world.TeamId, CallerPosture = caller });
+ var passed = await OutcomeAsync(tool, new AgentToolCall { Input = WriteArguments(branchMode), TeamId = world.TeamId, CallerPosture = caller });
+
+ refused.ShouldBe($"Repository {patchOnly} does not take pull-request writes from an agent: the repository requires patch-only publishing.");
+ passed.ShouldNotContain("patch-only", customMessage: $"a branch-mode repository's write reaches the pull-request service, which refuses this local provider on its own terms: {passed}");
+ passed.ShouldNotBe(AgentRepositoryBinding.NotFound(branchMode));
+ }
+
+ [Theory]
+ [MemberData(nameof(PullRequestWriteTools))]
+ public async Task A_read_only_context_repository_refuses_every_agent_pull_request_write_before_the_provider(string kind)
+ {
+ await using var world = await SeedWorldAsync(withGit: false);
+ var readContext = await SeedCredentialedRepositoryAsync(world.TeamId, world.OwnerUserId, RepositoryPublishMode.Branch);
+ var writable = await SeedCredentialedRepositoryAsync(world.TeamId, world.OwnerUserId, RepositoryPublishMode.Branch);
+ using var scope = fixture.BeginScope();
+ var tool = scope.Resolve().Resolve(kind).ShouldNotBeNull();
+ var caller = Posture(Bound(writable, WorkspaceAccess.Write), Bound(readContext, WorkspaceAccess.Read));
+
+ // Two branch-mode, credentialed repositories that differ only in how the run is bound to them. The writable one's
+ // write reaches the pull-request service (which refuses this local provider on its own terms); read-only context
+ // is refused on the binding's access, before its publish policy or its connection credential is ever reached.
+ var readCall = new AgentToolCall { Input = WriteArguments(readContext), TeamId = world.TeamId, CallerPosture = caller };
+ var onRead = await OutcomeAsync(tool, readCall);
+ var onWrite = await OutcomeAsync(tool, new AgentToolCall { Input = WriteArguments(writable), TeamId = world.TeamId, CallerPosture = caller });
+
+ onRead.ShouldBe(AgentRepositoryBinding.ReadOnlyContextWrite(readContext));
+ (await tool.RefusalAsync(readCall, CancellationToken.None)).ShouldBe(onRead, "the MCP dispatch's admission check gives the same answer before it would park the call");
+ onWrite.ShouldNotContain("read-only context", customMessage: $"fixture check: the writable twin's write gets past the binding: {onWrite}");
+ onWrite.ShouldNotBe(AgentRepositoryBinding.NotFound(writable));
+ }
+
+ [Theory]
+ [InlineData("git.fetch_pr_diff")]
+ [InlineData("git.list_prs")]
+ [InlineData("git.fetch_pr_checks")]
+ public async Task A_read_only_context_repository_keeps_its_pull_requests_readable_the_pin_holds_only_what_a_command_checks_out(string kind)
+ {
+ // The documented scope of the read-only ref pin (AgentRepositoryBinding): it holds the ref a command checks out,
+ // not what the provider publishes about a bound repository. A pull-request read of read-only context bound at main
+ // passes the binding to the provider layer — here the local provider, which serves no pull requests.
+ await using var world = await SeedWorldAsync(withGit: false);
+ var readContext = await SeedCredentialedRepositoryAsync(world.TeamId, world.OwnerUserId, RepositoryPublishMode.Branch);
+ var handler = HandlerBoundTo(world, Bound(readContext, WorkspaceAccess.Read) with { Ref = "main" });
+
+ var result = await CallToolAsync(handler, kind, ArgumentsFor(readContext, branch: null, command: "true"));
+
+ Text(result).ShouldNotBe(AgentRepositoryBinding.NotFound(readContext));
+ Text(result).ShouldNotContain("read-only context");
+ Text(result).ShouldContain("does not implement capability", customMessage: $"the read reached the provider layer: {Text(result)}");
+ }
+
+ [Theory]
+ // tool binding what the call names
+ [InlineData("agent.run_command", "unbound")]
+ [InlineData("git.open_pr", "unbound")]
+ [InlineData("git.merge_pr", "unbound")]
+ [InlineData("git.open_pr", "read-context")]
+ [InlineData("agent.run_command", "read-context-off-branch")]
+ [InlineData("git.post_pr_comment", "patch-only")]
+ public async Task A_standard_tier_call_that_would_be_refused_is_refused_at_once_with_no_approval_parked_or_posted(string kind, string target)
+ {
+ // Standard is the default tier: every side-effecting tool asks a human first. A call the tool would refuse anyway
+ // must not post a card, park a ledger row, or block the agent on the approval bound — it is answered at once.
+ await using var world = await SeedWorldAsync(withGit: false);
+ var patchOnly = await SeedCredentialedRepositoryAsync(world.TeamId, world.OwnerUserId, RepositoryPublishMode.PatchOnly);
+ var channelId = await SeedChannelAsync(world.TeamId, world.OwnerUserId);
+ var runId = Guid.NewGuid();
+ var (named, binding, branch) = target switch
+ {
+ "unbound" => (world.UnboundRepositoryId, Bound(world.BoundRepositoryId, WorkspaceAccess.Write), (string?)null),
+ "read-context" => (world.BoundRepositoryId, Bound(world.BoundRepositoryId, WorkspaceAccess.Read), null),
+ "read-context-off-branch" => (world.BoundRepositoryId, Bound(world.BoundRepositoryId, WorkspaceAccess.Read), "secret-branch"),
+ _ => (patchOnly, Bound(patchOnly, WorkspaceAccess.Write), null),
+ };
+
+ var previous = Environment.GetEnvironmentVariable(McpRequestHandler.ApprovalBoundSecondsEnvVar);
+ Environment.SetEnvironmentVariable(McpRequestHandler.ApprovalBoundSecondsEnvVar, "5"); // a regression parks and times out in seconds, never the 10-minute default
+
+ try
+ {
+ using var scope = fixture.BeginScope();
+ var handler = new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Standard, world.TeamId, null, runId,
+ scope.Resolve(), 0, governanceEnabled: true, approvalConversationId: channelId,
+ scope.Resolve(), scope.Resolve(), scope.Resolve(), repositories: [binding]);
+ var arguments = JsonSerializer.SerializeToElement(new Dictionary
+ {
+ ["repositoryId"] = named.ToString(), ["command"] = "true", ["branch"] = branch, ["number"] = 7, ["title"] = "t",
+ ["sourceBranch"] = "feature", ["targetBranch"] = "main", ["body"] = "b",
+ }.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value));
+
+ var result = await CallToolAsync(handler, kind, arguments);
+
+ result.GetProperty("isError").GetBoolean().ShouldBeTrue();
+ Text(result).ShouldBe(target switch
+ {
+ "unbound" => AgentRepositoryBinding.NotFound(named),
+ "read-context" => AgentRepositoryBinding.ReadOnlyContextWrite(named),
+ "read-context-off-branch" => AgentRepositoryBinding.RefOutsideBinding(named, binding, branch, "main"),
+ _ => $"Repository {named} does not take pull-request writes from an agent: the repository requires patch-only publishing.",
+ });
+ (await scope.Resolve().GetForRunAsync(runId, world.TeamId, CancellationToken.None)).ShouldBeEmpty("no ledger row is parked for a call that could only be refused");
+ (await CardCountAsync(world.TeamId, channelId)).ShouldBe(0, "no human is asked to approve a call that could only be refused");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(McpRequestHandler.ApprovalBoundSecondsEnvVar, previous);
+ }
+ }
+
+ [Fact]
+ public async Task A_patch_only_refusal_travels_the_real_mcp_dispatch_and_a_read_of_the_same_repository_does_not_meet_it()
+ {
+ await using var world = await SeedWorldAsync(withGit: false);
+ var patchOnly = await SeedCredentialedRepositoryAsync(world.TeamId, world.OwnerUserId, RepositoryPublishMode.PatchOnly);
+ var handler = HandlerBoundTo(world, Bound(patchOnly, WorkspaceAccess.Write));
+
+ // Unleashed: a reversible write is gate-Allowed, so only the repository's policy stands between it and the provider.
+ var comment = await CallToolAsync(handler, "git.post_pr_comment", WriteArguments(patchOnly));
+ var read = await CallToolAsync(handler, "git.list_prs", ArgumentsFor(patchOnly, branch: null, command: "true"));
+
+ comment.GetProperty("isError").GetBoolean().ShouldBeTrue();
+ Text(comment).ShouldContain("patch-only");
+ Text(read).ShouldNotContain("patch-only", customMessage: "the publish policy governs writes; reading a bound patch-only repository is untouched");
+ }
+
+ [Fact]
+ public async Task A_workflow_nodes_call_with_no_calling_run_reaches_any_repository_of_its_team_as_before()
+ {
+ if (!await GitReadyAsync()) return;
+
+ await using var world = await SeedWorldAsync();
+ using var scope = fixture.BeginScope();
+ var tool = scope.Resolve().Resolve("agent.run_command").ShouldNotBeNull();
+
+ // No CallerPosture: an authored workflow step's repository is the author's choice within the team, unchanged.
+ var result = await tool.CallAsync(new AgentToolCall { Input = ArgumentsFor(world.UnboundRepositoryId, "secret-branch", "cat", "SECRET.txt"), TeamId = world.TeamId }, CancellationToken.None);
+
+ result.IsError.ShouldBeFalse(result.Error);
+ result.Output.GetRawText().ShouldContain(UnboundSecret, customMessage: "fixture check: the unbound repository really holds the secret the agent path must never print");
+ }
+
+ [Fact]
+ public async Task RunCommandService_holds_an_agent_caller_to_its_binding_even_when_called_directly()
+ {
+ if (!await GitReadyAsync()) return;
+
+ await using var world = await SeedWorldAsync();
+ using var scope = fixture.BeginScope();
+ var service = scope.Resolve();
+ // The foreign repository is BOUND here, as no admitted run could be, so its call gets past the binding and the
+ // service's own tenant filter is what refuses it — the miss the binding's "not found" claims to be byte-identical to.
+ var caller = Posture(Bound(world.BoundRepositoryId, WorkspaceAccess.Read), Bound(world.ForeignRepositoryId, WorkspaceAccess.Write));
+
+ var unbound = await Should.ThrowAsync(() => service.RunAsync(new RunCommandRequest { RepositoryId = world.UnboundRepositoryId, TeamId = world.TeamId, Ref = "secret-branch", Command = "cat", Args = ["SECRET.txt"], CallerPosture = caller }, CancellationToken.None));
+ // README.md and a short timeout: were the tenant filter to fail open, the command must end in seconds (a bare
+ // `cat` would wait on stdin for the default ten minutes) and the assertion below names the regression.
+ var foreign = await Should.ThrowAsync(() => service.RunAsync(new RunCommandRequest { RepositoryId = world.ForeignRepositoryId, TeamId = world.TeamId, Command = "cat", Args = ["README.md"], TimeoutSeconds = 30, CallerPosture = caller }, CancellationToken.None));
+ var offBranch = await Should.ThrowAsync(() => service.RunAsync(new RunCommandRequest { RepositoryId = world.BoundRepositoryId, TeamId = world.TeamId, Ref = "secret-branch", Command = "cat", Args = ["SECRET.txt"], CallerPosture = caller }, CancellationToken.None));
+
+ unbound.Message.ShouldBe(AgentRepositoryBinding.NotFound(world.UnboundRepositoryId));
+ foreign.Message.ShouldBe(AgentRepositoryBinding.NotFound(world.ForeignRepositoryId), "the tenant filter's own miss is byte-identical to the binding's");
+ offBranch.Message.ShouldContain("read-only context");
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private McpRequestHandler HandlerBoundTo(World world, params WorkspaceRepositorySpec[] repositories)
+ {
+ var scope = fixture.BeginScope();
+ world.Own(scope);
+
+ return new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Unleashed, world.TeamId, runId: Guid.NewGuid(), repositories: repositories);
+ }
+
+ private async Task SeedChannelAsync(Guid teamId, Guid ownerUserId)
+ {
+ using var scope = fixture.BeginScope();
+ var slug = "bind-" + Guid.NewGuid().ToString("N")[..8];
+
+ return await scope.Resolve().CreateChannelAsync(teamId, slug, slug, isPrivate: false, ownerUserId, CancellationToken.None);
+ }
+
+ /// The interactive cards (an approval card is one) posted into .
+ private async Task CardCountAsync(Guid teamId, Guid channelId)
+ {
+ using var scope = fixture.BeginScope();
+
+ return await scope.Resolve().Message.AsNoTracking().CountAsync(m => m.ConversationId == channelId && m.TeamId == teamId && m.InteractionJson != null && m.DeletedDate == null);
+ }
+
+ private static AgentRunPosture Posture(params WorkspaceRepositorySpec[] repositories) =>
+ new() { RunId = Guid.NewGuid(), Autonomy = AgentAutonomyLevel.Unleashed, Permissions = AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Unleashed), Repositories = repositories };
+
+ private static WorkspaceRepositorySpec Bound(Guid repositoryId, WorkspaceAccess access) => new() { Alias = repositoryId.ToString("N"), RepositoryId = repositoryId, Access = access };
+
+ /// One argument bag every repository tool accepts: each node reads its own keys and ignores the rest, so a theory over tools needs no per-tool shape.
+ private static JsonElement ArgumentsFor(Guid repositoryId, string? branch, string command, params string[] args) => JsonSerializer.SerializeToElement(new Dictionary
+ {
+ ["repositoryId"] = repositoryId.ToString(),
+ ["number"] = 1,
+ ["state"] = "open",
+ ["command"] = command,
+ ["args"] = args,
+ ["branch"] = branch,
+ }.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value));
+
+ /// The required inputs of every pull-request write at once (open / merge / review / comment).
+ private static JsonElement WriteArguments(Guid repositoryId) => JsonSerializer.SerializeToElement(new
+ {
+ repositoryId = repositoryId.ToString(), number = 7, title = "t", sourceBranch = "feature", targetBranch = "main", body = "b", verdict = "comment",
+ });
+
+ /// What a write came back with — its error, or the message of what it threw past the node (the MCP dispatch maps a throw to the same tool error).
+ private static async Task OutcomeAsync(IAgentTool tool, AgentToolCall call)
+ {
+ try
+ {
+ var result = await tool.CallAsync(call, CancellationToken.None);
+ return result.IsError ? result.Error ?? "" : "ok";
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return ex.Message;
+ }
+ }
+
+ private static async Task CallToolAsync(McpRequestHandler handler, string name, JsonElement arguments)
+ {
+ var request = JsonSerializer.SerializeToElement(new { jsonrpc = "2.0", id = 1, method = "tools/call", @params = new { name, arguments } });
+
+ return (await handler.HandleAsync(request, CancellationToken.None))!.Value.GetProperty("result");
+ }
+
+ private static string Text(JsonElement toolResult) => toolResult.GetProperty("content")[0].GetProperty("text").GetString() ?? "";
+
+ private static async Task GitReadyAsync()
+ {
+ if (OperatingSystem.IsWindows()) return false;
+
+ try { return (await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = ["--version"], TimeoutSeconds = 10 }, CancellationToken.None)).Status == SandboxStatus.Success; }
+ catch { return false; }
+ }
+
+ /// Team A with the run's repository and a second, unbound one; team C with a foreign one. Each git-backed repository has a README on main and an unmerged secret-branch.
+ private async Task SeedWorldAsync(bool withGit = true)
+ {
+ var (teamId, ownerUserId) = await WorkflowsTestSeed.SeedTeamAsync(fixture);
+ var (foreignTeamId, _) = await WorkflowsTestSeed.SeedTeamAsync(fixture);
+ var world = new World(teamId, ownerUserId);
+
+ world.BoundRepositoryId = await SeedLocalRepositoryAsync(world, teamId, withGit, BoundContent, BoundSecret);
+ world.UnboundRepositoryId = await SeedLocalRepositoryAsync(world, teamId, withGit, "unbound-readme", UnboundSecret);
+ world.ForeignRepositoryId = await SeedLocalRepositoryAsync(world, foreignTeamId, withGit, "foreign-readme", "FOREIGN-SECRET");
+
+ return world;
+ }
+
+ private async Task SeedLocalRepositoryAsync(World world, Guid teamId, bool withGit, string readme, string secret)
+ {
+ var origin = world.TempDir();
+ if (withGit) await SeedOriginAsync(origin, readme, secret);
+
+ return await SeedRepositoryRowAsync(teamId, new Uri(origin).AbsoluteUri, credentialId: null, RepositoryPublishMode.Branch);
+ }
+
+ private async Task SeedCredentialedRepositoryAsync(Guid teamId, Guid ownerUserId, RepositoryPublishMode mode)
+ {
+ using var scope = fixture.BeginScope();
+ var db = scope.Resolve();
+ var instanceId = Guid.NewGuid();
+ var credentialId = Guid.NewGuid();
+ // A local (Git) provider: nothing behind it serves pull requests, so a write that gets past the policy fails in
+ // the service with no outbound call — the test stays hermetic whichever way it goes.
+ db.ProviderInstance.Add(new ProviderInstance { Id = instanceId, TeamId = teamId, Provider = ProviderKind.Git, DisplayName = "local", BaseUrl = $"https://git-{instanceId:N}.example.invalid" });
+ db.Credential.Add(new Credential { Id = credentialId, TeamId = teamId, ProviderInstanceId = instanceId, OwnerUserId = ownerUserId, AuthType = AuthType.Pat, DisplayName = "connection", EncryptedPayload = "not-a-real-ciphertext", Status = CredentialStatus.Active });
+ await db.SaveChangesAsync();
+
+ return await SeedRepositoryRowAsync(teamId, "https://git.example.invalid/acme/compliance.git", credentialId, mode, instanceId);
+ }
+
+ private async Task SeedRepositoryRowAsync(Guid teamId, string cloneUrl, Guid? credentialId, RepositoryPublishMode mode, Guid? providerInstanceId = null)
+ {
+ using var scope = fixture.BeginScope();
+ var db = scope.Resolve();
+ var instanceId = providerInstanceId ?? Guid.NewGuid();
+ if (providerInstanceId is null) db.ProviderInstance.Add(new ProviderInstance { Id = instanceId, TeamId = teamId, Provider = ProviderKind.Git, DisplayName = "local", BaseUrl = $"https://local-{instanceId:N}" });
+
+ var repositoryId = Guid.NewGuid();
+ db.Repository.Add(new Repository
+ {
+ Id = repositoryId, TeamId = teamId, ProviderInstanceId = instanceId, CredentialId = credentialId, PublishMode = mode,
+ ExternalId = repositoryId.ToString(), NamespacePath = "org", Name = "repo", FullPath = "org/repo",
+ DefaultBranch = "main", CloneUrlHttps = cloneUrl, WebUrl = "https://local/org/repo",
+ });
+ await db.SaveChangesAsync();
+
+ return repositoryId;
+ }
+
+ private static async Task SeedOriginAsync(string dir, string readme, string secret)
+ {
+ await GitAsync(dir, "init", "-b", "main");
+ await GitAsync(dir, "config", "user.email", "test@codespace.dev");
+ await GitAsync(dir, "config", "user.name", "Test");
+ await GitAsync(dir, "config", "commit.gpgsign", "false");
+ await File.WriteAllTextAsync(Path.Combine(dir, "README.md"), readme);
+ await GitAsync(dir, "add", ".");
+ await GitAsync(dir, "commit", "-m", "seed");
+ await GitAsync(dir, "checkout", "-b", "secret-branch");
+ await File.WriteAllTextAsync(Path.Combine(dir, "SECRET.txt"), secret);
+ await GitAsync(dir, "add", ".");
+ await GitAsync(dir, "commit", "-m", "unmerged work");
+ await GitAsync(dir, "checkout", "main");
+ }
+
+ private static async Task GitAsync(string workdir, params string[] args)
+ {
+ var result = await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = args, WorkingDirectory = workdir, TimeoutSeconds = 60 }, CancellationToken.None);
+ if (result.Status != SandboxStatus.Success) throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}");
+ }
+
+ /// The seeded world, plus every temp origin and DI scope a test opened — all released on dispose, even when an assertion fails.
+ private sealed class World(Guid teamId, Guid ownerUserId) : IAsyncDisposable
+ {
+ private readonly List _dirs = new();
+ private readonly List _scopes = new();
+
+ public Guid TeamId { get; } = teamId;
+ public Guid OwnerUserId { get; } = ownerUserId;
+ public Guid BoundRepositoryId { get; set; }
+ public Guid UnboundRepositoryId { get; set; }
+ public Guid ForeignRepositoryId { get; set; }
+
+ public string TempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "cs-bind-origin-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ _dirs.Add(dir);
+ return dir;
+ }
+
+ public void Own(ILifetimeScope scope) => _scopes.Add(scope);
+
+ public async ValueTask DisposeAsync()
+ {
+ foreach (var scope in _scopes) await scope.DisposeAsync();
+ foreach (var dir in _dirs)
+ try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/BenchmarkRunnerFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/BenchmarkRunnerFlowTests.cs
index 16c7560b8..59b1f3fa5 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Agents/BenchmarkRunnerFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Agents/BenchmarkRunnerFlowTests.cs
@@ -8,6 +8,7 @@
using CodeSpace.Core.Services.Agents.Harnesses.Codex;
using CodeSpace.Core.Services.Supervisor;
using CodeSpace.IntegrationTests.Infrastructure;
+using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Agents.Benchmark;
using CodeSpace.Messages.Enums;
using Microsoft.EntityFrameworkCore;
@@ -91,6 +92,22 @@ public async Task A_failing_workspace_grades_fail_even_though_the_run_succeeded(
card.Harnesses.Single().SuccessRate.ShouldBe(0.0);
}
+ [Fact]
+ public async Task A_cli_mcp_cell_whose_write_scope_is_read_only_is_not_recorded_as_served_the_full_catalog()
+ {
+ if (OperatingSystem.IsWindows()) return;
+
+ // A Confined cell derives a read-only write scope, and a read-only run is served only the tools that do not write
+ // even when its mode opts into the fabric. The row's label must follow what the executor served, never the opt-in.
+ using var cli = new FakeBenchmarkCli();
+ using var workspace = BenchmarkFixture.StageSolved();
+ var teamId = await SeedTeamAsync();
+
+ var run = await RunAsync(TestsPassTask(), BenchmarkMode.HarnessCliWithMcp, workspace.Directory, teamId, new BenchmarkAgentSelection { Autonomy = AgentAutonomyLevel.Confined });
+
+ run.McpFullCatalog.ShouldBeFalse("the executor withheld every write from this read-only cell, so the row must not claim the full fabric");
+ }
+
[Fact]
public async Task The_same_task_through_both_cli_modes_differs_observably_on_the_mcp_fabric_not_just_the_label()
{
@@ -369,10 +386,10 @@ public Task GradeAsync(BenchmarkGradingContext context, Cancella
private static CorpusCellState CellStateOf(BenchmarkTask task, BenchmarkResult result) =>
EvalSuite.Classify(EvalSuite.ManifestFor(new[] { task }), new[] { result }, Array.Empty()).Single().State;
- private async Task RunAsync(BenchmarkTask task, BenchmarkMode mode, string workspaceDir, Guid teamId)
+ private async Task RunAsync(BenchmarkTask task, BenchmarkMode mode, string workspaceDir, Guid teamId, BenchmarkAgentSelection? selection = null)
{
using var scope = _fixture.BeginScopeAs(_operators[teamId], teamId);
- return await scope.Resolve().RunAsync(task, mode, new BenchmarkExecutionContext { WorkspaceDirectory = workspaceDir, TeamId = teamId }, CancellationToken.None);
+ return await scope.Resolve().RunAsync(task, mode, new BenchmarkExecutionContext { WorkspaceDirectory = workspaceDir, TeamId = teamId, Selection = selection }, CancellationToken.None);
}
private async Task AssertRealRunRecordedAsync(BenchmarkResult result, Guid teamId)
diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/McpNodeLifetimeFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/McpNodeLifetimeFlowTests.cs
index e4ce48b60..7ea41b2b7 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Agents/McpNodeLifetimeFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Agents/McpNodeLifetimeFlowTests.cs
@@ -86,7 +86,7 @@ public async Task A_builtin_node_called_over_the_socket_cannot_resolve_a_foreign
missing.GetProperty("isError").GetBoolean().ShouldBeTrue();
var foreignText = foreign.GetProperty("content")[0].GetProperty("text").GetString().ShouldNotBeNull();
var missingText = missing.GetProperty("content")[0].GetProperty("text").GetString().ShouldNotBeNull();
- foreignText.ShouldContain($"Repository {repositoryId} not found.", customMessage: "the real builtin node must refuse at the tenant lookup before any clone or command");
+ foreignText.ShouldContain($"Repository {repositoryId} not found.", customMessage: "a repository outside the run's binding must be refused before any clone or command");
foreignText.Replace(repositoryId.ToString(), "id").ShouldBe(missingText.Replace(missingId.ToString(), "id"), "foreign and missing repositories must have indistinguishable failure shapes");
foreignText.ShouldNotContain("foreign.invalid");
host.Endpoint.ObservedToolCalls.ShouldBe(2);
diff --git a/backend/tests/CodeSpace.IntegrationTests/Agents/McpToolTeamScopeFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Agents/McpToolTeamScopeFlowTests.cs
index b2fd18adf..b2ab8302c 100644
--- a/backend/tests/CodeSpace.IntegrationTests/Agents/McpToolTeamScopeFlowTests.cs
+++ b/backend/tests/CodeSpace.IntegrationTests/Agents/McpToolTeamScopeFlowTests.cs
@@ -2,6 +2,7 @@
using Autofac;
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Agents;
using CodeSpace.Core.Services.Agents.Mcp;
using CodeSpace.Core.Services.Agents.Sandbox.Runners;
using CodeSpace.Core.Services.Agents.Tools;
@@ -44,7 +45,7 @@ public async Task Run_command_through_a_handler_bound_to_team_A_resolves_team_As
var repoId = await SeedRepositoryAsync(teamA, new Uri(origin.Path).AbsoluteUri, "main");
using var scope = _fixture.BeginScope();
- var handler = new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Unleashed, teamA);
+ var handler = new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Unleashed, teamA, repositories: [BoundTo(repoId)]);
// `cat README.md` only reads the file if the handler's team reached the node, the node resolved team A's
// repo, cloned it, and ran with the clone as cwd. Proves teamId travels handler → call → Sys → node.
@@ -67,13 +68,18 @@ public async Task A_handler_bound_to_team_A_naming_team_Bs_repo_fails_closed_wit
var repoId = await SeedRepositoryAsync(teamB, new Uri(origin.Path).AbsoluteUri, "main");
using var scope = _fixture.BeginScope();
- var handler = new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Unleashed, teamA);
+ // The handler's run is bound to team B's repository and to an id nobody owns — as no admitted run could be — so
+ // both calls get past the binding and the tenant filter alone stands between team A's run and team B's repository.
+ var missing = Guid.NewGuid();
+ var handler = new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Unleashed, teamA, repositories: [BoundTo(repoId), BoundTo(missing) with { Alias = "missing" }]);
// The repo belongs to team B; a handler bound to team A names it → the tenant filter resolves nothing.
var result = await CallToolAsync(handler, "agent.run_command", new { repositoryId = repoId.ToString(), command = "cat", args = new[] { "README.md" } });
+ var absent = await CallToolAsync(handler, "agent.run_command", new { repositoryId = missing.ToString(), command = "cat", args = new[] { "README.md" } });
result.GetProperty("isError").GetBoolean().ShouldBeTrue(customMessage: "a cross-team repo id must fail closed, never clone");
- Text(result).ShouldContain("not found", customMessage: "a cross-team repo is indistinguishable from a missing one (no existence leak)");
+ Text(result).ShouldContain(AgentRepositoryBinding.NotFound(repoId), customMessage: "the tenant filter's own miss — the sentence the binding's refusal repeats");
+ Text(result).Replace(repoId.ToString(), "id").ShouldBe(Text(absent).Replace(missing.ToString(), "id"), "a cross-team repo is indistinguishable from a missing one (no existence leak)");
Text(result).ShouldNotContain("secret-of-team-b");
}
@@ -89,9 +95,10 @@ public async Task A_repo_touching_tool_through_a_handler_with_no_team_fails_clos
var repoId = await SeedRepositoryAsync(teamId, new Uri(origin.Path).AbsoluteUri, "main");
using var scope = _fixture.BeginScope();
- var handler = new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Unleashed); // no teamId → null
+ var handler = new McpRequestHandler(scope.Resolve(), AgentAutonomyLevel.Unleashed, repositories: [BoundTo(repoId)]); // no teamId → null
- // No team on the handler → no sys.team_id → the node can't resolve a repo (today's fail-closed default).
+ // No team on the handler → no sys.team_id → the node can't resolve a repo (today's fail-closed default). The run
+ // is bound to the repository, so the call gets past the binding and fails at the team check itself.
var result = await CallToolAsync(handler, "agent.run_command", new { repositoryId = repoId.ToString(), command = "true" });
result.GetProperty("isError").GetBoolean().ShouldBeTrue();
@@ -132,6 +139,8 @@ private static async Task CallToolAsync(McpRequestHandler handler,
private static string Text(JsonElement toolResult) => toolResult.GetProperty("content")[0].GetProperty("text").GetString() ?? "";
+ private static WorkspaceRepositorySpec BoundTo(Guid repositoryId) => new() { Alias = "repo", RepositoryId = repositoryId };
+
private async Task SeedRepositoryAsync(Guid teamId, string cloneUrlHttps, string defaultBranch)
{
using var scope = _fixture.BeginScope();
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryBindingTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryBindingTests.cs
new file mode 100644
index 000000000..e7c9352d1
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryBindingTests.cs
@@ -0,0 +1,93 @@
+using CodeSpace.Core.Services.Agents;
+using CodeSpace.Messages.Agents;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.Agents;
+
+///
+/// The calling run's hold on a repository a tool call names: only the repositories the run is bound to resolve, every
+/// other id gets one "not found" whatever it is, and a repository bound as read-only context checks out only its bound
+/// branch or its default branch. A writable repository is the run's own to work in, at any ref.
+///
+[Trait("Category", "Unit")]
+public class AgentRepositoryBindingTests
+{
+ private const string DefaultBranch = "main";
+
+ private static readonly Guid Writable = Guid.NewGuid();
+ private static readonly Guid Context = Guid.NewGuid();
+
+ private static readonly AgentRunPosture Run = new()
+ {
+ Autonomy = AgentAutonomyLevel.Unleashed,
+ Permissions = new AgentPermissions(),
+ Repositories = [Spec(Writable, WorkspaceAccess.Write, null), Spec(Context, WorkspaceAccess.Read, "release/1")],
+ };
+
+ [Theory]
+ [InlineData("writable", true)]
+ [InlineData("read-context", true)]
+ [InlineData("unbound", false)]
+ public void Find_resolves_only_a_repository_the_run_is_bound_to(string target, bool bound)
+ {
+ var repositoryId = target switch { "writable" => Writable, "read-context" => Context, _ => Guid.NewGuid() };
+
+ var found = AgentRepositoryBinding.Find(Run, repositoryId);
+
+ (found is not null).ShouldBe(bound, $"a {target} repository must {(bound ? "" : "not ")}resolve against the run's binding");
+ found?.RepositoryId.ShouldBe(repositoryId);
+ }
+
+ [Fact]
+ public void A_run_bound_to_no_repository_resolves_none()
+ {
+ var run = new AgentRunPosture { Autonomy = AgentAutonomyLevel.Unleashed, Permissions = new AgentPermissions() };
+
+ run.Repositories.ShouldBeEmpty("a posture stamped without a binding binds nothing — fail-closed, never every repository");
+ AgentRepositoryBinding.Find(run, Writable).ShouldBeNull();
+ }
+
+ [Fact]
+ public void The_refusal_is_the_not_found_a_missing_repository_gets()
+ {
+ // One answer whatever the id is (this team's, another team's, nobody's) — and byte-identical to RunCommandService's
+ // own tenant-filter miss, pinned end to end in AgentToolRepositoryBindingFlowTests — so a refusal can never tell
+ // the model that the repository it guessed exists in the team.
+ var repositoryId = Guid.NewGuid();
+
+ AgentRepositoryBinding.NotFound(repositoryId).ShouldBe($"Repository {repositoryId} not found.");
+ }
+
+ [Theory]
+ // access bound ref requested ref allowed
+ [InlineData(WorkspaceAccess.Write, null, "secret-branch", true)] // the run's own repository: any ref
+ [InlineData(WorkspaceAccess.Write, "feature", "release/2026", true)]
+ [InlineData(WorkspaceAccess.Read, null, null, true)] // no ref → the default branch
+ [InlineData(WorkspaceAccess.Read, null, " ", true)] // blank reads as no ref, as the clone does
+ [InlineData(WorkspaceAccess.Read, null, "main", true)] // the default branch by name
+ [InlineData(WorkspaceAccess.Read, null, "secret-branch", false)] // an unmerged branch of read-only context
+ [InlineData(WorkspaceAccess.Read, "release/1", "release/1", true)] // the bound branch
+ [InlineData(WorkspaceAccess.Read, "release/1", "main", true)] // the default branch beside it
+ [InlineData(WorkspaceAccess.Read, "release/1", "release/2", false)]
+ [InlineData(WorkspaceAccess.Read, null, "MAIN", false)] // refs are case-sensitive
+ [InlineData(WorkspaceAccess.Read, null, "refs/heads/secret", false)]
+ [InlineData((WorkspaceAccess)99, null, "secret-branch", false)] // an access this code does not know is pinned like read
+ public void A_ref_is_open_on_a_writable_repository_and_pinned_on_read_only_context(WorkspaceAccess access, string? boundRef, string? requestedRef, bool allowed)
+ {
+ var bound = Spec(Guid.NewGuid(), access, boundRef);
+
+ AgentRepositoryBinding.AllowsRef(bound, requestedRef, DefaultBranch).ShouldBe(allowed, $"{access} bound at '{boundRef ?? "(default)"}' asked for '{requestedRef ?? "(none)"}'");
+ }
+
+ [Theory]
+ [InlineData(WorkspaceAccess.Write, true)]
+ [InlineData(WorkspaceAccess.Read, false)] // read-only context is the run's to read, not to open, merge, review or comment on
+ [InlineData((WorkspaceAccess)99, false)] // an access this code does not know is held like read-only context
+ public void Only_a_repository_bound_writable_takes_an_agents_write(WorkspaceAccess access, bool allowed)
+ {
+ AgentRepositoryBinding.AllowsWrite(Spec(Guid.NewGuid(), access, null)).ShouldBe(allowed);
+ }
+
+ private static WorkspaceRepositorySpec Spec(Guid repositoryId, WorkspaceAccess access, string? @ref) =>
+ new() { Alias = repositoryId.ToString("N"), RepositoryId = repositoryId, Access = access, Ref = @ref };
+}
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryPolicyTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryPolicyTests.cs
new file mode 100644
index 000000000..e4df3d6ce
--- /dev/null
+++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryPolicyTests.cs
@@ -0,0 +1,92 @@
+using CodeSpace.Core.Persistence.Entities;
+using CodeSpace.Core.Services.Agents;
+using CodeSpace.Core.Services.Agents.Publish;
+using CodeSpace.Core.Services.Agents.Publish.Guards;
+using CodeSpace.Core.Services.Agents.Tools;
+using CodeSpace.Messages.Agents;
+using CodeSpace.Messages.Enums;
+using Shouldly;
+
+namespace CodeSpace.UnitTests.Agents;
+
+///
+/// The repository's own say over an agent's use of it. A pull-request write (open, merge, review, comment) meets the SAME
+/// guard chain an agent's branch push meets, so a repository whose policy refuses agent-pushed branches
+/// () does not take an agent-opened, -merged, -reviewed or -commented pull
+/// request either; and a ref a command names on read-only context is judged against the repository's default branch.
+/// Pinned over the production guards.
+///
+[Trait("Category", "Unit")]
+public class AgentRepositoryPolicyTests
+{
+ private static readonly IPublishGuard[] ProductionGuards = [new RepositoryPolicyPublishGuard(), new ProfileOptOutPublishGuard(), new NoCredentialPublishGuard()];
+
+ [Theory]
+ [InlineData(RepositoryPublishMode.PatchOnly, true)]
+ [InlineData(RepositoryPublishMode.Branch, false)]
+ public void A_patch_only_repository_refuses_an_agents_pull_request_write(RepositoryPublishMode mode, bool refused)
+ {
+ var repository = Repo(mode, credentialId: Guid.NewGuid());
+
+ var refusal = AgentRepositoryPolicy.Refusal(repository, Write(repository), ProductionGuards);
+
+ (refusal is not null).ShouldBe(refused, refusal);
+ if (refused) refusal.ShouldBe($"Repository {repository.Id} does not take pull-request writes from an agent: the repository requires patch-only publishing.");
+ }
+
+ [Fact]
+ public void A_read_of_a_patch_only_repository_does_not_meet_its_publish_guards()
+ {
+ var repository = Repo(RepositoryPublishMode.PatchOnly, credentialId: null);
+
+ AgentRepositoryPolicy.Refusal(repository, new AgentRepositoryUse { TeamId = repository.TeamId, Bound = Bound(repository, WorkspaceAccess.Read, null), Ref = "main" }, ProductionGuards).ShouldBeNull();
+ }
+
+ [Fact]
+ public void The_chain_is_walked_in_its_declared_order_not_the_order_it_was_handed()
+ {
+ // A credential-less patch-only repository trips two guards; the lower Order (no-credential, 10) speaks first —
+ // the same first-wins walk the push path does, never DI registration order.
+ var repository = Repo(RepositoryPublishMode.PatchOnly, credentialId: null);
+
+ var refusal = AgentRepositoryPolicy.Refusal(repository, Write(repository), ProductionGuards);
+
+ refusal.ShouldNotBeNull().ShouldEndWith("the repository has no bound push credential.");
+ }
+
+ [Theory]
+ // access bound ref requested refused
+ [InlineData(WorkspaceAccess.Read, null, "secret-branch", true)]
+ [InlineData(WorkspaceAccess.Read, null, "main", false)] // the default branch, which only the repository row knows
+ [InlineData(WorkspaceAccess.Read, "release/1", "release/1", false)]
+ [InlineData(WorkspaceAccess.Read, "release/1", "main", false)]
+ [InlineData(WorkspaceAccess.Write, null, "secret-branch", false)]
+ public void A_ref_named_on_read_only_context_is_judged_against_the_repositorys_default_branch(WorkspaceAccess access, string? boundRef, string requestedRef, bool refused)
+ {
+ var repository = Repo(RepositoryPublishMode.Branch, credentialId: Guid.NewGuid());
+ var bound = Bound(repository, access, boundRef);
+
+ var refusal = AgentRepositoryPolicy.Refusal(repository, new AgentRepositoryUse { TeamId = repository.TeamId, Bound = bound, Ref = requestedRef }, ProductionGuards);
+
+ if (refused) refusal.ShouldBe(AgentRepositoryBinding.RefOutsideBinding(repository.Id, bound, requestedRef, "main"));
+ else refusal.ShouldBeNull();
+ }
+
+ [Fact]
+ public void A_repository_that_did_not_resolve_is_left_to_the_tool_which_reports_it_not_found()
+ {
+ var repository = Repo(RepositoryPublishMode.PatchOnly, credentialId: null);
+
+ AgentRepositoryPolicy.Refusal(null, Write(repository), ProductionGuards).ShouldBeNull();
+ }
+
+ private static AgentRepositoryUse Write(Repository repository) => new() { TeamId = repository.TeamId, Bound = Bound(repository, WorkspaceAccess.Write, null), Writes = true };
+
+ private static WorkspaceRepositorySpec Bound(Repository repository, WorkspaceAccess access, string? @ref) => new() { Alias = "repo", RepositoryId = repository.Id, Access = access, Ref = @ref };
+
+ private static Repository Repo(RepositoryPublishMode mode, Guid? credentialId) => new()
+ {
+ Id = Guid.NewGuid(), TeamId = Guid.NewGuid(), ProviderInstanceId = Guid.NewGuid(), CredentialId = credentialId, PublishMode = mode, DefaultBranch = "main",
+ ExternalId = "x", NamespacePath = "acme", Name = "compliance", FullPath = "acme/compliance", WebUrl = "https://git.example.invalid/acme/compliance",
+ };
+}
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/AgentToolRegistryTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/AgentToolRegistryTests.cs
index eaa46cbbd..c9ad37a97 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/AgentToolRegistryTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/AgentToolRegistryTests.cs
@@ -48,7 +48,13 @@ public Task RunAsync(RunCommandRequest request, CancellationToken
private static AgentToolRegistry BuildWith(IEnumerable nodes, IEnumerable firstParty)
{
var runtimes = nodes.ToArray();
- return new AgentToolRegistry(runtimes, firstParty, new TestNodeInvocations(runtimes), NullLoggerFactory.Instance);
+ return new AgentToolRegistry(runtimes, firstParty, new TestNodeInvocations(runtimes), new NoRepositoryPolicy(), NullLoggerFactory.Instance);
+ }
+
+ /// A repository policy that lets every use through — these tests pin the catalog, not the binding.
+ private sealed class NoRepositoryPolicy : IAgentRepositoryPolicy
+ {
+ public Task RefusalAsync(AgentRepositoryUse use, CancellationToken cancellationToken) => Task.FromResult(null);
}
private sealed class TestNodeInvocations(IReadOnlyList nodes) : INodeInvocationExecutor
@@ -230,7 +236,7 @@ public async Task A_model_supplied_actAsUserId_is_stripped_on_the_tool_path_so_t
// CONNECTION credential), making the "not a wider attack surface" claim true.
var pr = new CapturingPullRequestService();
var node = ActAsUserNode(kind, pr);
- var tool = new NodeAgentTool(node, new TestNodeInvocations(new[] { node }), NullLogger.Instance);
+ var tool = new NodeAgentTool(node, new TestNodeInvocations(new[] { node }), new NoRepositoryPolicy(), NullLogger.Instance);
var teamId = Guid.NewGuid();
var victim = Guid.NewGuid(); // a teammate the model tries to impersonate
@@ -248,6 +254,28 @@ public async Task A_model_supplied_actAsUserId_is_stripped_on_the_tool_path_so_t
pr.LastActorUserId.ShouldBeNull($"{kind} via the tool path must NOT honour a model-supplied actAsUserId — it acts as the connection credential, never as {victim}");
}
+ [Theory]
+ [InlineData(typeof(AgentRunCommandNode), false)]
+ [InlineData(typeof(GitFetchPrDiffNode), false)]
+ [InlineData(typeof(GitFetchPrChecksNode), false)]
+ [InlineData(typeof(GitListPullRequestsNode), false)]
+ [InlineData(typeof(GitOpenPullRequestNode), true)]
+ [InlineData(typeof(GitMergePullRequestNode), true)]
+ [InlineData(typeof(GitPrReviewNode), true)]
+ [InlineData(typeof(GitPostPrCommentNode), true)]
+ public void Every_repository_taking_tool_node_declares_its_repository_input_and_whether_it_writes_the_repository(Type nodeType, bool writes)
+ {
+ // The declaration is what holds a tool call to its run's bound repositories: a node that names a repository but
+ // forgets it would reach any repository of the team again. Only the pull-request writes meet the repository's
+ // publish policy — a command clones and runs locally, and its clone carries no push credential.
+ var node = (INodeRuntime)Activator.CreateInstance(nodeType, nodeType.GetConstructors().Single().GetParameters().Select(_ => (object?)null).ToArray())!;
+
+ var input = node.Manifest.RepositoryInput.ShouldNotBeNull($"{node.TypeKey} names a repository, so it must declare which input");
+ input.InputKey.ShouldBe("repositoryId");
+ input.WritesRepository.ShouldBe(writes, $"{node.TypeKey} {(writes ? "writes" : "does not write")} the repository through its provider");
+ node.Manifest.InputSchema.GetProperty("properties").TryGetProperty(input.InputKey, out _).ShouldBeTrue("the declared key must be an input the node's schema offers the model");
+ }
+
/// Captures the actorUserId the node forwards; everything else returns a minimal success shape. Only
/// the two act-as-user writes (open_pr / pr_review) are exercised; the rest throw so a misuse is loud.
private sealed class CapturingPullRequestService : IPullRequestService
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/McpCatalogModeTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/McpCatalogModeTests.cs
index 5708d4fd4..dda79ec50 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/McpCatalogModeTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/McpCatalogModeTests.cs
@@ -41,10 +41,11 @@ private sealed class FakeRegistry : IAgentToolRegistry
private static readonly IAgentTool Read = new FakeTool { Kind = "get_context", ReadOnly = true };
private static readonly IAgentTool Write = new FakeTool { Kind = "git.open_pr", ReadOnly = false };
+ private static readonly IAgentTool Ask = new DecisionRequestTool();
// Unleashed so the side-effecting tool would otherwise be Allow — proving the mode (not the gate) is what hides it.
private static McpRequestHandler Handler(McpCatalogMode mode) =>
- new(new FakeRegistry(Read, Write), AgentAutonomyLevel.Unleashed, catalogMode: mode);
+ new(new FakeRegistry(Read, Write, Ask), AgentAutonomyLevel.Unleashed, catalogMode: mode);
private static async Task Respond(McpRequestHandler handler, string requestJson) => (await handler.HandleAsync(Parse(requestJson), CancellationToken.None))!.Value;
private static string Call(string name) =>
@@ -65,6 +66,36 @@ public void ResolveMcpCatalogMode_takes_the_per_run_choice_else_the_committed_de
AgentRunExecutor.ResolveMcpCatalogMode(task).ShouldBe(expected);
}
+ [Theory]
+ [InlineData(null, AgentWriteScope.Workspace, McpCatalogMode.Full)]
+ [InlineData(null, AgentWriteScope.ReadOnly, McpCatalogMode.NonDestructive)] // readOnly=true on an agent.run node, or a Confined tier
+ [InlineData(true, AgentWriteScope.ReadOnly, McpCatalogMode.NonDestructive)] // opting into the fabric cannot widen a read-only run
+ [InlineData(false, AgentWriteScope.Workspace, McpCatalogMode.ReadOnly)]
+ [InlineData(false, AgentWriteScope.ReadOnly, McpCatalogMode.ReadOnly)] // the fabric opt-out stays the narrowest slice, byte-identical
+ [InlineData(null, (AgentWriteScope)99, McpCatalogMode.NonDestructive)] // a scope this code does not know reads as read-only, as the sandbox does
+ public void A_read_only_run_is_served_no_side_effecting_tool_whatever_it_asked_for(bool? perRunChoice, AgentWriteScope writeScope, McpCatalogMode expected)
+ {
+ // "Analysis-only (no writes), regardless of the autonomy level" is what readOnly promises the author. Its sandbox
+ // already mounts the workspace read-only; the tool fabric must not hand it a merge, a PR or a command instead —
+ // but it may still read and still ask, so it keeps every tool that does not write.
+ var task = new AgentTask { Goal = "g", Harness = "codex-cli", EnableMcpEndpoint = perRunChoice, Autonomy = AgentAutonomyLevel.Unleashed, Permissions = new AgentPermissions { WriteScope = writeScope } };
+
+ AgentRunExecutor.ResolveMcpCatalogMode(task).ShouldBe(expected);
+ }
+
+ [Theory]
+ // mode read ask write
+ [InlineData(McpCatalogMode.Full, true, true, true)]
+ [InlineData(McpCatalogMode.NonDestructive, true, true, false)]
+ [InlineData(McpCatalogMode.ReadOnly, true, false, false)]
+ [InlineData((McpCatalogMode)99, true, false, false)] // a mode this code does not know serves the narrowest slice
+ public void Each_mode_serves_its_slice_of_the_catalog(McpCatalogMode mode, bool servesRead, bool servesAsk, bool servesWrite)
+ {
+ McpRequestHandler.Serves(mode, Read).ShouldBe(servesRead);
+ McpRequestHandler.Serves(mode, Ask).ShouldBe(servesAsk, "decision.request is an ask, not a write");
+ McpRequestHandler.Serves(mode, Write).ShouldBe(servesWrite);
+ }
+
// ─── tools/list filtering ─────────────────────────────────────────────────
[Fact]
@@ -74,6 +105,15 @@ public async Task ReadOnly_lists_only_read_only_tools()
names.ShouldContain("get_context", customMessage: "the safe read is advertised by default");
names.ShouldNotContain("git.open_pr", customMessage: "a side-effecting tool is not even advertised in read-only mode");
+ names.ShouldNotContain(DecisionRequestTool.ToolKind, customMessage: "the fabric opt-out's slice is unchanged — only the read-only tools");
+ }
+
+ [Fact]
+ public async Task NonDestructive_lists_the_reads_and_the_ask_but_no_write()
+ {
+ var names = ToolNames(await Respond(Handler(McpCatalogMode.NonDestructive), """{"jsonrpc":"2.0","id":1,"method":"tools/list"}"""));
+
+ names.ShouldBe(new[] { DecisionRequestTool.ToolKind, "get_context" }); // a read-only run reads and can still ask a human — it is handed no write
}
[Fact]
@@ -81,7 +121,7 @@ public async Task Full_lists_every_tool_byte_identical()
{
var names = ToolNames(await Respond(Handler(McpCatalogMode.Full), """{"jsonrpc":"2.0","id":1,"method":"tools/list"}"""));
- names.ShouldBe(new[] { "get_context", "git.open_pr" }); // full mode serves the whole registry, as before
+ names.ShouldBe(new[] { DecisionRequestTool.ToolKind, "get_context", "git.open_pr" }); // full mode serves the whole registry, as before
}
// ─── tools/call enforcement ───────────────────────────────────────────────
@@ -103,6 +143,15 @@ public async Task ReadOnly_refuses_a_side_effecting_tool_call_before_the_gate()
result.GetProperty("content")[0].GetProperty("text").GetString().ShouldContain("read-only", customMessage: "the refusal explains the run serves only read-only tools");
}
+ [Fact]
+ public async Task NonDestructive_refuses_a_write_before_the_gate_and_says_the_run_is_read_only()
+ {
+ var result = (await Respond(Handler(McpCatalogMode.NonDestructive), Call("git.open_pr"))).GetProperty("result");
+
+ result.GetProperty("isError").GetBoolean().ShouldBeTrue("a write is refused for a read-only run even at Unleashed");
+ result.GetProperty("content")[0].GetProperty("text").GetString().ShouldBe("Tool 'git.open_pr' is not available: this run is read-only, so it is served no tool that writes.");
+ }
+
[Fact]
public async Task Full_serves_a_side_effecting_tool_call()
{
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/McpRequestHandlerTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/McpRequestHandlerTests.cs
index 101b5d569..185a0e185 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/McpRequestHandlerTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/McpRequestHandlerTests.cs
@@ -31,13 +31,23 @@ private sealed class FakeTool : IAgentTool
public bool IsDestructiveOverride { get; init; }
public bool IsDestructive => IsDestructiveOverride;
public bool IsReadOnly => !IsDestructiveOverride;
+ public bool AlwaysApprove { get; init; }
+ public bool AlwaysRequiresApproval => AlwaysApprove;
public Func? OnValidate { get; init; }
public Func>? OnCall { get; init; }
+ public Func? OnRefusal { get; init; }
public int CallCount { get; private set; }
+ public List RefusalChecks { get; } = new();
public AgentToolValidation ValidateInput(JsonElement input) => OnValidate?.Invoke(input) ?? AgentToolValidation.Valid;
+ public Task RefusalAsync(AgentToolCall call, CancellationToken cancellationToken)
+ {
+ RefusalChecks.Add(call);
+ return Task.FromResult(OnRefusal?.Invoke(call));
+ }
+
public Task CallAsync(AgentToolCall call, CancellationToken cancellationToken)
{
CallCount++;
@@ -533,7 +543,8 @@ public async Task ToolsCall_stamps_the_runs_posture_onto_the_tool_call_on_the_un
AgentRunPosture? seen = null;
var tool = new FakeTool { Kind = "agent.run_command", IsDestructiveOverride = true, OnCall = (c, _) => { seen = c.CallerPosture; return Task.FromResult(AgentToolResult.Ok(Parse("{}"), 2)); } };
var runId = Guid.NewGuid();
- var handler = new McpRequestHandler(new FakeRegistry(tool), AgentAutonomyLevel.Unleashed, Guid.NewGuid(), null, runId, new SpyLedger(), fenceEpoch: 1, governanceEnabled: governed, permissions: permissions);
+ IReadOnlyList repositories = [new() { Alias = "repo", RepositoryId = Guid.NewGuid() }, new() { Alias = "ctx", RepositoryId = Guid.NewGuid(), Access = WorkspaceAccess.Read, Ref = "release/1" }];
+ var handler = new McpRequestHandler(new FakeRegistry(tool), AgentAutonomyLevel.Unleashed, Guid.NewGuid(), null, runId, new SpyLedger(), fenceEpoch: 1, governanceEnabled: governed, permissions: permissions, repositories: repositories);
await Respond(handler, Call("agent.run_command", """{"network":true}"""));
@@ -541,6 +552,20 @@ public async Task ToolsCall_stamps_the_runs_posture_onto_the_tool_call_on_the_un
posture.RunId.ShouldBe(runId, "the run the posture belongs to — the unit a run's commands queue by");
posture.Autonomy.ShouldBe(AgentAutonomyLevel.Unleashed);
posture.Permissions.ShouldBeSameAs(permissions, "the run's own permissions, not a re-derivation from its tier");
+ posture.Repositories.ShouldBeSameAs(repositories, "the run's bound repositories, as the endpoint stamped them — never read from the model's arguments");
+ }
+
+ [Fact]
+ public async Task ToolsCall_on_a_handler_built_without_a_binding_stamps_a_posture_bound_to_no_repository()
+ {
+ // Fail-closed: a handler that was not told which repositories its run may reach binds none, so a repository-taking
+ // tool refuses every id rather than reaching the whole team.
+ AgentRunPosture? seen = null;
+ var tool = new FakeTool { Kind = "echo", OnCall = (c, _) => { seen = c.CallerPosture; return Task.FromResult(AgentToolResult.Ok(Parse("{}"), 2)); } };
+
+ await Respond(Handler(AgentAutonomyLevel.Unleashed, tool), Call("echo", """{"repositoryId":"00000000-0000-0000-0000-000000000001"}"""));
+
+ seen.ShouldNotBeNull().Repositories.ShouldBeEmpty();
}
[Fact]
@@ -1360,6 +1385,56 @@ private static McpRequestHandler ApprovalHandlerAt(AgentAutonomyLevel autonomy,
new(new FakeRegistry(tools), autonomy, Guid.NewGuid(), null, Guid.NewGuid(), ledger, fenceEpoch: 1, governanceEnabled: true,
approvalConversationId: Guid.NewGuid(), bot, new StubWaiters(), new StubComponents());
+ [Theory]
+ [InlineData(AgentAutonomyLevel.Standard, "git.open_pr")]
+ [InlineData(AgentAutonomyLevel.Trusted, "git.open_pr")]
+ [InlineData(AgentAutonomyLevel.Unleashed, "git.merge_pr")] // always-approve: parked even at Unleashed
+ [InlineData(AgentAutonomyLevel.Unleashed, "git.open_pr")] // gate-Allow: refused before the ledger claims it
+ public async Task A_call_the_tool_refuses_is_answered_before_it_is_parked_for_approval_or_claimed(AgentAutonomyLevel level, string kind)
+ {
+ // A repository outside the run's binding (or a write to read-only context, or a patch-only repository) is refused
+ // whatever a human decides — so no card may ask one to approve it, and no ledger row is minted for it.
+ var ledger = new SpyLedger();
+ var bot = new StubBot { ConversationInTeam = true };
+ var tool = new FakeTool { Kind = kind, IsDestructiveOverride = true, AlwaysApprove = kind == "git.merge_pr", OnRefusal = _ => "Repository x not found." };
+ var handler = ApprovalHandlerAt(level, ledger, bot, tool);
+
+ var result = (await Respond(handler, Call(kind, """{"repositoryId":"x"}"""))).GetProperty("result");
+
+ result.GetProperty("isError").GetBoolean().ShouldBeTrue();
+ result.GetProperty("content")[0].GetProperty("text").GetString().ShouldBe("Repository x not found.");
+ bot.PostCount.ShouldBe(0, "no approval card is posted for a call that could only be refused");
+ ledger.Claims.ShouldBeEmpty("no ledger row is parked or claimed for it");
+ tool.CallCount.ShouldBe(0);
+ tool.RefusalChecks.ShouldHaveSingleItem().CallerPosture.ShouldNotBeNull("the check sees the calling run's posture — its binding");
+ }
+
+ [Fact]
+ public async Task A_gate_denial_answers_first_so_a_tier_that_may_not_call_a_tool_learns_nothing_about_its_arguments()
+ {
+ var tool = new FakeTool { Kind = "git.open_pr", IsDestructiveOverride = true, OnRefusal = _ => "Repository x not found." };
+
+ var result = (await Respond(Handler(AgentAutonomyLevel.Confined, tool), Call("git.open_pr", """{"repositoryId":"x"}"""))).GetProperty("result");
+
+ result.GetProperty("content")[0].GetProperty("text").GetString().ShouldContain("not permitted");
+ tool.RefusalChecks.ShouldBeEmpty("authorize before judging the input, as validation already is");
+ }
+
+ [Fact]
+ public async Task A_call_the_tool_admits_proceeds_to_the_approval_park_as_before()
+ {
+ // The park's own DB write faults, so the call degrades to a retryable error right after it is claimed — enough to
+ // prove the admitted call reached the approval path without blocking on a human.
+ var ledger = new SpyLedger { OnBeginApprovalThrow = () => new InvalidOperationException("transient") };
+ var bot = new StubBot { ConversationInTeam = true };
+ var tool = new FakeTool { Kind = "git.open_pr", IsDestructiveOverride = true };
+
+ await Respond(ApprovalHandler(ledger, bot, tool), Call("git.open_pr", "{}"));
+
+ tool.RefusalChecks.ShouldHaveSingleItem("the check runs once before the park");
+ ledger.Claims.ShouldHaveSingleItem("an admitted call is claimed and parked for approval exactly as before");
+ }
+
[Theory]
[InlineData(AgentAutonomyLevel.Standard)]
[InlineData(AgentAutonomyLevel.Trusted)]
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/NodeAgentToolTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/NodeAgentToolTests.cs
index b505c485a..05dc3a2a4 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/NodeAgentToolTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/NodeAgentToolTests.cs
@@ -1,10 +1,13 @@
using System.Text.Json;
+using CodeSpace.Core.Services.Agents;
using CodeSpace.Core.Services.Agents.Commands;
using CodeSpace.Core.Services.Agents.Tools;
+using CodeSpace.Core.Services.PullRequests;
using CodeSpace.Core.Services.Workflows.Nodes;
using CodeSpace.Core.Services.Workflows.Nodes.Builtin;
using CodeSpace.Core.Services.Workflows.Runtime;
using CodeSpace.Messages.Agents;
+using CodeSpace.Messages.Dtos.Providers;
using CodeSpace.Messages.Enums;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
@@ -64,7 +67,37 @@ public Task RunAsync(NodeRunContext context, CancellationToken ct)
}
}
- private static NodeAgentTool Tool(INodeRuntime node) => new(node, new TestNodeInvocations(node), NullLogger.Instance);
+ private static NodeAgentTool Tool(INodeRuntime node, IAgentRepositoryPolicy? repositoryPolicy = null) => new(node, new TestNodeInvocations(node), repositoryPolicy ?? new RecordingRepositoryPolicy(refusal: null), NullLogger.Instance);
+
+ /// A node that declares a repository input () and records whether it ran — the shape every repository-taking builtin node has.
+ private sealed class RepositoryNode : INodeRuntime
+ {
+ public RepositoryNode(bool writes, string? refInputKey = null) => Manifest = new NodeManifest
+ {
+ DisplayName = "repo", Category = "Test", Kind = NodeKind.Regular, Description = "desc", IsSideEffecting = writes,
+ RepositoryInput = new RepositoryInputSpec { InputKey = "repositoryId", WritesRepository = writes, RefInputKey = refInputKey },
+ ConfigSchema = SchemaBuilder.EmptyObject(), InputSchema = SchemaBuilder.EmptyObject(), OutputSchema = SchemaBuilder.EmptyObject(),
+ };
+ public bool Ran { get; private set; }
+ public string TypeKey => "test.repository";
+ public NodeManifest Manifest { get; }
+ public Task RunAsync(NodeRunContext context, CancellationToken ct)
+ {
+ Ran = true;
+ return Task.FromResult(NodeResult.Ok());
+ }
+ }
+
+ /// Records every use the tool asked the repository's policy about, and answers with a fixed refusal (null = the policy lets the use through).
+ private sealed class RecordingRepositoryPolicy(string? refusal) : IAgentRepositoryPolicy
+ {
+ public List Asked { get; } = new();
+ public Task RefusalAsync(AgentRepositoryUse use, CancellationToken cancellationToken)
+ {
+ Asked.Add(use);
+ return Task.FromResult(refusal);
+ }
+ }
private sealed class TestNodeInvocations(INodeRuntime node) : INodeInvocationExecutor
{
@@ -223,4 +256,240 @@ public async Task TeamId_does_not_alter_inputs_rawinputs_config_or_observability
a.Config.Count.ShouldBe(0);
a.Observability.ShouldBeSameAs(b.Observability); // both NodeObservability.NoOp
}
+
+ // ── the calling run's repository binding ───────────────────────────────────
+
+ private static readonly Guid BoundRepository = Guid.NewGuid();
+
+ private static AgentRunPosture BoundTo(params Guid[] repositoryIds) => new()
+ {
+ Autonomy = AgentAutonomyLevel.Unleashed,
+ Permissions = new AgentPermissions(),
+ Repositories = repositoryIds.Select(id => new WorkspaceRepositorySpec { Alias = id.ToString("N"), RepositoryId = id }).ToList(),
+ };
+
+ private static AgentToolCall CallNaming(string repositoryIdJson, AgentRunPosture? caller) =>
+ new() { Input = JsonDocument.Parse($$"""{"repositoryId":{{repositoryIdJson}}}""").RootElement.Clone(), TeamId = Guid.NewGuid(), CallerPosture = caller };
+
+ [Theory]
+ [InlineData("bound", false)]
+ [InlineData("unbound", true)]
+ [InlineData("unbound-braced", true)] // "{uuid}" parses as a uuid, so a node would act on it — the binding must see it too
+ public async Task A_tool_a_run_calls_reaches_only_a_repository_the_run_is_bound_to(string target, bool refused)
+ {
+ var unbound = Guid.NewGuid();
+ var named = target switch { "bound" => $"\"{BoundRepository}\"", "unbound" => $"\"{unbound}\"", _ => $"\"{{{unbound}}}\"" };
+ var node = new RepositoryNode(writes: false);
+
+ var result = await Tool(node).CallAsync(CallNaming(named, BoundTo(BoundRepository)), CancellationToken.None);
+
+ result.IsError.ShouldBe(refused, $"a {target} repository: {result.Error}");
+ node.Ran.ShouldBe(!refused, "a refused call never reaches the node, so no clone, provider call or command runs");
+ if (refused) result.Error.ShouldBe(AgentRepositoryBinding.NotFound(unbound), "an unbound repository gets exactly the not-found a missing or foreign one gets — no existence oracle");
+ }
+
+ [Theory]
+ [InlineData("\"abc\"")]
+ [InlineData("\"\"")]
+ [InlineData("42")]
+ [InlineData("null")]
+ public async Task A_repository_value_no_node_would_act_on_reaches_the_node_which_refuses_or_ignores_it_as_it_always_did(string repositoryIdJson)
+ {
+ // Every repository-taking node reads its id as a JSON string that parses as a uuid. Anything else it treats as
+ // absent (agent.run_command runs with no checkout) or invalid (the git tools fail) — no repository is reached,
+ // so there is nothing for the binding to hold.
+ var node = new RepositoryNode(writes: false);
+
+ var result = await Tool(node).CallAsync(CallNaming(repositoryIdJson, BoundTo(BoundRepository)), CancellationToken.None);
+
+ result.IsError.ShouldBeFalse(result.Error);
+ node.Ran.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task A_call_with_no_calling_run_reaches_any_repository_as_a_workflow_node_always_did()
+ {
+ // No CallerPosture → not an agent's tool call (a workflow node, a test): the node resolves its repository within
+ // its team exactly as before. The binding is a property of an agent run, never of the node.
+ var node = new RepositoryNode(writes: true);
+ var policy = new RecordingRepositoryPolicy(refusal: "should never be asked");
+
+ var result = await Tool(node, policy).CallAsync(CallNaming($"\"{Guid.NewGuid()}\"", caller: null), CancellationToken.None);
+
+ result.IsError.ShouldBeFalse(result.Error);
+ node.Ran.ShouldBeTrue();
+ policy.Asked.ShouldBeEmpty("off the agent path the repository's publish policy is the publish path's business, not the tool's");
+ }
+
+ [Fact]
+ public async Task A_node_that_declares_no_repository_input_is_not_held_to_the_binding()
+ {
+ var node = new CapturingNode();
+
+ var result = await Tool(node).CallAsync(CallNaming($"\"{Guid.NewGuid()}\"", BoundTo()), CancellationToken.None);
+
+ result.IsError.ShouldBeFalse(result.Error);
+ node.Captured.ShouldNotBeNull("a node with no repository input never names a repository, so the binding has nothing to hold");
+ }
+
+ [Theory]
+ [InlineData(false, "bound", false)] // a read never meets the publish policy
+ [InlineData(true, "bound", true)] // a write to a bound repository does
+ [InlineData(true, "unbound", false)] // an unbound one is refused as not found first — the policy is never asked about it
+ public async Task Only_a_write_to_a_bound_repository_meets_the_repositorys_publish_policy(bool writes, string target, bool asked)
+ {
+ var named = target == "bound" ? BoundRepository : Guid.NewGuid();
+ var policy = new RecordingRepositoryPolicy(refusal: null);
+ var call = CallNaming($"\"{named}\"", BoundTo(BoundRepository));
+
+ await Tool(new RepositoryNode(writes), policy).CallAsync(call, CancellationToken.None);
+
+ policy.Asked.Count.ShouldBe(asked ? 1 : 0);
+ if (asked) (policy.Asked[0].Bound.RepositoryId, policy.Asked[0].TeamId, policy.Asked[0].Writes).ShouldBe((named, call.TeamId!.Value, true), "the policy is asked about the named repository's write within the run's own team");
+ }
+
+ [Fact]
+ public async Task A_write_the_publish_policy_refuses_never_reaches_the_node()
+ {
+ var node = new RepositoryNode(writes: true);
+ var policy = new RecordingRepositoryPolicy(refusal: "Repository x does not take agent pull-request writes: the repository requires patch-only publishing.");
+
+ var result = await Tool(node, policy).CallAsync(CallNaming($"\"{BoundRepository}\"", BoundTo(BoundRepository)), CancellationToken.None);
+
+ result.IsError.ShouldBeTrue();
+ result.Error.ShouldContain("patch-only");
+ node.Ran.ShouldBeFalse("a write the repository's policy refuses never reaches the provider");
+ }
+
+ [Theory]
+ // access branch (JSON) asked ref the policy judges
+ [InlineData(WorkspaceAccess.Read, "\"secret-branch\"", true, "secret-branch")]
+ [InlineData(WorkspaceAccess.Read, "\" release/1 \"", true, "release/1")] // trimmed, as the node reads it
+ [InlineData(WorkspaceAccess.Read, "\" \"", false, null)] // blank is the default branch — nothing to judge
+ [InlineData(WorkspaceAccess.Read, "42", false, null)] // not a string: the node checks out its default branch
+ [InlineData(WorkspaceAccess.Write, "\"secret-branch\"", false, null)] // the run's own repository: any ref
+ public async Task A_ref_named_on_read_only_context_is_put_to_the_repositorys_policy_before_the_call_is_admitted(WorkspaceAccess access, string branchJson, bool asked, string? judgedRef)
+ {
+ var policy = new RecordingRepositoryPolicy(refusal: null);
+ var caller = new AgentRunPosture { Autonomy = AgentAutonomyLevel.Unleashed, Permissions = new AgentPermissions(), Repositories = [new() { Alias = "ctx", RepositoryId = BoundRepository, Access = access }] };
+ var call = new AgentToolCall { Input = JsonDocument.Parse($$"""{"repositoryId":"{{BoundRepository}}","branch":{{branchJson}}}""").RootElement.Clone(), TeamId = Guid.NewGuid(), CallerPosture = caller };
+
+ (await Tool(new RepositoryNode(writes: false, refInputKey: "branch"), policy).RefusalAsync(call, CancellationToken.None)).ShouldBeNull();
+
+ policy.Asked.Count.ShouldBe(asked ? 1 : 0);
+ if (asked) (policy.Asked[0].Ref, policy.Asked[0].Writes, policy.Asked[0].Bound.Access).ShouldBe((judgedRef, false, access));
+ }
+
+ [Fact]
+ public async Task The_admission_check_and_the_call_refuse_the_same_way()
+ {
+ // The MCP layer asks RefusalAsync before it parks a call for approval; CallAsync asks again when it runs. Both
+ // must give the model the same answer, and neither reaches the node.
+ var unbound = Guid.NewGuid();
+ var node = new RepositoryNode(writes: true);
+ var tool = Tool(node);
+ var call = CallNaming($"\"{unbound}\"", BoundTo(BoundRepository));
+
+ var admitted = await tool.RefusalAsync(call, CancellationToken.None);
+ var called = await tool.CallAsync(call, CancellationToken.None);
+
+ admitted.ShouldBe(AgentRepositoryBinding.NotFound(unbound));
+ called.Error.ShouldBe(admitted);
+ node.Ran.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task A_call_with_no_calling_run_is_admitted_without_a_look_at_the_repository()
+ {
+ var policy = new RecordingRepositoryPolicy(refusal: "should never be asked");
+
+ (await Tool(new RepositoryNode(writes: true), policy).RefusalAsync(CallNaming($"\"{Guid.NewGuid()}\"", caller: null), CancellationToken.None)).ShouldBeNull();
+
+ policy.Asked.ShouldBeEmpty();
+ }
+
+ [Theory]
+ // tool access refused
+ [InlineData("git.open_pr", WorkspaceAccess.Write, false)]
+ [InlineData("git.open_pr", WorkspaceAccess.Read, true)]
+ [InlineData("git.merge_pr", WorkspaceAccess.Write, false)]
+ [InlineData("git.merge_pr", WorkspaceAccess.Read, true)]
+ [InlineData("git.pr_review", WorkspaceAccess.Write, false)]
+ [InlineData("git.pr_review", WorkspaceAccess.Read, true)]
+ [InlineData("git.post_pr_comment", WorkspaceAccess.Write, false)]
+ [InlineData("git.post_pr_comment", WorkspaceAccess.Read, true)]
+ [InlineData("git.post_pr_comment", (WorkspaceAccess)99, true)] // an access this code does not know is held like read-only context
+ public async Task A_pull_request_write_reaches_only_a_repository_the_run_is_bound_to_with_write_access(string kind, WorkspaceAccess access, bool refused)
+ {
+ // The production write nodes over a provider that records what reached it. Read-only context is something the run
+ // reads; it is not the run's to open, merge, review or comment on with the repository's connection credential.
+ var provider = new RecordingPullRequestService();
+ var node = PullRequestWriteNode(kind, provider);
+ var policy = new RecordingRepositoryPolicy(refusal: null);
+ var caller = new AgentRunPosture { Autonomy = AgentAutonomyLevel.Unleashed, Permissions = new AgentPermissions(), Repositories = [new() { Alias = "ctx", RepositoryId = BoundRepository, Access = access }] };
+ var call = new AgentToolCall { Input = PullRequestWriteArguments(BoundRepository), TeamId = Guid.NewGuid(), CallerPosture = caller };
+
+ var outcome = await OutcomeAsync(Tool(node, policy), call);
+
+ if (refused)
+ {
+ outcome.ShouldBe(AgentRepositoryBinding.ReadOnlyContextWrite(BoundRepository));
+ provider.Calls.ShouldBeEmpty("a write to read-only context never reaches the provider");
+ policy.Asked.ShouldBeEmpty("refused on the binding's own access, before the repository's publish policy is consulted");
+ }
+ else provider.Calls.ShouldBe([$"{kind}:{BoundRepository}"], "a write to a repository the run is bound to writably reaches the provider");
+ }
+
+ private static INodeRuntime PullRequestWriteNode(string kind, IPullRequestService provider) => kind switch
+ {
+ "git.open_pr" => new GitOpenPullRequestNode(provider),
+ "git.merge_pr" => new GitMergePullRequestNode(provider),
+ "git.pr_review" => new GitPrReviewNode(provider),
+ _ => new GitPostPrCommentNode(provider),
+ };
+
+ /// The required inputs of every pull-request write at once — each node reads its own keys and ignores the rest.
+ private static JsonElement PullRequestWriteArguments(Guid repositoryId) => JsonSerializer.SerializeToElement(new
+ {
+ repositoryId = repositoryId.ToString(), number = 7, title = "t", sourceBranch = "feature", targetBranch = "main", body = "b", verdict = "comment",
+ });
+
+ /// What a call came back with — "reached" when the provider was called, else the tool's error.
+ private static async Task OutcomeAsync(NodeAgentTool tool, AgentToolCall call)
+ {
+ try
+ {
+ var result = await tool.CallAsync(call, CancellationToken.None);
+ return result.IsError ? result.Error ?? "" : "ok";
+ }
+ catch (ProviderReachedException)
+ {
+ return "reached";
+ }
+ }
+
+ private sealed class ProviderReachedException : Exception;
+
+ /// Records each pull-request write that reached it, then stops the node — what happens past the provider call is not under test.
+ private sealed class RecordingPullRequestService : IPullRequestService
+ {
+ public List Calls { get; } = new();
+
+ private Exception Reached(string kind, Guid repositoryId)
+ {
+ Calls.Add($"{kind}:{repositoryId}");
+ return new ProviderReachedException();
+ }
+
+ public Task> ListAsync(Guid repositoryId, Guid teamId, PullRequestState? state, int page, int perPage, CancellationToken cancellationToken) => throw new NotSupportedException();
+ public Task GetAsync(Guid repositoryId, Guid teamId, int number, CancellationToken cancellationToken) => throw new NotSupportedException();
+ public Task> ListCommitsAsync(Guid repositoryId, Guid teamId, int number, CancellationToken cancellationToken) => throw new NotSupportedException();
+ public Task> ListFilesAsync(Guid repositoryId, Guid teamId, int number, CancellationToken cancellationToken) => throw new NotSupportedException();
+ public Task GetCountsAsync(Guid repositoryId, Guid teamId, CancellationToken cancellationToken) => throw new NotSupportedException();
+ public Task> ListChecksAsync(Guid repositoryId, Guid teamId, int number, CancellationToken cancellationToken) => throw new NotSupportedException();
+ public Task PostCommentAsync(Guid repositoryId, Guid teamId, int number, string body, CancellationToken cancellationToken) => throw Reached("git.post_pr_comment", repositoryId);
+ public Task SubmitReviewAsync(Guid repositoryId, Guid teamId, int number, PullRequestReviewVerdict verdict, string? body, Guid? actorUserId, CancellationToken cancellationToken) => throw Reached("git.pr_review", repositoryId);
+ public Task OpenPullRequestAsync(Guid repositoryId, Guid teamId, OpenPullRequestInput input, Guid? actorUserId, CancellationToken cancellationToken) => throw Reached("git.open_pr", repositoryId);
+ public Task MergePullRequestAsync(Guid repositoryId, Guid teamId, int number, MergePullRequestInput input, Guid? actorUserId, CancellationToken cancellationToken) => throw Reached("git.merge_pr", repositoryId);
+ }
}
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBuildAgentTaskTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBuildAgentTaskTests.cs
index 971970eeb..fc6f6e1cf 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBuildAgentTaskTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorBuildAgentTaskTests.cs
@@ -299,6 +299,23 @@ public void A_dispatch_targets_a_subset_of_the_bound_repos()
task.Workspace.Repositories.ShouldNotContain(r => r.RepositoryId == sdk, "a related repo the agent did not target is excluded");
}
+ [Fact]
+ public void A_dispatch_cannot_widen_the_ref_its_childs_read_only_binding_pins_commands_to()
+ {
+ // The spawn's targetRepos is model-authored and its schema has no ref, but the server does not enforce the
+ // schema's additionalProperties. The child's workspace — and so the binding its agent.run_command is pinned to —
+ // must carry the operator's ref for read-only context, never one the supervisor model named.
+ var primary = Guid.NewGuid(); var api = Guid.NewGuid(); var sdk = Guid.NewGuid(); // sdk is bound READ-only, at its default branch
+ var spec = new SupervisorAgentDispatch { SubtaskId = SubtaskId, TargetRepos = JsonDocument.Parse($$"""[{"repositoryId":"{{sdk}}","access":"read","ref":"secret-branch"}]""").RootElement };
+
+ var task = BuildWithSpec(BoundContext(primary, api, sdk), spec);
+
+ var caller = new AgentRunPosture { Autonomy = AgentAutonomyLevel.Unleashed, Permissions = new AgentPermissions(), Repositories = task.Workspace!.Repositories };
+ var binding = AgentRepositoryBinding.Find(caller, sdk).ShouldNotBeNull();
+ binding.Ref.ShouldBeNull("the operator bound sdk at its default branch");
+ AgentRepositoryBinding.AllowsRef(binding, "secret-branch", "main").ShouldBeFalse("the child's commands stay pinned to what the operator bound");
+ }
+
[Fact]
public void A_dispatch_primary_override_within_bound_becomes_the_agents_primary()
{
diff --git a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorRepoClampTests.cs b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorRepoClampTests.cs
index f0ffdf785..0f3d3a5cc 100644
--- a/backend/tests/CodeSpace.UnitTests/Agents/SupervisorRepoClampTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Agents/SupervisorRepoClampTests.cs
@@ -162,6 +162,40 @@ public void A_clamped_subset_takes_each_bound_repos_own_pin_and_discards_a_model
result.Single(r => r.RepositoryId == SdkReadOnly).PinnedSha.ShouldBeNull("no launch pin on the bound spec ⇒ none on the clamp output — never the model's invention");
}
+ [Fact]
+ public void A_clamped_subset_takes_each_bound_repos_own_ref_and_discards_a_model_authored_one()
+ {
+ // The ref is what the child clones and what its read-only binding then pins its commands to, so it is the
+ // operator's narrowing, never the supervisor model's: an authored ref, soft fallback or recovery anchor is
+ // discarded for the BOUND spec's own (none ⇒ the default branch).
+ var boundWithRefs = new[]
+ {
+ new WorkspaceRepositorySpec { RepositoryId = ApiWritable, Alias = "api", Access = WorkspaceAccess.Write },
+ new WorkspaceRepositorySpec { RepositoryId = SdkReadOnly, Alias = "sdk", Access = WorkspaceAccess.Read, Ref = "release/1", RefSoftFallback = true, RefRecoverySha = "ccc333ccc333" },
+ };
+
+ var authored = JsonSerializer.SerializeToElement(new object[]
+ {
+ new { repositoryId = ApiWritable, access = "write", @ref = "secret-branch", refSoftFallback = true, refRecoverySha = "deadbeefdead" },
+ new { repositoryId = SdkReadOnly, access = "read", @ref = "secret-branch" },
+ new { repositoryId = Primary, access = "read", @ref = "secret-branch" },
+ });
+
+ var result = SupervisorRepoClamp.IntersectWithBoundRepos(authored, Primary, boundWithRefs);
+
+ var api = result.Single(r => r.RepositoryId == ApiWritable);
+ api.Ref.ShouldBeNull("the operator bound api at its default branch — the model's ref is discarded");
+ api.RefSoftFallback.ShouldBeFalse();
+ api.RefRecoverySha.ShouldBeNull();
+
+ var sdk = result.Single(r => r.RepositoryId == SdkReadOnly);
+ sdk.Ref.ShouldBe("release/1", "read-only context keeps the ref the operator bound");
+ sdk.RefSoftFallback.ShouldBeTrue();
+ sdk.RefRecoverySha.ShouldBe("ccc333ccc333");
+
+ result.Single(r => r.RepositoryId == Primary).Ref.ShouldBeNull("the operator's primary, targeted as context, is cloned at its default branch — never the model's ref");
+ }
+
// ── Helpers ───
private static IReadOnlyList Clamp(JsonElement authored) =>