Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3672,7 +3672,7 @@

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

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

View workflow job for this annotation

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

Possible null reference assignment.

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

View workflow job for this annotation

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

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.

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

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

View workflow job for this annotation

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

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

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

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

View workflow job for this annotation

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

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

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

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.
}

/// <summary>Ask the row, on a token of its own, whether the run actually reached a terminal state — the only honest answer to "did the landing take?" once an exception has been raised somewhere after the fenced write.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using CodeSpace.Core.DependencyInjection;

namespace CodeSpace.Core.Services.Agents.Commands;

/// <summary>
/// One <c>agent.run_command</c> at a time per calling agent run. Each command gets a cgroup leaf of its own, beside the
/// agent's leaf rather than inside it, carrying the run's whole tier row (<c>RunCommandService.BuildSpec</c>). The run
/// token in the agent's config lets it open as many endpoint connections as it likes, so commands it started at once
/// would each hold a full row. Queued here, the commands one run has running never hold more than one row between them.
/// The agent's own leaf is separate, so an agent and its one running command can together hold up to two rows.
///
/// <para>Process-local by design: a run's MCP endpoint, and so every command its agent asks for, lives in the worker
/// that launched it. A lane exists only while a command of its run holds or awaits it.</para>
/// </summary>
public sealed class CallerCommandLanes : ISingletonDependency
{
private readonly Dictionary<Guid, Lane> _lanes = new();
private readonly object _gate = new();

/// <summary>How many runs currently hold or await a lane — what a test reads to prove a finished run leaves nothing behind.</summary>
internal int Count
{
get { lock (_gate) return _lanes.Count; }
}

/// <summary>Wait for <paramref name="runId"/>'s lane and hold it until the returned handle is disposed. A cancelled wait gives its place back.</summary>
public async Task<IAsyncDisposable> EnterAsync(Guid runId, CancellationToken cancellationToken)
{
var lane = Join(runId);

try
{
await lane.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
Leave(runId, lane, held: false);
throw;
}

return new Held(this, runId, lane);
}

private Lane Join(Guid runId)
{
lock (_gate)
{
if (!_lanes.TryGetValue(runId, out var lane)) _lanes[runId] = lane = new Lane();

lane.Users++;

return lane;
}
}

private void Leave(Guid runId, Lane lane, bool held)
{
if (held) lane.Semaphore.Release();

lock (_gate)
{
if (--lane.Users > 0) return;

_lanes.Remove(runId);
lane.Semaphore.Dispose();
}
}

private sealed class Lane
{
public SemaphoreSlim Semaphore { get; } = new(1, 1);
public int Users { get; set; }
}

private sealed class Held(CallerCommandLanes lanes, Guid runId, Lane lane) : IAsyncDisposable
{
private int _released;

public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _released, 1) == 0) lanes.Leave(runId, lane, held: true);

return ValueTask.CompletedTask;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using CodeSpace.Core.Persistence.Db;
using CodeSpace.Core.Persistence.Entities;
using CodeSpace.Core.Services.Agents.Sandbox;
using CodeSpace.Core.Services.Agents.Sandbox.Isolation;
using CodeSpace.Core.Services.Agents.Workspace;
using CodeSpace.Core.Services.Providers;
using CodeSpace.Core.Services.Providers.Auth;
Expand All @@ -18,21 +19,25 @@ public sealed class RunCommandService : IRunCommandService, IScopedDependency
private readonly ISandboxRunnerRegistry _runners;
private readonly IWorkspaceProviderRegistry _workspaces;
private readonly AgentDefaultRunnerSetting _defaultRunner;
private readonly CallerCommandLanes _lanes;

public RunCommandService(CodeSpaceDbContext db, IProviderAuthResolver auth, ISandboxRunnerRegistry runners, IWorkspaceProviderRegistry workspaces, AgentDefaultRunnerSetting defaultRunner)
public RunCommandService(CodeSpaceDbContext db, IProviderAuthResolver auth, ISandboxRunnerRegistry runners, IWorkspaceProviderRegistry workspaces, AgentDefaultRunnerSetting defaultRunner, CallerCommandLanes lanes)
{
_db = db;
_auth = auth;
_runners = runners;
_workspaces = workspaces;
_defaultRunner = defaultRunner;
_lanes = lanes;
}

public async Task<SandboxResult> RunAsync(RunCommandRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Command))
throw new InvalidOperationException("A command is required.");

