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) =>