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
10 changes: 5 additions & 5 deletions .github/workflows/sandbox-isolation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ jobs:
executed=$(grep -oE 'executed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+')
passed=$(grep -oE 'passed="[0-9]+"' "$trx" | head -1 | grep -oE '[0-9]+')
echo "executed=${executed:-0} passed=${passed:-0}"
if [ "${executed:-0}" -lt 98 ]; then
echo "::error::Expected >=98 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run reaching its broker through the relay and nothing else + an allowlist run relayed to its broker + a read-only reviewer reading its diff with the real CLIs + a bwrap probe that runs the launch argv + the MCP helper bound file by file behind a read-only socket dir + a CLI reaching its broker socket through the relay + a severed child reaching its broker over the lease socket across a worker restart + a pre-relay namespaced run re-bound at its gateway and torn down with its seal + an allowlist run's veth guarded both ways, a flow the worker opened before the run included + forwarding a root worker may not write named before an allowlist is planned + an allowlist run's port 53 open only at the resolvers of the resolv.conf its namespace reads, and still to a resolver address the worker's own NAT rewrites before its forward and its input hooks + a target repository's own CLI config kept out of the run with the real CLIs, every repository's memory read in a multi-repo workspace included + a multi-repo Codex run starting at a workspace root that is no git repository and loading no config from it + a repo-less Codex run starting in a scratch directory that is no git repository + a goal handed to the real Claude CLI as text it cannot act on, fresh and resumed, against the text channel as its control + a repository clean filter the capture runs with its egress severed + a real Codex agent that tampers its clone's .git while the platform publishes the branch from a clean repo, its Claude counterpart running in the non-root lane), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR."
if [ "${executed:-0}" -lt 99 ]; then
echo "::error::Expected >=99 sandbox isolation tests to run (bwrap/prlimit confinement + cap-drop + cgroup-namespace re-root + egress-allowlist filter + cgroup resource cap + durable-launch cgroup wiring + argv/envp per-string kernel ceiling + a prompt past it riding stdin + a read-only workspace mount + a network-off run reaching its broker through the relay and nothing else + an allowlist run relayed to its broker + a read-only reviewer reading its diff with the real CLIs + a bwrap probe that runs the launch argv + the MCP helper bound file by file behind a read-only socket dir + a CLI reaching its broker socket through the relay + a severed child reaching its broker over the lease socket across a worker restart + a pre-relay namespaced run re-bound at its gateway and torn down with its seal + an allowlist run's veth guarded both ways, a flow the worker opened before the run included + forwarding a root worker may not write named before an allowlist is planned + an allowlist run's port 53 open only at the resolvers of the resolv.conf its namespace reads, and still to a resolver address the worker's own NAT rewrites before its forward and its input hooks + a target repository's own CLI config kept out of the run with the real CLIs, every repository's memory read in a multi-repo workspace included + a repository whose memory links outside the workspace left out of a real Claude run, against the CLI following the link once that repository is added + a multi-repo Codex run starting at a workspace root that is no git repository and loading no config from it + a repo-less Codex run starting in a scratch directory that is no git repository + a goal handed to the real Claude CLI as text it cannot act on, fresh and resumed, against the text channel as its control + a repository clean filter the capture runs with its egress severed + a real Codex agent that tampers its clone's .git while the platform publishes the branch from a clean repo, its Claude counterpart running in the non-root lane), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR."
exit 1
fi

Expand Down Expand Up @@ -268,12 +268,12 @@ jobs:
print(f'All {len(arms)} reviewer E2E arms ran and passed.')