await using var lane = await EnterCallerLaneAsync(request.CallerPosture, cancellationToken).ConfigureAwait(false);

var runnerKind = string.IsNullOrWhiteSpace(request.RunnerKind) ? _defaultRunner.Value : request.RunnerKind;
var runner = _runners.Resolve(runnerKind);

Expand All @@ -52,6 +57,38 @@ public async Task<SandboxResult> RunAsync(RunCommandRequest request, Cancellatio
}
}

/// <summary>
/// A command an agent asked for waits its turn among its run's commands (<see cref="CallerCommandLanes"/>), so the
/// commands one run has running never hold more than one tier row of cgroup ceilings between them. A workflow
/// node's command has no calling run and never waits.
/// </summary>
private async Task<IAsyncDisposable?> EnterCallerLaneAsync(AgentRunPosture? caller, CancellationToken cancellationToken) =>
caller is null ? null : await _lanes.EnterAsync(caller.RunId, cancellationToken).ConfigureAwait(false);

/// <summary>
/// What a command an agent asked network for lost to its calling run's posture, as one line the agent and whoever
/// approved the call can read on the tool result — or null when nothing was lost: no calling run (a workflow node),
/// no network asked for (or none the deployment ceiling allows anyway), or granted as asked. Derived from the same
/// projection <see cref="BuildSpec"/> runs, so it can never disagree with the sandbox the command got. "Off" carries
/// the confinement caveat: it is severed only where the sandbox confines.
/// </summary>
public static string? CallerNetworkNarrowing(RunCommandRequest request)
{
if (request.CallerPosture is not { } caller) return null;

var authored = AuthoredSpec(request, workingDirectory: null);

if (!authored.AllowNetwork) return null;

return WithinCallerPosture(authored, caller) switch
{
{ AllowNetwork: false } when caller.Permissions.Network != AgentNetworkAccess.On => $"off: the calling run ({caller.Autonomy}) has no network{AgentAutonomyPolicy.ConfinementCaveat}",
{ AllowNetwork: false } => $"off: the calling run's egress allowlist names no host a command may reach{AgentAutonomyPolicy.ConfinementCaveat}",
{ EgressAllowlist: { Count: > 0 } hosts } => $"narrowed to the calling run's egress allowlist ({string.Join(", ", hosts)})",
_ => null,
};
}

