Skip to content
Open
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
23 changes: 12 additions & 11 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 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."
if [ "${executed:-0}" -lt 92 ]; then
echo "::error::Expected >=92 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), 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'):
for arm in ('claude-code single-repo Confined', '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'):
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'):
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.')
print('All 5 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'):
Expand Down Expand Up @@ -398,10 +398,11 @@ jobs:
# docker-compose.yml ships the worker image with none of the grants bubblewrap needs: uid 1654 with ip and nft
# installed, nothing confining, RequireConfinement off. A confining worker severs an allowlist it cannot filter;
# here nothing would enforce that, so Category=SandboxUnconfined pins that such a run is still planned into its
# namespace and aborted at the setup, never launched on the worker's network. It runs as that uid with
# CODESPACE_BWRAP_PATH naming no binary and RequireConfinement unset — the one lane that must not confine —
# and each arm first asserts exactly that posture. Reuses the home, packages and build output the non-root step
# made readable to that uid.
# namespace and aborted at the setup, never launched on the worker's network. Unconfined, Codex's own sandbox is
# the only boundary its commands meet, so the lane also pins that a multi-repo Codex run's agent can write no
# repository's .git or .codex. It runs as that uid with CODESPACE_BWRAP_PATH naming no binary and
# RequireConfinement unset — the one lane that must not confine — and each arm first asserts exactly that
# posture. Reuses the home, packages and build output the non-root step made readable to that uid.
shell: bash
run: |
set -euo pipefail
Expand All @@ -427,9 +428,9 @@ jobs:
root = ET.parse(path).getroot()
counters = root.find('.//{*}Counters')
executed, passed = int(counters.get('executed')), int(counters.get('passed'))
assert executed >= 3 and passed == executed, f'expected all 3 unconfined arms to run and pass, got executed={executed} passed={passed}'
assert executed >= 4 and passed == executed, f'expected all 4 unconfined arms to run and pass, got executed={executed} passed={passed}'
text = open(path, encoding='utf-8').read()
for marker in ('[unconfined-e2e] ran allowlist-never-unfiltered durable uid=1654', '[unconfined-e2e] ran allowlist-never-unfiltered one-shot uid=1654', '[unconfined-e2e] ran admission uid=1654'):
for marker in ('[unconfined-e2e] ran allowlist-never-unfiltered durable uid=1654', '[unconfined-e2e] ran allowlist-never-unfiltered one-shot uid=1654', '[unconfined-e2e] ran admission uid=1654', '[repo-config-e2e] ran unconfined codex-cli multi-repo metadata-read-only uid=1654'):
assert marker in text, f'unconfined arm marker "{marker}" is missing — the arm returned early or did not run as the worker uid'
print(f'All {executed} unconfined arms ran as uid 1654 and passed.')
PY
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ public sealed class CodexHarness : IAgentHarness, IAgentHarnessBinary, IAgentHar
/// </summary>
public const int MaxInputCharacters = 1_048_576;

/// <summary>
/// Lets Codex start in a directory that is not inside a git repository. Two kinds of run have such a cwd: a
/// multi-repo run whose cwd mode is <c>Auto</c> or <c>WorkspaceRoot</c>, which works at the workspace root holding
/// each repository in a folder of its own, and a repo-less run, which works in a scratch directory with no git
/// anywhere above it. (A single-repo workspace is cloned into its root, so its cwd is a repository in every mode.)
/// Without this flag the pinned 0.142.2 refuses both before any model request: exit 1, "Not inside a trusted
/// directory and --skip-git-repo-check was not specified". It does so for <c>exec</c> and <c>exec resume &lt;id&gt;</c>
/// alike, and no <c>projects</c> trust entry lifts the refusal. Every run passes it, because CodeSpace decides which
/// directory a run works in, not Codex's git heuristic. Inside a repository it changes nothing the model is sent: the
/// repository's <c>AGENTS.md</c> and skills still load, and <see cref="AppendWorkspaceDistrust"/> still keeps its
/// project config out. Verified against 0.142.2, which accepts the flag after <c>resume &lt;id&gt;</c> too.
/// </summary>
private const string SkipGitRepoCheck = "--skip-git-repo-check";

public SandboxSpec BuildInvocation(AgentTask task)
{
EnsureWithinInputCap(task.Goal);
Expand All @@ -153,8 +167,8 @@ public SandboxSpec BuildInvocation(AgentTask task)
// prior thread. The subcommand must follow `exec` directly; --model, the `-c` overrides (incl. the sandbox on
// the resume path — see AppendSandbox), and the stdin `-` positional follow. Null (a fresh run) → the plain seed.
var args = task.ResumeFromSessionId is { Length: > 0 } resumeThreadId
? new List<string> { "exec", "resume", resumeThreadId, "--json" }
: new List<string> { "exec", "--json" };
? new List<string> { "exec", "resume", resumeThreadId, "--json", SkipGitRepoCheck }
: new List<string> { "exec", "--json", SkipGitRepoCheck };

// task.Tools is intentionally NOT projected here: Codex has no global tool allow-list (it restricts via
// --sandbox + per-MCP-server enabled_tools), so a Claude-Code-style tool list has no faithful Codex flag.
Expand All @@ -170,6 +184,7 @@ public SandboxSpec BuildInvocation(AgentTask task)
}

