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
16 changes: 12 additions & 4 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 87 ]; then
echo "::error::Expected >=87 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), 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 90 ]; then
echo "::error::Expected >=90 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), 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 @@ -267,6 +267,14 @@ jobs:
assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass'
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'):
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'):
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 3 repository-config E2E arms ran and passed.')

# The sealed-egress E2E returns early on a host that cannot confine, which reads as Passed; require each arm's marker.
for arm in ('durable', 'non-durable', 'relay-ipv6', 'restart', 'relay-refused', 'relay-policy-route'):
assert f'[sealed-egress-e2e] ran {arm}' in text, f'sealed-egress E2E arm "{arm}" did not run — this lane is root with bwrap and the relay helper, so it must relay'
Expand Down Expand Up @@ -379,9 +387,9 @@ jobs:
root = ET.parse(path).getroot()
counters = root.find('.//{*}Counters')
executed, passed = int(counters.get('executed')), int(counters.get('passed'))
assert executed >= 10 and passed == executed, f'expected all 10 non-root arms to run and pass, got executed={executed} passed={passed}'
assert executed >= 11 and passed == executed, f'expected all 11 non-root arms to run and pass, got executed={executed} passed={passed}'
text = open(path, encoding='utf-8').read()
for marker in ('[non-root-e2e] ran admission uid=1654', '[sealed-egress-e2e] ran non-root durable uid=1654', '[sealed-egress-e2e] ran non-root non-durable uid=1654', '[sealed-egress-e2e] ran non-root restart uid=1654', '[sealed-egress-e2e] ran non-root relay-refused uid=1654', '[sealed-egress-e2e] ran non-root relay-ipv6', '[broker-socket-e2e] ran non-root socket-channel uid=1654', '[review-diff-e2e] ran non-root network-off-relayed claude-code uid=1654', '[review-diff-e2e] ran non-root network-off-relayed codex-cli uid=1654', '[durable-egress-e2e] ran non-root allowlist-severed uid=1654'):
for marker in ('[non-root-e2e] ran admission uid=1654', '[sealed-egress-e2e] ran non-root durable uid=1654', '[sealed-egress-e2e] ran non-root non-durable uid=1654', '[sealed-egress-e2e] ran non-root restart uid=1654', '[sealed-egress-e2e] ran non-root relay-refused uid=1654', '[sealed-egress-e2e] ran non-root relay-ipv6', '[broker-socket-e2e] ran non-root socket-channel uid=1654', '[review-diff-e2e] ran non-root network-off-relayed claude-code uid=1654', '[review-diff-e2e] ran non-root network-off-relayed codex-cli uid=1654', '[durable-egress-e2e] ran non-root allowlist-severed uid=1654', '[repo-config-e2e] ran non-root claude-code single-repo Standard uid=1654'):
assert marker in text, f'non-root arm marker "{marker}" is missing — the arm returned early or did not run as the worker uid'
print(f'All {executed} non-root arms ran as uid 1654 and passed.')
PY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@
if (string.IsNullOrWhiteSpace(task.Model) && !string.IsNullOrWhiteSpace(effectiveModel))
await PersistResolvedModelAsync(owner, task with { Model = effectiveModel }, cancellationToken).ConfigureAwait(false);

var effectiveTask = (workspace is null ? task : task with { WorkspaceDirectory = workspace.Directory }) with { Environment = MergeEnvironment(task.Environment, secretEnv), Model = effectiveModel };
var effectiveTask = InWorkspace(task, workspace) with { Environment = MergeEnvironment(task.Environment, secretEnv), Model = effectiveModel };

// D3: an escalation the DISPATCHER already decided this attempt owes — the agent.run node's respawn after
// an attempt whose own evidence said the MODEL was the limit. It arrives as a request (why + the prior
Expand Down Expand Up @@ -4113,6 +4113,10 @@
private static IReadOnlyList<string> CloneUrlsOf(WorkspaceProvisionRequest? workspace) =>
workspace is null ? Array.Empty<string>() : workspace.Repositories.Select(r => r.CloneRequest.RepositoryUrl).ToList();

/// <summary>The task as it runs in its materialised workspace: the workspace's cwd, and the directory of every repository in it, which a harness that names directories to its CLI needs beside the cwd. Unchanged when no workspace was materialised. In-memory only, like the rest of the effective task.</summary>
private static AgentTask InWorkspace(AgentTask task, IWorkspaceHandle? workspace) =>
workspace is null ? task : task with { WorkspaceDirectory = workspace.Directory, WorkspaceRepositoryDirectories = workspace.Repositories.Select(repository => repository.Directory).ToList() };

