From 86c9a1b30e8446088e2dafdf842ff9d7dc6b869b Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Wed, 7 Oct 2026 20:28:32 +0800 Subject: [PATCH] Bind agent tool calls to the run's repositories agent.run_command and the git.* pull-request tools resolved a model-supplied repositoryId by id and team only, so an agent could clone, read, merge or comment on any repository of its team at any ref it named, including repositories its run was never bound to and unmerged branches, using that repository's connection credential. Network Off and a read-only write scope did not stop it. The run's bound repositories (its admitted task's workspace, with each repository's access and ref) are now stamped server-side onto the posture every tool call carries. NodeAgentTool holds every node that declares a repository input (NodeManifest.RepositoryInput) to that set: any other id gets the same "not found" a missing or foreign one gets. Read-only context is the run's to read, not to write: an agent opens, merges, reviews and comments on none of its pull requests, and a command checks it out only at its bound or default branch. The pin covers what a command checks out; the repository's pull requests stay readable through the git read tools. A patch-only repository refuses agent pull-request writes through the guard chain an agent's pushed branch meets. Each of these refusals is answered before a call is parked for approval (IAgentTool.RefusalAsync), so a Standard or Trusted run never asks a human to approve a call that could only be refused; the tool checks again when the call runs, since a repository can change while a card waits. RunCommandService keeps its own ref pin for a caller that reaches it directly. A supervisor-spawned child's related repositories carry the operator's bound ref, not one the supervisor model authored: that ref is what the child clones and what its read-only binding pins commands to. A run whose write scope is read-only (an agent.run with readOnly, or a Confined tier) is served a NonDestructive catalog: every tool that does not write, so it can still ask a human through decision.request. The fabric opt-out's ReadOnly slice is unchanged. Workflow-node calls carry no calling run and are unchanged. --- .../Services/Agents/AgentRepositoryBinding.cs | 55 ++ .../Services/Agents/AgentRunExecutor.cs | 19 +- .../Agents/Commands/RunCommandService.cs | 40 +- .../Agents/Eval/Benchmark/BenchmarkRunner.cs | 9 +- .../Services/Agents/Mcp/AgentMcpEndpoint.cs | 14 +- .../Agents/Mcp/AuthorizedMcpRequestHandler.cs | 1 + .../Services/Agents/Mcp/McpRequestHandler.cs | 48 +- .../Agents/Tools/AgentRepositoryPolicy.cs | 65 +++ .../Agents/Tools/AgentToolRegistry.cs | 4 +- .../Services/Agents/Tools/IAgentTool.cs | 8 + .../Services/Agents/Tools/NodeAgentTool.cs | 87 +++- .../Supervisor/SupervisorRepoClamp.cs | 14 +- .../Nodes/Builtin/AgentRunCommandNode.cs | 3 + .../Nodes/Builtin/GitFetchPrChecksNode.cs | 2 + .../Nodes/Builtin/GitFetchPrDiffNode.cs | 2 + .../Nodes/Builtin/GitListPullRequestsNode.cs | 2 + .../Nodes/Builtin/GitMergePullRequestNode.cs | 2 + .../Nodes/Builtin/GitOpenPullRequestNode.cs | 2 + .../Nodes/Builtin/GitPostPrCommentNode.cs | 2 + .../Nodes/Builtin/GitPrReviewNode.cs | 2 + .../Services/Workflows/Nodes/NodeManifest.cs | 33 ++ .../Agents/AgentRepositoryUse.cs | 21 + .../Agents/AgentRunPosture.cs | 18 +- .../Agents/McpCatalogMode.cs | 5 + .../Agents/AgentMcpEndpointFlowTests.cs | 154 +++++- .../Agents/AgentToolRegistryFlowTests.cs | 25 + .../AgentToolRepositoryBindingFlowTests.cs | 474 ++++++++++++++++++ .../Agents/BenchmarkRunnerFlowTests.cs | 21 +- .../Agents/McpNodeLifetimeFlowTests.cs | 2 +- .../Agents/McpToolTeamScopeFlowTests.cs | 19 +- .../Agents/AgentRepositoryBindingTests.cs | 93 ++++ .../Agents/AgentRepositoryPolicyTests.cs | 92 ++++ .../Agents/AgentToolRegistryTests.cs | 32 +- .../Agents/McpCatalogModeTests.cs | 53 +- .../Agents/McpRequestHandlerTests.cs | 77 ++- .../Agents/NodeAgentToolTests.cs | 271 +++++++++- .../Agents/SupervisorBuildAgentTaskTests.cs | 17 + .../Agents/SupervisorRepoClampTests.cs | 34 ++ 38 files changed, 1752 insertions(+), 70 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Services/Agents/AgentRepositoryBinding.cs create mode 100644 backend/src/CodeSpace.Core/Services/Agents/Tools/AgentRepositoryPolicy.cs create mode 100644 backend/src/CodeSpace.Messages/Agents/AgentRepositoryUse.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Agents/AgentToolRepositoryBindingFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryBindingTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Agents/AgentRepositoryPolicyTests.cs 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) =>