AppendSandbox(args, task);
AppendRepositoryWritableRoots(args, task);

// Point Codex at a custom gateway (when one was projected) BEFORE the `-` positional — Codex parses `-c`
// overrides as flags, so they must precede it.
Expand Down Expand Up @@ -646,6 +661,32 @@ private static string SandboxMode(AgentPermissions permissions) =>
private static string[] SandboxFragment(AgentTask task, string mode) =>
task.ResumeFromSessionId is { Length: > 0 } ? new[] { "-c", $"sandbox_mode={mode}" } : new[] { "--sandbox", mode };

/// <summary>
/// Name every repository below the cwd to Codex's workspace-write sandbox as a writable root of its own. That sandbox
/// keeps <c>.git</c>, <c>.codex</c> and <c>.agents</c> read-only only at the top of each writable root, so at a
/// multi-repo root, whose repositories sit below the cwd rather than at the top of a root, each repository's
/// <c>.git/hooks</c> and <c>.git/config</c> were writable to the agent. The platform's own commit and push then run
/// git in each repository with the run's credential. Named as roots, the pinned 0.142.2 refuses those writes, on
/// <c>exec</c> and <c>exec resume</c> alike, and the agent can still change each repository's files (observed under
/// macOS's sandbox; the unconfined sandbox lane runs the same check on Linux). No write access is added, since each
/// directory is already inside the cwd; a repository outside it is left out, because naming it would widen the
/// sandbox. A single-repo run's repository is its cwd, so its argv is unchanged. A read-only run has no writable root
/// to add to. Under our confinement Codex's sandbox is stood down (<see cref="SandboxStandDown"/>) and ignores this
/// table.
/// </summary>
private static void AppendRepositoryWritableRoots(List<string> args, AgentTask task)
{
if (task.Permissions.WriteScope == AgentWriteScope.ReadOnly || string.IsNullOrWhiteSpace(task.WorkspaceDirectory)) return;

var inside = Path.TrimEndingDirectorySeparator(task.WorkspaceDirectory) + Path.DirectorySeparatorChar;
var repositories = (task.WorkspaceRepositoryDirectories ?? []).Where(directory => directory.StartsWith(inside, StringComparison.Ordinal)).Distinct(StringComparer.Ordinal).ToList();

if (repositories.Count == 0) return;

args.Add("-c");
args.Add($"sandbox_workspace_write.writable_roots=[{string.Join(',', repositories.Select(McpDeclarationWriter.TomlString))}]");
}

/// <summary>What the runner swaps the sandbox fragment for where it confines the run: the same spelling, carrying <see cref="ConfinedSandboxMode"/>.</summary>
private static ArgsSubstitution SandboxStandDown(AgentTask task) =>
new() { Replace = SandboxFragment(task, SandboxMode(task.Permissions)), With = SandboxFragment(task, ConfinedSandboxMode) };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ private async Task<Guid> SeedAgentCredentialAsync(Guid teamId, string baseUrl, s
return credId;
}

/// <summary>A fresh git-initialised temp workspace — <c>codex exec</c> refuses to run outside a trusted git repo, and the executor provisions NO workspace for a no-repo task, so the test supplies one (mirrors <see cref="RealCodexResumeE2ETests"/>). Tracked for teardown.</summary>
/// <summary>A fresh git-initialised temp workspace the test supplies (mirrors <see cref="RealCodexResumeE2ETests"/>). Tracked for teardown. The repository is this test's choice, not the CLI's: every Codex run passes <c>--skip-git-repo-check</c>, so a repo-less run's scratch directory starts too.</summary>
private string NewGitWorkspace()
{
var ws = Path.Combine(Path.GetTempPath(), "cs-codex-inject-" + Guid.NewGuid().ToString("N"), "ws");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ private async Task<Guid> SeedAgentCredentialAsync(Guid teamId, string baseUrl, s
return credId;
}

/// <summary>A fresh git-initialised temp workspace — <c>codex exec</c> refuses to run outside a trusted git repo (mirrors <see cref="RealModelCodexInjectionE2ETests"/>'s own helper). Tracked for teardown.</summary>
/// <summary>A fresh git-initialised temp workspace the test supplies (mirrors <see cref="RealModelCodexInjectionE2ETests"/>'s own helper). Tracked for teardown. The repository is this test's choice, not the CLI's: every Codex run passes <c>--skip-git-repo-check</c>.</summary>
private string NewGitWorkspace()
{
var ws = Path.Combine(Path.GetTempPath(), "cs-codex-stophook-" + Guid.NewGuid().ToString("N"), "ws");
Expand Down
Loading
Loading