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
19 changes: 11 additions & 8 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 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."
if [ "${executed:-0}" -lt 102 ]; then
echo "::error::Expected >=102 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 + nested memory loaded in place by a real Claude run, single- and multi-repo, and none of it past the in-place budget + 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', 'memory-link-outside 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', 'nested-in-place claude-code single-repo Confined', 'nested-in-place claude-code multi-repo Confined', 'nested-over-budget claude-code single-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_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'):
for method, rows in (('A_claude_run_ignores_the_settings_its_repository_commits_and_still_reads_its_memory', 1), ('A_multi_repo_claude_run_reads_every_repositorys_memory_and_none_of_its_settings', 1), ('A_claude_run_leaves_out_repository_memory_that_links_outside_the_workspace', 1), ('A_claude_run_reads_nested_memory_in_place_and_nothing_it_must_not', 2), ('A_claude_run_over_the_in_place_budget_loads_no_nested_memory_up_front', 1), ('A_codex_run_ignores_the_config_and_hooks_its_repository_commits_and_still_reads_its_agents_md', 1), ('A_multi_repo_codex_run_starts_at_a_workspace_root_that_is_no_repository_and_loads_no_config_from_it', 1), ('A_repo_less_codex_run_starts_in_a_scratch_directory_that_is_no_repository', 1)):
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 6 repository-config E2E arms ran and passed.')
assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass'
print('All 9 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 Expand Up @@ -387,6 +387,9 @@ jobs:
results=backend/TestResults/nonroot
mkdir -p "$home" "$results"
chown 1654:1654 "$home" "$results"
# The repository-config E2E plants its "outside the workspace" files under a system root the sandbox binds
# read-only; uid 1654 cannot create one under /etc, so hand it one directory there.
install -d -o 1654 -g 1654 -m 0755 /etc/cs-sandbox-outside
# The restore above ran as root under the job's HOME (/github/home in a container job, not /root): ask NuGet
# where it put the packages, and let uid 1654 traverse every directory down to them and read them.
packages=$(dotnet nuget locals global-packages --list | sed -E 's/^global-packages: *//; s:/+$::')
Expand All @@ -411,9 +414,9 @@ jobs:
root = ET.parse(path).getroot()
counters = root.find('.//{*}Counters')
executed, passed = int(counters.get('executed')), int(counters.get('passed'))
assert executed >= 17 and passed == executed, f'expected all 17 non-root arms to run and pass, got executed={executed} passed={passed}'
assert executed >= 18 and passed == executed, f'expected all 18 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', '[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'):
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', '[repo-config-e2e] ran non-root nested-in-place 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'):
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 @@ -90,10 +90,11 @@ public sealed class ClaudeCodeHarness : IAgentHarness, IAgentHarnessBinary, IAge
/// <summary>
/// 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).
/// files any of those or of its scoped rules @-import. A rule scoped by <c>paths:</c> never loads from an added
/// directory itself, 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>
public const string AdditionalDirectoriesMemoryEnvVar = "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD";

Expand Down Expand Up @@ -202,7 +203,8 @@ 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).
// The directories added back for their memory — the workspace, its repositories and the nested directories that fit
// in place — 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.
Expand Down Expand Up @@ -271,7 +273,7 @@ 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.
// Memory left out — linked outside the workspace, or nested past the in-place budget — the run's timeline says so.
LaunchNotices = memory.Notices,
};
}
Expand Down Expand Up @@ -647,12 +649,15 @@ private static void AddGatewayModelTiers(Dictionary<string, string> env, AgentTa
/// <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>
/// style replaces the CLI's own instructions in the system prompt. A rule scoped by <c>paths:</c>, which the unpinned CLI
/// attached once the run opened a file it covers, is lost as well. A subdirectory's own memory, which it attached the
/// same way, comes back in place instead: an <c>--add-dir</c> naming the subdirectory loads its <c>CLAUDE.md</c>, its
/// <c>.claude/CLAUDE.md</c>, its rules without <c>paths:</c> and what its rules import before the first request, and
/// reads no settings from it either. Every nested directory that holds such memory is added after the workspace and
/// its repositories, shallowest first, when all of it together, imports included, fits the in-place budget; past it
/// none is (<see cref="ClaudeWorkspaceMemory"/>).
/// 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, and each repository's memory loads.</para>
Expand Down
Loading
Loading