/// <summary>Layer the resolved credential's env onto the task's own non-secret env — the injected value wins for a shared key. In-memory only; the result is never re-persisted (an empty secret env returns the task env unchanged).</summary>
internal static IReadOnlyDictionary<string, string> MergeEnvironment(IReadOnlyDictionary<string, string> taskEnv, IReadOnlyDictionary<string, string> secretEnv)
{
Expand Down Expand Up @@ -5002,10 +5006,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 5009 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 5009 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 5009 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 5009 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 5009 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 5011 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 5011 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 5011 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 5011 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 5011 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 5012 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 5012 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 5012 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 5012 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 5012 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 @@ -85,6 +85,14 @@ public sealed class ClaudeCodeHarness : IAgentHarness, IAgentHarnessBinary, IAge
/// </summary>
public const string DisableNonEssentialTrafficEnvVar = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC";

/// <summary>
/// Claude Code's switch that loads <c>CLAUDE.md</c>, <c>.claude/CLAUDE.md</c> and <c>.claude/rules</c> from every
/// <c>--add-dir</c> directory — the one project-memory route the pinned CLI's loader does not gate on the
/// <c>project</c> setting source, which is how a run pinned to <c>--setting-sources user</c> keeps the repository's
/// memory (see <see cref="AppendSettingsPin"/>). Pinned by a test (Rule 8).
/// </summary>
public const string AdditionalDirectoriesMemoryEnvVar = "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD";

/// <summary>Claude Code's "small/fast" background model — used for title/summary generation, <c>/compact</c>, lightweight steps. Defaults to a haiku model NAME. Pinned by a test (Rule 8).</summary>
public const string SmallFastModelEnvVar = "ANTHROPIC_SMALL_FAST_MODEL";

Expand Down Expand Up @@ -183,28 +191,13 @@ public SandboxSpec BuildInvocation(AgentTask task)
args.Add("--append-system-prompt");
args.Add(AgentOperatingContract.Compose(task.SystemPrompt));

// B1 config isolation (CORRECTED — the earlier "print is hermetic, skills need --setting-sources" rationale was
// wrong: that hermetic default is the programmatic Agent SDK's, NOT the `claude` CLI's). The CLI `claude -p`
// AUTO-DISCOVERS + loads personal skills from CLAUDE_CONFIG_DIR/skills/<slug>/SKILL.md by DEFAULT — official docs:
// headless.md "Without --bare, `claude -p` loads the same context an interactive session would, including
// anything configured in ... ~/.claude"; cli-reference `--bare` = "skip auto-discovery of ... skills". So our
// projected skills load with NO extra flag; the REAL requirement is that we NEVER pass --bare / --safe-mode
// (guarded by a unit test). `--setting-sources` (user|project|local) instead controls which settings.json LAYERS
// load (sdk-headless "To restrict which sources load, set settingSources"), NOT skill discovery. We pass
// `--setting-sources user` when we project skills to PIN settings to the isolated per-run user config home (§344)
// — so the run inherits ONLY our config, never the TARGET REPO's `.claude` project/local settings (an untrusted
// -input vector). Byte-identical argv for a skill-less run. The real-model E2E is the live arbiter of application.
// P3.3: the SAME pin is required when we project an in-loop acceptance Stop hook (BuildConfigHomeFiles writes a
// settings.json) — without it, Claude ALSO loads the target repo's own project/local .claude settings, which
// could carry an UNTRUSTED Stop hook of the repo's own. Widening this condition (not adding a second flag) keeps
// the pin's rule uniform: whenever WE write settings into the isolated config home, that's the ONLY layer that loads.
// Keyed on the oracle's SHAPE, not on whether the hook is wired: a read-only run carries no hook (its workspace
// is mounted read-only, see InLoopAcceptanceHook.AppliesTo) but must not start loading the repo's settings for it.
if (task.Skills is { Count: > 0 } || InLoopAcceptanceHook.HasRunnableOracle(task))
{
args.Add("--setting-sources");
args.Add("user");
}
// B1 config isolation: `claude -p` AUTO-DISCOVERS + loads personal skills from CLAUDE_CONFIG_DIR/skills/<slug>/SKILL.md
// by DEFAULT (headless.md "Without --bare, `claude -p` loads the same context an interactive session would";
// cli-reference `--bare` = "skip auto-discovery of ... skills"), so our projected skills load with NO extra flag
// 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);

AppendSealedEgressSettings(args, task);

Expand Down Expand Up @@ -510,16 +503,20 @@ 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), 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.
/// 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
/// (<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)
{
var injected = new Dictionary<string, string>(StringComparer.Ordinal);

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

if (HasWorkspace(task)) injected[AdditionalDirectoriesMemoryEnvVar] = "1";

AddGatewayModelTiers(injected, task);

if (injected.Count == 0) return task.Environment;
Expand Down Expand Up @@ -547,6 +544,50 @@ private static void AddGatewayModelTiers(Dictionary<string, string> env, AgentTa
foreach (var key in BackgroundModelEnvVars) env[key] = task.Model;
}

/// <summary>
/// Pin every run's settings to its own isolated config home. <c>--setting-sources user</c> loads
/// <c>CLAUDE_CONFIG_DIR/settings.json</c> and nothing else, so the target repository's <c>.claude/settings.json</c>
/// and <c>.claude/settings.local.json</c> never apply. Unpinned, the pinned 2.1.263 CLI obeyed them completely: a
/// planted <c>env.ANTHROPIC_BASE_URL</c> took the model call off the run's broker to the repository's endpoint, with
/// the repository's <c>env.ANTHROPIC_AUTH_TOKEN</c> and its <c>apiKeyHelper</c>'s key; every planted hook ran; and a
/// project <c>.mcp.json</c> server was spawned whenever no declaration of ours made the MCP config strict.
///
/// <para>The same source also gates project memory, so the pin alone drops the repository's <c>CLAUDE.md</c>. The
/// workspace comes back as an <c>--add-dir</c> with <see cref="AdditionalDirectoriesMemoryEnvVar"/> set: the loader
/// reads <c>CLAUDE.md</c>, <c>.claude/CLAUDE.md</c> and <c>.claude/rules</c> from an added directory whatever the
/// setting sources, and reads no settings from it. Project commands, agents and skills, and a subdirectory's own
/// <c>CLAUDE.md</c>, have no such route in 2.1.263 and stay unloaded. <c>--add-dir</c> is variadic; every flag that
/// follows it 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>
/// </summary>
private static void AppendSettingsPin(List<string> args, AgentTask task)
{
args.Add("--setting-sources");
args.Add("user");

if (!HasWorkspace(task)) return;

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

/// <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