# The repository-config E2E is armed by the same CLI pins and returns early the same way; require each arm's marker.
for arm in ('claude-code single-repo Confined', 'claude-code multi-repo Confined', 'codex-cli single-repo', 'codex-cli multi-repo', 'codex-cli scratch'):
for arm in ('claude-code single-repo Confined', 'claude-code multi-repo Confined', 'memory-link-outside claude-code multi-repo Confined', 'codex-cli single-repo', 'codex-cli multi-repo', 'codex-cli scratch'):
assert f'[repo-config-e2e] ran {arm}' in text, f'repository-config E2E arm "{arm}" did not run — check CODESPACE_REQUIRE_REVIEW_CLIS and the CLI install step'
for method in ('A_claude_run_ignores_the_settings_its_repository_commits_and_still_reads_its_memory', 'A_multi_repo_claude_run_reads_every_repositorys_memory_and_none_of_its_settings', 'A_codex_run_ignores_the_config_and_hooks_its_repository_commits_and_still_reads_its_agents_md', 'A_multi_repo_codex_run_starts_at_a_workspace_root_that_is_no_repository_and_loads_no_config_from_it', 'A_repo_less_codex_run_starts_in_a_scratch_directory_that_is_no_repository'):
for method in ('A_claude_run_ignores_the_settings_its_repository_commits_and_still_reads_its_memory', 'A_multi_repo_claude_run_reads_every_repositorys_memory_and_none_of_its_settings', 'A_claude_run_leaves_out_repository_memory_that_links_outside_the_workspace', 'A_codex_run_ignores_the_config_and_hooks_its_repository_commits_and_still_reads_its_agents_md', 'A_multi_repo_codex_run_starts_at_a_workspace_root_that_is_no_repository_and_loads_no_config_from_it', 'A_repo_less_codex_run_starts_in_a_scratch_directory_that_is_no_repository'):
cases = [r for r in results if 'RepositoryConfigE2ETests.' + method in r.get('testName', '')]
assert len(cases) == 1 and cases[0].get('outcome') == 'Passed', f'{method}: must pass'
print('All 5 repository-config E2E arms ran and passed.')
print('All 6 repository-config E2E arms ran and passed.')

# The goal-channel E2E is armed by the same CLI pins and returns early the same way; require each arm's marker,
# confined, so the positive control it checks against is this lane's posture and not an unconfined one.
Expand Down
53 changes: 53 additions & 0 deletions backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,10 @@
if (ranCold)
await RecordRunColdAsync(owner, task with { Model = dispatchedModel }, LaunchRanColdNote, cancellationToken).ConfigureAwait(false);

// The same rule for what the harness left out of the launch. Said again only when a revise round's build
// leaves out something else: the agent changes the workspace those notices describe.
var launchNotices = await AppendLaunchNoticesAsync(owner, spec, said: null, cancellationToken).ConfigureAwait(false);

var result = await RunHarnessAsync(runContext, cancellationToken).ConfigureAwait(false);
result = AgentRunBudget.Apply(effectiveTask, result, modelPrices);

Expand Down Expand Up @@ -678,6 +682,8 @@
if (roundRanCold)
await RecordRunColdAsync(owner, null, ReviseRanColdNote, cancellationToken).ConfigureAwait(false);

launchNotices = await AppendLaunchNoticesAsync(owner, reviseSpec, launchNotices, cancellationToken).ConfigureAwait(false);

var roundResult = await RunHarnessAsync(runContext with { Spec = reviseSpec, Task = reviseTask, SpoolKey = ReviseSpoolKey(agentRunId, round) }, cancellationToken).ConfigureAwait(false);
result = AgentRunBudget.Apply(reviseTask with { BudgetSpentUsd = result.CumulativeCostUsd }, roundResult, modelPrices) with { TokenUsage = SumTokenUsage(priorUsage, roundResult.TokenUsage), ReviseRounds = round };

Expand Down Expand Up @@ -2702,6 +2708,53 @@
}
}

/// <summary>The most launch notices one timeline event repeats; the rest are counted. Pinned by a unit test.</summary>
internal const int MaxLaunchNotices = 10;

/// <summary>The launch notices as one event's text — the first <see cref="MaxLaunchNotices"/> in order, then how many more there were; null when there are none.</summary>
internal static string? DescribeLaunchNotices(IReadOnlyList<string> notices)
{
if (notices.Count == 0) return null;

var shown = string.Join(" ", notices.Take(MaxLaunchNotices));

return notices.Count > MaxLaunchNotices ? $"{shown} ({notices.Count - MaxLaunchNotices} more)" : shown;
}

/// <summary>The timeline's account of a revise round that leaves out none of what an earlier launch of the run left out.</summary>
internal const string LaunchNoticesClearedNote = "Left no memory out of this round: what an earlier round of this run left out loads again.";

