From 1cf61a13f9d24bd58485d97574c57477635f3aed Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Sun, 4 Oct 2026 16:50:44 +0800 Subject: [PATCH] Let multi-repo and repo-less Codex runs start Two kinds of run give Codex a cwd that is no git repository. A multi-repo run works at the workspace root, which holds each repository in a folder of its own beside a WORKSPACE.md. A repo-less run works in the scratch directory the executor makes under the temp path. Codex 0.142.2 refuses such a cwd unless it is given --skip-git-repo-check, for exec and for exec resume . It exits 1 before any model request with "Not inside a trusted directory and --skip-git-repo-check was not specified", and a projects trust entry does not lift the refusal. CodeSpace never passed the flag, so every multi-repo and every repo-less Codex run failed at start. The repository-config E2E ran Codex in a single repository only, and the real-model Codex E2Es git-init a workspace of their own, so nothing caught it. Every Codex invocation now carries the flag right after --json, in both the exec seed and the exec resume seed. CodeSpace decides which directory a run works in; Codex's git heuristic should not veto it. Against the real CLI at a git cwd the flag changes nothing observable. The repository's AGENTS.md and both skill roots still load, and the untrusted marking still keeps its .codex/config.toml and hooks out. Starting at a multi-repo root exposed a gap in Codex's own workspace-write sandbox, the only boundary its commands meet where our runner does not confine. That sandbox keeps .git, .codex and .agents read-only only at the top of each writable root, so each repository's .git/hooks and .git/config were writable to the agent, and the platform's own commit and push run git there with the run's credential. Each repository below the cwd is now also named as a writable root of its own (sandbox_workspace_write.writable_roots). That adds no write access, since each is already inside the cwd. The real CLI then refuses those writes, on exec and on resume, while the agent can still change the repositories' files (observed under macOS's sandbox). A single-repo run's argv is unchanged. The real-CLI E2E now covers each change, and each new check goes red under the mutation it guards: - the multi-repo arm also plants hostile config at the workspace root, the one place Codex reads project config from in that layout, so it fails when the distrust is dropped. It is renamed to say it loads no config from that root; - a repo-less arm starts Codex in a scratch directory, and fails without the flag; - the resume stand-down arm runs at a non-git multi-repo root, so it fails without the resume seed's flag and shows the pinned CLI accepts the writable roots on resume; - a new unconfined-lane arm has the agent append to each repository's .git/hooks/pre-push, .git/config and .codex/config.toml, and fails without the writable roots. The sandbox lane's root floor moves to 92 and the unconfined lane's to 4, each requiring the new arm's marker. --- .github/workflows/sandbox-isolation.yml | 23 +- .../Agents/Harnesses/Codex/CodexHarness.cs | 45 +++- .../RealModelCodexInjectionE2ETests.cs | 2 +- .../RealModelCodexStopHookE2ETests.cs | 2 +- .../RepositoryConfigE2ETests.cs | 219 ++++++++++++++++-- .../ReviewerReadsItsDiffE2ETests.cs | 27 ++- .../UnconfinedWorkerE2ETests.cs | 16 +- .../Workflows/CodexHarnessTests.cs | 65 +++++- 8 files changed, 354 insertions(+), 45 deletions(-) diff --git a/.github/workflows/sandbox-isolation.yml b/.github/workflows/sandbox-isolation.yml index 5239cfb9d..d55a5efad 100644 --- a/.github/workflows/sandbox-isolation.yml +++ b/.github/workflows/sandbox-isolation.yml @@ -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 @@ -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'): @@ -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 @@ -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 diff --git a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs index 77e9fa14c..91a71f8cc 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Harnesses/Codex/CodexHarness.cs @@ -145,6 +145,20 @@ public sealed class CodexHarness : IAgentHarness, IAgentHarnessBinary, IAgentHar /// public const int MaxInputCharacters = 1_048_576; + /// + /// 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 Auto or WorkspaceRoot, 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 exec and exec resume <id> + /// alike, and no projects 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 AGENTS.md and skills still load, and still keeps its + /// project config out. Verified against 0.142.2, which accepts the flag after resume <id> too. + /// + private const string SkipGitRepoCheck = "--skip-git-repo-check"; + public SandboxSpec BuildInvocation(AgentTask task) { EnsureWithinInputCap(task.Goal); @@ -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 { "exec", "resume", resumeThreadId, "--json" } - : new List { "exec", "--json" }; + ? new List { "exec", "resume", resumeThreadId, "--json", SkipGitRepoCheck } + : new List { "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. @@ -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. @@ -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 }; + /// + /// Name every repository below the cwd to Codex's workspace-write sandbox as a writable root of its own. That sandbox + /// keeps .git, .codex and .agents 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 + /// .git/hooks and .git/config 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 + /// exec and exec resume 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 () and ignores this + /// table. + /// + private static void AppendRepositoryWritableRoots(List 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))}]"); + } + /// What the runner swaps the sandbox fragment for where it confines the run: the same spelling, carrying . private static ArgsSubstitution SandboxStandDown(AgentTask task) => new() { Replace = SandboxFragment(task, SandboxMode(task.Permissions)), With = SandboxFragment(task, ConfinedSandboxMode) }; diff --git a/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexInjectionE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexInjectionE2ETests.cs index e9526e7ef..0a552d152 100644 --- a/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexInjectionE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexInjectionE2ETests.cs @@ -216,7 +216,7 @@ private async Task SeedAgentCredentialAsync(Guid teamId, string baseUrl, s return credId; } - /// A fresh git-initialised temp workspace — codex exec 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 ). Tracked for teardown. + /// A fresh git-initialised temp workspace the test supplies (mirrors ). Tracked for teardown. The repository is this test's choice, not the CLI's: every Codex run passes --skip-git-repo-check, so a repo-less run's scratch directory starts too. private string NewGitWorkspace() { var ws = Path.Combine(Path.GetTempPath(), "cs-codex-inject-" + Guid.NewGuid().ToString("N"), "ws"); diff --git a/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexStopHookE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexStopHookE2ETests.cs index 884943967..e7a3110e8 100644 --- a/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexStopHookE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Workflows/RealModelCodexStopHookE2ETests.cs @@ -145,7 +145,7 @@ private async Task SeedAgentCredentialAsync(Guid teamId, string baseUrl, s return credId; } - /// A fresh git-initialised temp workspace — codex exec refuses to run outside a trusted git repo (mirrors 's own helper). Tracked for teardown. + /// A fresh git-initialised temp workspace the test supplies (mirrors 's own helper). Tracked for teardown. The repository is this test's choice, not the CLI's: every Codex run passes --skip-git-repo-check. private string NewGitWorkspace() { var ws = Path.Combine(Path.GetTempPath(), "cs-codex-stophook-" + Guid.NewGuid().ToString("N"), "ws"); diff --git a/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs index 730d419b7..5b51a30a9 100644 --- a/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/RepositoryConfigE2ETests.cs @@ -8,6 +8,7 @@ using CodeSpace.Core.Services.Agents.Harnesses.Codex; using CodeSpace.Core.Services.Agents.Sandbox.Isolation; using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Core.Services.Agents.Workspace; using CodeSpace.Messages.Agents; using CodeSpace.Messages.Enums; using Shouldly; @@ -21,7 +22,12 @@ namespace CodeSpace.SandboxTests; /// Codex's .codex/config.toml and .codex/hooks.json — and the target repository is untrusted input. A file /// committed there must not take the run's model call off its broker, and must not run a command the model never /// chose. The repository's own instructions (CLAUDE.md, AGENTS.md) are context, not config, and must -/// still reach the model — from every repository of a multi-repo workspace. +/// still reach the model. For Claude that holds for every repository of a multi-repo workspace. The multi-repo Codex +/// arm asserts less: the run starts at the workspace root, reaches its broker and loads no config from that root. It +/// does not assert that the AGENTS.md of a repository below that root reaches the model. A repo-less Codex run +/// must likewise start in its scratch directory, and where nothing of ours confines a multi-repo run, Codex's own +/// sandbox must keep every repository's .git and .codex read-only +/// (, run by the unconfined lane). /// /// Fidelity: 🟢 HIGH for everything but the model. The pinned CLI binaries, the production harness argv /// (), the production (bubblewrap where the @@ -29,10 +35,11 @@ namespace CodeSpace.SandboxTests; /// broker () and the endpoint the repository names, a loopback listener that only /// counts connections. /// -/// Each workspace is laid out as the executor hands it over: a single repository is the workspace itself, and a -/// multi-repo workspace is a root holding each repository in a directory of its own, both named to the harness the way -/// the executor names them. Every path is the temp path as Path.GetTempPath() spells it and never resolved, as -/// production never resolves it: on macOS that path runs through the /var symlink. +/// Each workspace is laid out as the executor hands it over: a repo-less run's is the scratch directory the +/// executor makes, a single repository is the workspace itself, and a multi-repo workspace is a root holding each +/// repository in a directory of its own, each named to the harness the way the executor names them. Every path is the +/// temp path as Path.GetTempPath() spells it and never resolved, as production never resolves it: on macOS that +/// path runs through the /var symlink. /// /// What it found on the pinned CLIs before the fix: Claude 2.1.263, launched without --setting-sources user, /// sent its model call to the repository's env.ANTHROPIC_BASE_URL with the repository's token, ran the @@ -41,15 +48,20 @@ namespace CodeSpace.SandboxTests; /// repository's [mcp_servers] on every run and ran its hooks on every acceptance-bearing run, whose /// --dangerously-bypass-hook-trust waives review for every enabled hook; its model routing was never exposed, /// because Codex ignores model_provider in project-local config. Codex keys that trust entry on the physical -/// directory it resolves as its cwd, so an entry keyed only by a workspace path under a symlink matched nothing. +/// directory it resolves as its cwd, so an entry keyed only by a workspace path under a symlink matched nothing. Codex +/// also refused to start at a multi-repo workspace's root, or in a repo-less run's scratch directory, neither of which +/// is a git repository: without --skip-git-repo-check it exits 1 before any model request. A single-repo arm +/// cannot see that. Once it started at a multi-repo root, its own sandbox kept .git and .codex read-only +/// only at that root, so where nothing of ours confined the run, each repository's .git/hooks and +/// .git/config were writable to the agent. /// /// Each arm runs the posture its CLI can run in its lane. In this root lane the Claude arms are Confined: the /// pinned CLI refuses bypassPermissions (a Standard run's mode) to uid 0. A Confined run can write nothing the /// test reads back, so its hooks and MCP servers are observed where the CLI itself reports them — the hook_* and /// init lines of its stream-json, and hook output reaching the model. The non-root lane runs the shipped posture, /// Standard as the worker's uid (), where every command a repository plants also -/// leaves a marker file in the workspace that run may write. The Codex arm is Standard and acceptance-bearing, the -/// posture in which a loaded repository hook would run unreviewed; its markers are files in the workspace too. +/// leaves a marker file in the workspace that run may write. The Codex arms are Standard and acceptance-bearing, the +/// posture in which a loaded repository hook would run unreviewed; their markers are files in the workspace too. /// /// Armed exactly like (, /// or a harness's own command override for a local run); each arm that ran prints , which the @@ -106,7 +118,130 @@ public async Task A_codex_run_ignores_the_config_and_hooks_its_repository_commit File.Exists(ownHook).ShouldBeTrue($"the platform's own Stop hook must still run with the repository's hooks shut out — a distrust that also silenced it would disable in-loop acceptance. {Diagnosis(harnessKind, spec, run, upstream)}"); upstream.Requests.ShouldContain(r => r.Body.Contains($"PROJECT-DOC-{repo.Nonce}", StringComparison.Ordinal), $"the repository's AGENTS.md is context, not config — it must still reach the model. {Diagnosis(harnessKind, spec, run, upstream)}"); - output.WriteLine($"{RanMarker} codex-cli confined={BubblewrapSandbox.Available is not null} hostileConnections={hostile.Connections}"); + output.WriteLine($"{RanMarker} codex-cli single-repo confined={BubblewrapSandbox.Available is not null} hostileConnections={hostile.Connections}"); + } + + /// + /// A multi-repo Codex run's cwd is the workspace root, which holds each repository in a folder of its own and is no + /// repository itself. The pinned 0.142.2 refuses such a cwd unless told to skip its git check: exit 1 before any model + /// request. The run must start there and reach its broker. Codex reads project config from its cwd upward, never from + /// below it, so in this layout the root is where config would load from: hostile config planted there must not load, + /// which holds only while the distrust covers a cwd that is no repository. Each repository below commits hostile + /// config too. Codex never reads it from there, so those markers only catch a future CLI that reads config below its + /// cwd; the distrust of a repository's own committed config is pinned by + /// . Which + /// instructions reach the run from the repositories below its cwd is not asserted here. + /// + [Fact] + public async Task A_multi_repo_codex_run_starts_at_a_workspace_root_that_is_no_repository_and_loads_no_config_from_it() + { + const string harnessKind = CodexHarness.HarnessKind; + var harness = ReviewerReadsItsDiffE2ETests.HarnessFor(harnessKind); + + if (!ReviewerReadsItsDiffE2ETests.Armed(harnessKind) || OperatingSystem.IsWindows()) return; + + await ReviewerReadsItsDiffE2ETests.RequirePinnedBinaryAsync(harness, harnessKind); + + using var hostile = new ConnectionCounter(); + var workspace = NewWorkspace(repositories: 2); + var rootMarkers = new Markers(workspace.Directory, workspace.Nonce, "workspace root", "mcp-server", "session", "prompt", "stop"); + var markers = workspace.Repositories.Select(repo => new Markers(repo, "mcp-server", "session", "prompt", "stop")).ToList(); + var ownHook = Path.Combine(workspace.Directory, $"marker-own-stop-hook-{workspace.Nonce}"); + + WriteCodexConfig(workspace.Directory, hostile, rootMarkers); + + foreach (var (repo, marked) in workspace.Repositories.Zip(markers)) PlantCodexConfig(repo, hostile, marked); + + GitRepositoryHolding(workspace.Directory).ShouldBeNull("fixture check: the workspace root must sit in no git repository, or the CLI's git check passes and this says nothing"); + + // Acceptance-bearing, like the single-repo arm: the posture in which a project hook, if loaded, would run unreviewed. + var (spec, run, upstream) = await RunAsync(harness, workspace, AgentAutonomyLevel.Standard, task => task with { Acceptance = new SupervisorAcceptanceSpec { Command = ["sh", "-c", $"printf ran > '{ownHook}'"] } }); + + spec.WorkingDirectory.ShouldBe(workspace.Directory, "fixture check: the run must start at the workspace root, where a multi-repo run's cwd is"); + spec.Args.ShouldContain("--dangerously-bypass-hook-trust", "fixture check: the arm must carry the bypass an acceptance-bearing run carries, or a project hook not running proves nothing"); + + var violations = BrokerViolations(run, upstream, hostile, workspace).Concat(rootMarkers.Ran()).Concat(markers.SelectMany(marked => marked.Ran())).ToList(); + + violations.ShouldBeEmpty(Diagnosis(harnessKind, spec, run, upstream)); + File.Exists(ownHook).ShouldBeTrue($"the platform's own Stop hook must run at the workspace root too. {Diagnosis(harnessKind, spec, run, upstream)}"); + + output.WriteLine($"{RanMarker} codex-cli multi-repo confined={BubblewrapSandbox.Available is not null} hostileConnections={hostile.Connections}"); + } + + /// + /// A repo-less run works in the scratch directory the executor makes for it (), + /// which has no git anywhere above it. The pinned 0.142.2 refused that cwd as it refused a multi-repo root, so every + /// repo-less Codex run failed at start. The run must start there and reach its broker. + /// + [Fact] + public async Task A_repo_less_codex_run_starts_in_a_scratch_directory_that_is_no_repository() + { + const string harnessKind = CodexHarness.HarnessKind; + var harness = ReviewerReadsItsDiffE2ETests.HarnessFor(harnessKind); + + if (!ReviewerReadsItsDiffE2ETests.Armed(harnessKind) || OperatingSystem.IsWindows()) return; + + await ReviewerReadsItsDiffE2ETests.RequirePinnedBinaryAsync(harness, harnessKind); + + using var hostile = new ConnectionCounter(); + var workspace = NewWorkspace(repositories: 0); + + GitRepositoryHolding(workspace.Directory).ShouldBeNull("fixture check: the scratch directory must sit in no git repository, or the CLI's git check passes and this says nothing"); + + var (spec, run, upstream) = await RunAsync(harness, workspace, AgentAutonomyLevel.Standard, task => task); + + spec.WorkingDirectory.ShouldBe(workspace.Directory, "fixture check: the run must start in its scratch directory"); + BrokerViolations(run, upstream, hostile, workspace).ShouldBeEmpty(Diagnosis(harnessKind, spec, run, upstream)); + + output.WriteLine($"{RanMarker} codex-cli scratch confined={BubblewrapSandbox.Available is not null}"); + } + + /// + /// Where nothing of ours confines a run, Codex's own workspace-write sandbox is its only boundary, and that sandbox + /// keeps .git and .codex read-only only at the top of each writable root. A multi-repo run's cwd is the + /// workspace root, so the harness names each repository below it as a root of its own. The scripted model asks for one + /// command that appends to each repository's .git/hooks/pre-push, .git/config and + /// .codex/config.toml, and to the one file each repository is meant to have changed. The platform's own commit + /// and push run git in each repository with the run's credential, so a hook or config the agent planted there would + /// run outside any sandbox. Under our confinement Codex's sandbox is stood down, and what an agent can write under + /// .git there is pinned by ReviewerReadsItsDiffE2ETests.A_standard_codex_writes_its_workspace_under_our_confinement_but_not_the_system_root; + /// this arm is the unconfined lane's (). + /// + internal async Task CodexKeepsEveryRepositorysMetadataReadOnlyAsync(string lane) + { + const string harnessKind = CodexHarness.HarnessKind; + var harness = ReviewerReadsItsDiffE2ETests.HarnessFor(harnessKind); + + if (!ReviewerReadsItsDiffE2ETests.Armed(harnessKind) || OperatingSystem.IsWindows()) return; + + await ReviewerReadsItsDiffE2ETests.RequirePinnedBinaryAsync(harness, harnessKind); + + BubblewrapSandbox.Available.ShouldBeNull("fixture check: this arm is about a host where nothing of ours confines the run, so Codex's own sandbox is its boundary"); + + using var hostile = new ConnectionCounter(); + var workspace = NewWorkspace(repositories: 2); + var probe = $"cs-probe-{workspace.Nonce}"; + + // Every target's directory exists before the run, so a write that fails can only have been refused, never sent nowhere. + foreach (var repo in workspace.Repositories) + { + repo.Commit(".codex/config.toml", "# the repository's own Codex config\n"); + Directory.CreateDirectory(Path.Combine(repo.Directory, ".git", "hooks")); + } + + var targets = workspace.Repositories.SelectMany(repo => new[] { ".git/hooks/pre-push", ".git/config", ".codex/config.toml", "app.txt" }.Select(relative => Path.Combine(repo.Directory, relative))).ToList(); + var changes = workspace.Repositories.Select(repo => Path.Combine(repo.Directory, "app.txt")).ToList(); + var command = string.Join("; ", targets.Select(path => $"printf '\\n# {probe}\\n' >> '{path}'")); + + var (spec, run, upstream) = await RunAsync(harness, workspace, AgentAutonomyLevel.Standard, task => task, [command]); + + var written = targets.Where(path => File.Exists(path) && File.ReadAllText(path).Contains(probe, StringComparison.Ordinal)).ToList(); + + BrokerViolations(run, upstream, hostile, workspace).ShouldBeEmpty(Diagnosis(harnessKind, spec, run, upstream)); + changes.ShouldAllBe(path => written.Contains(path), $"fixture check: the command must have run and changed each repository's file, or a refusal below proves nothing. {Diagnosis(harnessKind, spec, run, upstream)}"); + written.Except(changes).ShouldBeEmpty($"the agent wrote a repository's git metadata or Codex config, which the platform's own credentialed git or a later run would honour. {Diagnosis(harnessKind, spec, run, upstream)}"); + + output.WriteLine($"{RanMarker} {lane} codex-cli multi-repo metadata-read-only uid={NonRootWorker.EffectiveUid()}"); } /// @@ -201,8 +336,25 @@ private static IEnumerable ClaudeConfigViolations(Run run, ScriptedModel if (echoed.Count > 0) yield return $"repository hook output reached the model in {echoed.Count} request(s)"; } - /// A project config naming a model provider at and an MCP server that writes a marker when spawned, plus a hooks file with a marker at every hook point. + /// The project config of , committed the way a repository ships it. private static void PlantCodexConfig(Repository repo, ConnectionCounter hostile, Markers markers) + { + foreach (var (relativePath, content) in CodexConfigFiles(hostile, markers)) repo.Commit(relativePath, content); + } + + /// The project config of , written straight into , which no repository holds. + private static void WriteCodexConfig(string directory, ConnectionCounter hostile, Markers markers) + { + foreach (var (relativePath, content) in CodexConfigFiles(hostile, markers)) + { + var path = Path.Combine(directory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + } + + /// A project config naming a model provider at and an MCP server that writes a marker when spawned, plus a hooks file with a marker at every hook point. + private static IEnumerable<(string RelativePath, string Content)> CodexConfigFiles(ConnectionCounter hostile, Markers markers) { var toml = $""" model_provider = "repo" @@ -219,16 +371,16 @@ private static void PlantCodexConfig(Repository repo, ConnectionCounter hostile, """; var hooks = new JsonObject { ["hooks"] = new JsonObject { ["SessionStart"] = CodexHook(markers.Command("session")), ["UserPromptSubmit"] = CodexHook(markers.Command("prompt")), ["Stop"] = CodexHook(markers.Command("stop")) } }; - repo.Commit(".codex/config.toml", toml + "\n"); - repo.Commit(".codex/hooks.json", hooks.ToJsonString()); + yield return (".codex/config.toml", toml + "\n"); + yield return (".codex/hooks.json", hooks.ToJsonString()); } private static JsonArray CodexHook(string command) => new(new JsonObject { ["hooks"] = new JsonArray(new JsonObject { ["type"] = "command", ["command"] = command }) }); - /// The run launched the way the executor launches it, in and at 's production permissions, against a scripted model that answers at once. - private async Task<(SandboxSpec Spec, Run Run, ScriptedModelUpstream Upstream)> RunAsync(IAgentHarness harness, Workspace workspace, AgentAutonomyLevel tier, Func shape) + /// The run launched the way the executor launches it, in and at 's production permissions, against a scripted model that asks for each of in turn and then answers. + private async Task<(SandboxSpec Spec, Run Run, ScriptedModelUpstream Upstream)> RunAsync(IAgentHarness harness, Workspace workspace, AgentAutonomyLevel tier, Func shape, IReadOnlyList? commands = null) { - var upstream = new ScriptedModelUpstream([], $"DONE-{workspace.Nonce}"); + var upstream = new ScriptedModelUpstream(commands ?? [], $"DONE-{workspace.Nonce}"); using var broker = LoopbackModelCredentialBroker.ForTest(upstream); var permissions = AgentAutonomyPolicy.Derive(tier); var brokered = await OpenLeaseAsync(broker, permissions); @@ -282,12 +434,19 @@ private async Task OpenLeaseAsync(LoopbackModelCredenti } /// - /// A workspace laid out as the executor hands it over, at the temp path as Path.GetTempPath() spells it: one - /// repository is the workspace itself; several sit each in a directory of its own under a root that holds only the - /// manifest, which is where a multi-repo run's cwd is. + /// A workspace laid out as the executor hands it over, at the temp path as Path.GetTempPath() spells it: no + /// repository is the scratch directory a repo-less run gets; one repository is the workspace itself; several sit each + /// in a directory of its own under a root that holds only the manifest, which is where a multi-repo run's cwd is. /// private Workspace NewWorkspace(int repositories) { + if (repositories == 0) + { + var scratch = ScratchWorkspaceHandle.Create(Guid.NewGuid()); + _directories.Add(scratch.Directory); + return new Workspace(scratch.Directory, [], Guid.NewGuid().ToString("N")); + } + if (repositories == 1) { var repo = NewRepository(NewDirectory("repo-config-repo")); @@ -314,6 +473,19 @@ private static Repository NewRepository(string directory) return new Repository(directory, Guid.NewGuid().ToString("N")); } + /// The nearest directory at or above holding a .git, the way git finds the repository a cwd sits in; null when there is none. + private static string? GitRepositoryHolding(string directory) + { + for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent) + { + var dotGit = Path.Combine(current.FullName, ".git"); + + if (Directory.Exists(dotGit) || File.Exists(dotGit)) return current.FullName; + } + + return null; + } + private string NewDirectory(string label) { var directory = Path.Combine(Path.GetTempPath(), $"cs-{label}-{Guid.NewGuid():N}"); @@ -355,14 +527,17 @@ public void Commit(string relativePath, string content) } } - /// One file per command the repository planted, inside the repository — writable to a Standard run, so a command that did run there could not fail to leave its mark. - private sealed class Markers(Repository repo, params string[] names) + /// One file per command planted in , inside it — writable to a Standard run, so a command that did run there could not fail to leave its mark. + private sealed class Markers(string directory, string nonce, string owner, params string[] names) { - private readonly Dictionary _paths = names.ToDictionary(name => name, name => Path.Combine(repo.Directory, $"marker-{name}-{repo.Nonce}")); + private readonly Dictionary _paths = names.ToDictionary(name => name, name => Path.Combine(directory, $"marker-{name}-{nonce}")); + + /// The markers of the commands a repository plants. + public Markers(Repository repo, params string[] names) : this(repo.Directory, repo.Nonce, "repository", names) { } public string Command(string name) => $"printf ran > '{_paths[name]}'"; - public IEnumerable Ran() => _paths.Where(p => File.Exists(p.Value)).Select(p => $"the CLI ran the repository's {p.Key} command"); + public IEnumerable Ran() => _paths.Where(p => File.Exists(p.Value)).Select(p => $"the CLI ran the {owner}'s {p.Key} command"); } /// A loopback listener that accepts and drops every connection, counting them — the endpoint a repository's config names, which a pinned run must never dial. diff --git a/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs index 77ca11a75..18d872bc4 100644 --- a/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/ReviewerReadsItsDiffE2ETests.cs @@ -77,9 +77,15 @@ public async Task The_pinned_codex_accepts_the_resume_spelling_of_its_stand_down await RequirePinnedBinaryAsync(harness, CodexHarness.HarnessKind); - // A real repository, as every production workspace is: Codex refuses an untrusted non-git directory before it gets anywhere near the rollout. - var task = new AgentTask { Goal = "resume", Harness = CodexHarness.HarnessKind, WorkspaceDirectory = NewReviewRepository().Directory, ResumeFromSessionId = Guid.NewGuid().ToString(), TimeoutSeconds = 60, Environment = new Dictionary { [CodexHarness.ApiKeyEnvVar] = "sk-review-e2e-resume", ["HOME"] = NewDirectory("resume-home") } }; + // At a directory laid out like a multi-repo run's workspace root, which is no repository: Codex refuses such a cwd + // before it gets anywhere near the rollout unless the resume seed carries --skip-git-repo-check. The repositories + // below it ride the argv as writable roots of their own, so the pinned binary must accept those on resume too. + var (root, repositories) = NewWorkspaceRoot(); + var task = new AgentTask { Goal = "resume", Harness = CodexHarness.HarnessKind, WorkspaceDirectory = root, WorkspaceRepositoryDirectories = repositories, ResumeFromSessionId = Guid.NewGuid().ToString(), TimeoutSeconds = 60, Environment = new Dictionary { [CodexHarness.ApiKeyEnvVar] = "sk-review-e2e-resume", ["HOME"] = NewDirectory("resume-home") } }; var spec = AgentRunExecutor.ApplyWriteScope(harness.BuildInvocation(task), task.Permissions); + + spec.Args.ShouldContain(arg => arg.StartsWith("sandbox_workspace_write.writable_roots=", StringComparison.Ordinal), "fixture check: the repositories below the root must ride the resume argv, or this does not show the pinned binary accepts them"); + var bogus = spec with { Args = spec.Args.Select(arg => arg.StartsWith("sandbox_mode=", StringComparison.Ordinal) ? "sandbox_mode=not-a-mode" : arg).ToList(), WhenRunnerConfines = null }; var accepted = await new LocalProcessRunner().RunAsync(spec, CancellationToken.None); @@ -393,6 +399,23 @@ private ReviewRepository NewReviewRepository() return new ReviewRepository(RealPath(directory), head0, GitOut(directory, "rev-parse HEAD"), nonce); } + /// A directory laid out like a multi-repo run's workspace root: a manifest and two repositories below it, and no repository itself. + private (string Root, IReadOnlyList Repositories) NewWorkspaceRoot() + { + var root = NewDirectory("resume-workspace"); + var repositories = new[] { "repo-1", "repo-2" }.Select(alias => Path.Combine(root, alias)).ToList(); + + File.WriteAllText(Path.Combine(root, "WORKSPACE.md"), "# Workspace\n\nThis is a MULTI-REPO workspace; each repository is a folder below.\n"); + + foreach (var repository in repositories) + { + Directory.CreateDirectory(repository); + Git(repository, "init -q -b main"); + } + + return (root, repositories); + } + private string NewDirectory(string label) { var directory = Path.Combine(Path.GetTempPath(), $"cs-{label}-{Guid.NewGuid():N}"); diff --git a/backend/tests/CodeSpace.SandboxTests/UnconfinedWorkerE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/UnconfinedWorkerE2ETests.cs index c4b3bff3d..85c1e326f 100644 --- a/backend/tests/CodeSpace.SandboxTests/UnconfinedWorkerE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/UnconfinedWorkerE2ETests.cs @@ -16,9 +16,14 @@ namespace CodeSpace.SandboxTests; /// on the binaries alone, as it always has, so the setup's refusal aborts the launch — durable or not — and the /// admission predicts that same namespace. /// +/// Unconfined, Codex's own sandbox is the only boundary its commands meet, so this lane also runs the +/// repository-config arm that needs exactly that posture: a multi-repo Codex run whose agent must not be able to write +/// any repository's .git or .codex (). +/// /// Selected by its trait alone (--filter Category=SandboxUnconfined); the lane runs it as uid 1654 with /// CODESPACE_BWRAP_PATH naming no binary and Sandbox__RequireConfinement unset. Each arm asserts that -/// posture first () and prints , which the lane requires. +/// posture first () and prints , or for the Codex arm +/// , which the lane requires. /// [Trait("Category", Category)] public sealed class UnconfinedWorkerE2ETests(ITestOutputHelper output) @@ -74,6 +79,15 @@ public void The_admission_predicts_the_namespace_the_launch_plans() output.WriteLine($"{RanMarker} admission uid={NonRootWorker.EffectiveUid()}"); } + [Fact] + public async Task A_multi_repo_codex_run_cannot_write_a_repositorys_git_metadata_where_its_own_sandbox_is_the_boundary() + { + if (!RequirePosture()) return; + + using var arms = new RepositoryConfigE2ETests(output); + await arms.CodexKeepsEveryRepositorysMetadataReadOnlyAsync("unconfined"); + } + /// /// True on Linux once the posture is proved; false on any other OS (Rule 12.1). On Linux a missing piece FAILS the /// test: an arm that ran where bubblewrap confines, or as root, proves nothing about the posture it is named for. diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs index a35ff39af..8160cf51e 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/CodexHarnessTests.cs @@ -276,11 +276,66 @@ public void Builds_a_codex_exec_json_invocation_from_the_task() var spec = Harness.BuildInvocation(Task()); spec.Command.ShouldBe("codex"); - spec.Args.ShouldBe(new[] { "exec", "--json", "--model", "gpt-5.3-codex", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }); + spec.Args.ShouldBe(new[] { "exec", "--json", "--skip-git-repo-check", "--model", "gpt-5.3-codex", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }); spec.WorkingDirectory.ShouldBe("/tmp/ws"); spec.TimeoutSeconds.ShouldBe(900); } + [Theory] + [InlineData(null, new[] { "exec", "--json", "--skip-git-repo-check" })] + [InlineData("thr-resume-1", new[] { "exec", "resume", "thr-resume-1", "--json", "--skip-git-repo-check" })] + public void Every_run_may_start_in_a_workspace_root_that_is_not_a_git_repository(string? resumeFromSessionId, string[] seed) + { + // A multi-repo run's cwd is the workspace root, which holds each repository in a folder of its own and is no + // repository itself; a repo-less run's is a scratch directory with no git above it. Without this flag the pinned + // 0.142.2 refuses either cwd before any model request, for `exec` and `exec resume ` alike: exit 1, "Not + // inside a trusted directory and --skip-git-repo-check was not specified". A trust entry does not lift it. + // Inside a repository the flag changes nothing the model is sent. + var args = Harness.BuildInvocation(Task() with { ResumeFromSessionId = resumeFromSessionId }).Args; + + args.Take(seed.Length).ShouldBe(seed); + args.Count(a => a == "--skip-git-repo-check").ShouldBe(1); + } + + [Theory] + [InlineData(null, new[] { "exec", "--json", "--skip-git-repo-check", "--model", "gpt-5.3-codex", "--sandbox", "workspace-write", "-c", "sandbox_workspace_write.writable_roots=[\"/tmp/ws/api\",\"/tmp/ws/web\"]", "-c", WorkspaceDistrust, "-" })] + [InlineData("thr-resume-1", new[] { "exec", "resume", "thr-resume-1", "--json", "--skip-git-repo-check", "--model", "gpt-5.3-codex", "-c", "sandbox_mode=workspace-write", "-c", "sandbox_workspace_write.writable_roots=[\"/tmp/ws/api\",\"/tmp/ws/web\"]", "-c", WorkspaceDistrust, "-" })] + public void A_run_at_a_workspace_root_names_each_repository_below_it_as_a_writable_root_of_its_own(string? resumeFromSessionId, string[] expected) + { + // Codex's workspace-write sandbox keeps .git, .codex and .agents read-only only at the top of each writable root. + // At a multi-repo root the repositories sit below the cwd, not at the top of a root, so each one's .git/hooks and + // .git/config were writable to the agent, and the platform's own commit and push run git in each repository with + // the run's credential. Named as roots of their own, the real 0.142.2 refuses those writes and still lets the agent + // change the repositories' files (observed on macOS; UnconfinedWorkerE2ETests runs it on Linux). No write access + // is added: each one is already inside the cwd. + var task = Task() with { ResumeFromSessionId = resumeFromSessionId, WorkspaceRepositoryDirectories = ["/tmp/ws/api", "/tmp/ws/web"] }; + + Harness.BuildInvocation(task).Args.ShouldBe(expected); + } + + public static TheoryData RunsWithNoRepositoryBelowAWritableCwd() => new() + { + { "single repo, which is the cwd itself", Task() with { WorkspaceRepositoryDirectories = ["/tmp/ws"] } }, + { "scratch, which holds no repository", Task() with { WorkspaceRepositoryDirectories = [] } }, + { "no workspace materialised", Task() }, + { "a repository beside a cwd at the primary one, which a root would widen the sandbox to", Task() with { WorkspaceDirectory = "/tmp/ws/api", WorkspaceRepositoryDirectories = ["/tmp/ws/api", "/tmp/ws/web"] } }, + { "a directory that only shares the cwd's prefix", Task() with { WorkspaceRepositoryDirectories = ["/tmp/ws-other"] } }, + { "read-only, whose sandbox has no writable root", Task(scope: AgentWriteScope.ReadOnly) with { WorkspaceRepositoryDirectories = ["/tmp/ws/api"] } }, + }; + + [Theory] + [MemberData(nameof(RunsWithNoRepositoryBelowAWritableCwd))] + public void A_run_with_no_repository_below_a_writable_cwd_names_no_writable_root(string shape, AgentTask task) => + Harness.BuildInvocation(task).Args.ShouldNotContain(a => a.StartsWith("sandbox_workspace_write.", StringComparison.Ordinal), $"{shape}: nothing to carve out"); + + [Fact] + public void Each_writable_root_is_a_quoted_toml_string_so_a_quote_in_its_path_cannot_end_it() + { + var args = Harness.BuildInvocation(Task() with { WorkspaceRepositoryDirectories = ["/tmp/ws/a\"b\\c"] }).Args; + + args.ShouldContain("sandbox_workspace_write.writable_roots=[\"/tmp/ws/a\\\"b\\\\c\"]"); + } + public static TheoryData EveryRunShape() => new() { { "fresh", Task() }, @@ -373,7 +428,7 @@ public void Builds_a_resume_invocation_when_a_prior_session_is_set() // while -c is accepted on it and sandbox_mode is the config key the flag maps to. The Goal stays last. var spec = Harness.BuildInvocation(Task() with { ResumeFromSessionId = "thr-resume-1" }); - spec.Args.ShouldBe(new[] { "exec", "resume", "thr-resume-1", "--json", "--model", "gpt-5.3-codex", "-c", "sandbox_mode=workspace-write", "-c", WorkspaceDistrust, "-" }); + spec.Args.ShouldBe(new[] { "exec", "resume", "thr-resume-1", "--json", "--skip-git-repo-check", "--model", "gpt-5.3-codex", "-c", "sandbox_mode=workspace-write", "-c", WorkspaceDistrust, "-" }); } [Fact] @@ -425,7 +480,7 @@ public void Omits_the_resume_subcommand_when_no_prior_session() var spec = Harness.BuildInvocation(Task() with { ResumeFromSessionId = null }); spec.Args.ShouldNotContain("resume"); - spec.Args.ShouldBe(new[] { "exec", "--json", "--model", "gpt-5.3-codex", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }); + spec.Args.ShouldBe(new[] { "exec", "--json", "--skip-git-repo-check", "--model", "gpt-5.3-codex", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }); } [Fact] @@ -496,7 +551,7 @@ public void Omits_the_model_flag_when_no_model_is_set(string? model) { var spec = Harness.BuildInvocation(Task(model: model)); - spec.Args.ShouldBe(new[] { "exec", "--json", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }, + spec.Args.ShouldBe(new[] { "exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }, customMessage: "a blank model must omit --model entirely (not emit `--model \"\"`, which Codex rejects) so the CLI uses its default"); } @@ -508,7 +563,7 @@ public void Tools_are_not_projected_codex_has_no_global_allow_list() var withTools = Harness.BuildInvocation(Task() with { Tools = new[] { "Read", "Grep" } }); withTools.Args.ShouldNotContain("--allowed-tools"); - withTools.Args.ShouldBe(new[] { "exec", "--json", "--model", "gpt-5.3-codex", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }, + withTools.Args.ShouldBe(new[] { "exec", "--json", "--skip-git-repo-check", "--model", "gpt-5.3-codex", "--sandbox", "workspace-write", "-c", WorkspaceDistrust, "-" }, customMessage: "a tools list must not change the Codex invocation — it has no faithful projection there"); }