/// <summary>
/// The request → <see cref="SandboxSpec"/> projection, with the deployment autonomy ceiling
/// (<c>Sandbox:MaxAutonomy</c>) narrowing the requested egress. This lane has NO autonomy tier anywhere in its
Expand All @@ -61,10 +98,16 @@ public async Task<SandboxResult> RunAsync(RunCommandRequest request, Cancellatio
/// sandbox enforces) has the last word instead. NARROW-ONLY: a ceiling that grants network leaves the request
/// exactly as asked, so the committed default clamps nothing.
///
/// <para>A command an AGENT asked for (<see cref="RunCommandRequest.CallerPosture"/> set) is then narrowed to that
/// agent's own run by <see cref="WithinCallerPosture"/>; a workflow node's command is exactly as above.</para>
///
/// <para>Internal (not private) so the narrowing is unit-pinned directly (InternalsVisibleTo) rather than only
/// through a runner that would have to be confining to show it.</para>
/// </summary>
internal static SandboxSpec BuildSpec(RunCommandRequest request, string? workingDirectory) => new()
internal static SandboxSpec BuildSpec(RunCommandRequest request, string? workingDirectory) => WithinCallerPosture(AuthoredSpec(request, workingDirectory), request.CallerPosture);

/// <summary>The command as authored, under the deployment ceiling alone — what a workflow node's command runs as, and what an agent's is narrowed from.</summary>
private static SandboxSpec AuthoredSpec(RunCommandRequest request, string? workingDirectory) => new()
{
Command = request.Command,
Args = request.Args,
Expand All @@ -77,6 +120,42 @@ public async Task<SandboxResult> RunAsync(RunCommandRequest request, Cancellatio
MaxFileSizeMb = request.MaxFileSizeMb,
};

/// <summary>
/// Narrow a command an agent asked for through its tool fabric to the posture of the agent's OWN run. The command's
/// sandbox is a sandbox of its own, so without this a network-off agent could hand itself the internet by asking
/// for <c>"network": true</c>, and every command it ran was uncapped. NARROW-ONLY: the network stays only when the
/// command asked for it, the deployment ceiling allows it (above) AND the run has it; the ceilings are those of the
/// run's tier clamped by the deployment ceiling, narrowed by the operator's host memory budget — the same table and
/// budget <c>AgentRunExecutor.ApplyResourceCeilings</c> holds the run itself to. Those ceilings land on a cgroup leaf
/// of the command's own, beside the agent's: they bound the command, not the run as a whole, which is why a run's
/// commands also queue (<see cref="CallerCommandLanes"/>). No caller (a workflow node) ⇒ the spec is returned untouched.
/// </summary>
private static SandboxSpec WithinCallerPosture(SandboxSpec spec, AgentRunPosture? caller)
{
if (caller is null) return spec;

var ceilings = AgentAutonomyPolicy.Ceilings(AgentAutonomyPolicy.Clamp(caller.Autonomy, AgentAutonomyPolicy.DeploymentCeiling), RuntimeSettings.Current.AgentMemoryCeilingMb);
var narrowed = spec with { AllowNetwork = spec.AllowNetwork && caller.Permissions.Network == AgentNetworkAccess.On, MaxMemoryMb = ceilings.MemoryMb, MaxCpuPercent = ceilings.CpuPercent };

return WithinCallerEgress(narrowed, caller.Permissions);
}

/// <summary>
/// An allowlisted caller's command reaches ONLY the operator's extra hosts (<see cref="AgentPermissions.EgressAllowHosts"/>).
/// The run's own allowlist adds its model host and its repositories' git hosts; a command needs neither, and a
/// repository the command names may sit on a host the run never had, so the extra hosts are the one part that is a
/// strict subset of the run's reach. None ⇒ severed, never full egress — the same fail-closed rule
/// <c>AgentRunExecutor.ApplyEgressPolicy</c> applies to the run itself.
/// </summary>
private static SandboxSpec WithinCallerEgress(SandboxSpec spec, AgentPermissions permissions)
{
if (!spec.AllowNetwork || permissions.Egress != AgentEgressPolicy.Allowlist) return spec;

var hosts = EgressAllowlistBuilder.Build(modelBaseUrl: null, modelProvider: null, Array.Empty<string>(), permissions.EgressAllowHosts);

return hosts.Count == 0 ? spec with { AllowNetwork = false } : spec with { EgressAllowlist = hosts };
}

/// <summary>
/// Repo → clone request: load the repository (by id, like the git.* node services), resolve a short-lived
/// token through the same provider auth layer the resolver uses, and reuse its provider→username table so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace CodeSpace.Core.Services.Agents.Mcp;
/// One run's live MCP endpoint over a PER-RUN Unix-domain socket: it binds + listens on the run's socket path, accepts
/// connections in a loop, and for each connection validates the per-run <c>CODESPACE_RUN_TOKEN</c> on the FIRST line
/// before serving — then pumps one <see cref="McpFramingLoop"/> (a fresh <see cref="McpRequestHandler"/> bound to the
/// run's tool registry + autonomy + team + secret redactor) over the socket's <see cref="NetworkStream"/>. Every
/// run's tool registry + autonomy + permissions + team + secret redactor) over the socket's <see cref="NetworkStream"/>. Every
/// tool-result text the handler returns is run through the run's <see cref="SecretRedactor"/>, so an echoed model key
/// never reaches the model. The connect descriptor
/// (socket path + token) is registered with the <see cref="IAgentMcpConnectRegistry"/> under the run id so a consumer
Expand Down Expand Up @@ -48,6 +48,7 @@ public sealed class AgentMcpEndpoint : IAsyncDisposable
private readonly bool _governanceEnabled;
private readonly Guid? _approvalConversationId;
private readonly McpCatalogMode _catalogMode;
private readonly AgentPermissions? _permissions;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts;
private readonly Socket _listener;
Expand All @@ -56,7 +57,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)
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)
{
_runId = runId;
_registry = registry;
Expand All @@ -71,6 +72,7 @@ public AgentMcpEndpoint(Guid runId, IAgentToolRegistry registry, AgentAutonomyLe
_governanceEnabled = governanceEnabled;
_approvalConversationId = approvalConversationId;
_catalogMode = catalogMode;
_permissions = permissions;
_logger = logger;
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_counters = new McpFabricCounters();
Expand Down Expand Up @@ -187,7 +189,7 @@ private async Task ServeConnectionAsync(Socket conn, CancellationToken ct)

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

var handler = new AuthorizedMcpRequestHandler(protocol, authorityContext);

Expand Down
Loading
Loading