/// <summary>
/// What a launch's notices add to a timeline that last said <paramref name="said"/> (<see cref="DescribeLaunchNotices"/>,
/// null for nothing): null when they say the same, so an unchanged workspace is announced once per run; their text
/// when they differ; and <see cref="LaunchNoticesClearedNote"/> when a launch leaves nothing out that the last one did.
/// </summary>
internal static string? DescribeLaunchNoticeChange(IReadOnlyList<string> notices, string? said)
{
var text = DescribeLaunchNotices(notices);

if (text == said) return null;

return text ?? LaunchNoticesClearedNote;
}

/// <summary>Say on the timeline what the harness left out of this launch (<see cref="SandboxSpec.LaunchNotices"/>) — a repository's memory that links outside the workspace, for one — so a run missing its instructions says why, and say it again only when a revise round's launch leaves out something else (<see cref="DescribeLaunchNoticeChange"/>). Returns what the timeline now says. One bounded event per change; best-effort like the other launch notes.</summary>
private async Task<string?> AppendLaunchNoticesAsync(AgentRunOwnerToken owner, SandboxSpec spec, string? said, CancellationToken cancellationToken)
{
if (DescribeLaunchNoticeChange(spec.LaunchNotices, said) is not { } text) return said;

try
{
await _runs.AppendEventAsync(owner, new AgentEvent { Kind = AgentEventKind.Warning, Text = text }, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException and not AgentRunOwnershipLostException)
{
_logger.LogWarning(ex, "Agent run {RunId}: could not record the launch notices", owner.RunId);
}

return DescribeLaunchNotices(spec.LaunchNotices);
}

/// <summary>Announce the escalation on the timeline — the operator sees the run reached for a stronger model and WHY, or that it wanted to and the team had nothing stronger. Best-effort like the other completion-tail events.</summary>
private async Task AppendEscalationEventAsync(AgentRunOwnerToken owner, AgentModelEscalation escalation, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -5006,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 / recurring jobs fire (worker host · 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 (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 / recurring jobs fire (worker host · 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 (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
Expand Up @@ -202,6 +202,9 @@ public SandboxSpec BuildInvocation(AgentTask task)
// model sees it.
var args = new List<string> { "--print", "--output-format", "stream-json", "--verbose", "--input-format", "stream-json" };

// The directories added back for their memory, less any whose memory reaches outside the workspace (see AppendSettingsPin).
var memory = ClaudeWorkspaceMemory.For(task);

// P3.2: a CONTINUE re-stage threads the prior session id as `--resume <id>` to pick up the conversation.
// Placed right after the seed — before the variadic --allowed-tools / --permission-mode — so the variadic can
// never swallow it. The continuation prompt rides stdin like any other, in the same message. Null (a fresh run) → omitted.
Expand All @@ -223,7 +226,7 @@ public SandboxSpec BuildInvocation(AgentTask task)
// and the requirement is that we NEVER pass --bare / --safe-mode (guarded by a unit test). What every run does
// get is the settings pin — one mechanism, no per-run condition: the target repository's own .claude settings
// are untrusted input whether or not this run writes settings of its own (see AppendSettingsPin).
AppendSettingsPin(args, task);
AppendSettingsPin(args, memory.Directories);

AppendSealedEgressSettings(args, task);

Expand Down Expand Up @@ -255,7 +258,7 @@ public SandboxSpec BuildInvocation(AgentTask task)
Args = args,
StandardInput = PromptMessage(task.Goal),
WorkingDirectory = task.WorkspaceDirectory,
Environment = BuildEnvironment(task),
Environment = BuildEnvironment(task, memory.Directories),
TimeoutSeconds = task.TimeoutSeconds,
// Isolate Claude Code's config dir per run so it ignores the operator's personal ~/.claude.
ConfigHomeEnvVars = new[] { ConfigDirEnvVar },
Expand All @@ -268,6 +271,8 @@ public SandboxSpec BuildInvocation(AgentTask task)
ConfigHomeFiles = BuildConfigHomeFiles(task),
// The agent reaches the network only when its permissions allow it (the sandbox severs egress otherwise).
AllowNetwork = task.Permissions.Network == AgentNetworkAccess.On,
// A repository's memory left out because it links outside the workspace — the run's timeline says so.
LaunchNotices = memory.Notices,
};
}

Expand Down Expand Up @@ -583,19 +588,19 @@ private static string PermissionMode(AgentPermissions permissions) =>
/// <summary>
/// The child env: the task's env, plus harness-injected entries — the <see cref="DisableNonEssentialTrafficEnvVar"/>
/// for an Allowlist (deny-by-default) egress run (so the CLI doesn't stall reaching telemetry hosts the allowlist
/// doesn't pin, B3.3c), the <see cref="AdditionalDirectoriesMemoryEnvVar"/> that makes the workspace's
/// <c>--add-dir</c> load its memory (<see cref="AppendSettingsPin"/>), and the gateway model-tier pins
/// doesn't pin, B3.3c), the <see cref="AdditionalDirectoriesMemoryEnvVar"/> that makes each <c>--add-dir</c>
/// directory load its memory (<see cref="AppendSettingsPin"/>) when there is one, and the gateway model-tier pins
/// (<see cref="AddGatewayModelTiers"/>). An explicit <see cref="AgentTask.Environment"/> entry WINS (operator intent —
/// layered last), matching the runner's NonInteractiveEnv "operator value wins" convention. When nothing is injected
/// the task env is returned unchanged → byte-identical.
/// </summary>
private static IReadOnlyDictionary<string, string> BuildEnvironment(AgentTask task)
private static IReadOnlyDictionary<string, string> BuildEnvironment(AgentTask task, IReadOnlyList<string> memoryDirectories)
{
var injected = new Dictionary<string, string>(StringComparer.Ordinal);

if (task.Permissions.Egress == AgentEgressPolicy.Allowlist) injected[DisableNonEssentialTrafficEnvVar] = "1";

if (HasWorkspace(task)) injected[AdditionalDirectoriesMemoryEnvVar] = "1";
if (memoryDirectories.Count > 0) injected[AdditionalDirectoriesMemoryEnvVar] = "1";

AddGatewayModelTiers(injected, task);

Expand Down Expand Up @@ -650,34 +655,24 @@ private static void AddGatewayModelTiers(Dictionary<string, string> env, AgentTa
/// terminates the list.</para>
///
/// <para>A multi-repo workspace runs at its root, which holds no <c>CLAUDE.md</c>, so every repository directory
/// inside the workspace is added too (<see cref="MemoryDirectories"/>), and each repository's memory loads.</para>
/// inside the workspace is added too, and each repository's memory loads.</para>
///
/// <para>The CLI opens an added directory's <c>CLAUDE.md</c> and <c>.claude/CLAUDE.md</c> by path and follows a
/// symlink at either, or at <c>.claude</c> itself, wherever it leads, so a directory whose memory resolves outside the
/// workspace is not added at all (<see cref="ClaudeWorkspaceMemory"/>), and when none is left there is no
/// <c>--add-dir</c>. The settings pin stays either way.</para>
/// </summary>
private static void AppendSettingsPin(List<string> args, AgentTask task)
private static void AppendSettingsPin(List<string> args, IReadOnlyList<string> memoryDirectories)
{
args.Add("--setting-sources");
args.Add("user");

if (!HasWorkspace(task)) return;
if (memoryDirectories.Count == 0) return;

args.Add("--add-dir");
args.AddRange(MemoryDirectories(task));
args.AddRange(memoryDirectories);
}

/// <summary>
/// The workspace, then every repository directory inside it. A repository outside it — a sibling of a cwd at the
/// primary repository — is left out: the unpinned CLI never loaded its memory either, and an added directory also
/// widens what the CLI's tools may touch.
/// </summary>
private static IEnumerable<string> MemoryDirectories(AgentTask task)
{
var workspace = task.WorkspaceDirectory!;
var inside = Path.TrimEndingDirectorySeparator(workspace) + Path.DirectorySeparatorChar;

return new[] { workspace }.Concat((task.WorkspaceRepositoryDirectories ?? []).Where(directory => directory.StartsWith(inside, StringComparison.Ordinal))).Distinct(StringComparer.Ordinal);
}

private static bool HasWorkspace(AgentTask task) => !string.IsNullOrWhiteSpace(task.WorkspaceDirectory);

/// <summary>
/// On a deny-by-default (Allowlist) egress run, deliver <c>--settings {"<see cref="SkipWebFetchPreflightSetting"/>":true}</c>
/// so a WebFetch tool call doesn't preflight the hostname against <c>api.anthropic.com</c> — a host the egress allowlist
Expand Down
Loading
Loading