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
4 changes: 2 additions & 2 deletions .github/workflows/sandbox-isolation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,9 @@ jobs:
root = ET.parse(path).getroot()
counters = root.find('.//{*}Counters')
executed, passed = int(counters.get('executed')), int(counters.get('passed'))
assert executed >= 16 and passed == executed, f'expected all 16 non-root arms to run and pass, got executed={executed} passed={passed}'
assert executed >= 17 and passed == executed, f'expected all 17 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', '[repo-config-e2e] ran non-root claude-code single-repo Standard uid=1654', '[goal-channel-e2e] ran non-root mentions claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root resume claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /fix claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /security-review claude-code Standard uid=1654 confined=True', '[publish-isolation-e2e] ran non-root claude-code uid=1654 confined=True'):
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', '[repo-config-e2e] ran non-root claude-code own-stop-hook sealed-egress Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root mentions claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root resume claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /fix claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /security-review claude-code Standard uid=1654 confined=True', '[publish-isolation-e2e] ran non-root claude-code uid=1654 confined=True'):
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 @@ -88,8 +88,10 @@ public sealed class ClaudeCodeHarness : IAgentHarness, IAgentHarnessBinary, IAge
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
/// Claude Code's switch that loads every <c>--add-dir</c> directory's memory: its <c>CLAUDE.md</c>, its
/// <c>.claude/CLAUDE.md</c>, each <c>.claude/rules</c> file WITHOUT a <c>paths:</c> frontmatter, and the in-repository
/// files those @-import. A rule scoped by <c>paths:</c> never loads from an added directory, not even once the run
/// opens a file it covers. This is 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>
Expand Down Expand Up @@ -631,11 +633,21 @@ private static void AddGatewayModelTiers(Dictionary<string, string> env, AgentTa
/// 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>
/// workspace comes back as an <c>--add-dir</c> with <see cref="AdditionalDirectoriesMemoryEnvVar"/> set: whatever the
/// setting sources, the CLI then reads an added directory's <c>CLAUDE.md</c>, its <c>.claude/CLAUDE.md</c>, its
/// <c>.claude/rules</c> files without a <c>paths:</c> frontmatter and the in-repository files those @-import, all
/// before the first request, and reads no settings from it.</para>
///
/// <para>Everything else the unpinned 2.1.263 took from the project stays unloaded. Its skills, commands and agents,
/// <c>CLAUDE.local.md</c> and the output style its settings select stay out for good: a skill's or command's body
/// runs shell once invoked and a skill's frontmatter runs hooks, an agent's frontmatter can set its own permission
/// mode, hooks and MCP servers, <c>CLAUDE.local.md</c> is a developer's untracked file by convention, and an output
/// style replaces the CLI's own instructions in the system prompt. A rule scoped by <c>paths:</c> and a subdirectory's own
/// <c>CLAUDE.md</c>, which the unpinned CLI attached once the run opened a file they cover, are lost as well. The
/// subdirectory's memory is not unreachable: an <c>--add-dir</c> naming that subdirectory loads it in place, before
/// the first request. This pin adds only the workspace and its repositories. RepositoryConfigE2ETests pins what
/// loads and what does not against the real binary. <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>
Expand Down Expand Up @@ -670,9 +682,10 @@ private static IEnumerable<string> MemoryDirectories(AgentTask task)
/// 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
/// (model + git only) doesn't pin, which would stall the run (it's NOT covered by <see cref="DisableNonEssentialTrafficEnvVar"/>,
/// the only other escape our env closes). Safe to add unconditionally: the runner writes NO <c>settings.json</c> into the
/// per-run config dir (only <c>.mcp.json</c>, loaded independently), so <c>--settings</c> cannot clobber any run settings.
/// A Full-egress run is unchanged.
/// the only other escape our env closes). Safe to add unconditionally: the one <c>settings.json</c> the runner writes into
/// the per-run config dir is the in-loop Stop hook's (<see cref="BuildConfigHomeFiles"/>, on an acceptance-bearing run),
/// and the CLI layers <c>--settings</c> over that file rather than reading it instead — with both, the Stop hook still
/// runs (observed against 2.1.263 under <c>--setting-sources user</c>). A Full-egress run is unchanged.
/// </summary>
private static void AppendSealedEgressSettings(List<string> args, AgentTask task)
{
Expand Down
14 changes: 13 additions & 1 deletion backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ namespace CodeSpace.SandboxTests;
/// but for two arms: this worker cannot filter, so its allowlist run is severed where the root lane's is filtered, and
/// it still reaches its broker; and its repository-config arm runs Claude at Standard, the tier the root lane's uid 0
/// cannot give it. The publish-isolation Claude arm runs here alone for the same reason: a Confined Claude could not
/// write the <c>.git</c> it plants into, so the root lane runs only that class's Codex arm.
/// write the <c>.git</c> it plants into, so the root lane runs only that class's Codex arm. So does the Claude arm whose
/// own Stop hook must run beside an Allowlist run's sealed-egress settings: a Confined run gets no in-loop check.
///
/// <para>Selected by its trait alone (<c>--filter Category=SandboxNonRoot</c>), never by the root lane's
/// <c>Category=Sandbox</c>. Every arm that ran prints its class's marker with <c>non-root</c> and its uid, which the
Expand Down Expand Up @@ -122,6 +123,17 @@ public async Task A_standard_claude_run_ignores_the_settings_its_repository_comm
await arms.ClaudeIgnoresRepositorySettingsAsync(AgentAutonomyLevel.Standard, repositories: 1, Lane);
}

[Fact]
public async Task A_standard_allowlist_claude_run_still_runs_its_own_stop_hook_beside_the_sealed_egress_settings()
{
// An acceptance-bearing Allowlist run: its in-loop check rides the config home's settings.json, its egress
// settings ride --settings, and Standard — which uid 0 cannot get — lets the check leave its marker.
if (!NonRootWorker.Require()) return;

using var arms = new RepositoryConfigE2ETests(output);
await arms.ClaudeRunsItsOwnStopHookUnderTheSealedEgressSettingsAsync(Lane);
}

[Fact]
public async Task A_standard_claude_goal_naming_secrets_reaches_the_model_verbatim_and_reads_none_of_them()
{
Expand Down
Loading
Loading