From f68742f69e723c1398eaeab24ef5614c823e7e84 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Tue, 6 Oct 2026 08:31:50 +0800 Subject: [PATCH] Keep agent-planted git hooks and drivers out of capture The capture, the re-attach capture and the branch commit ran git inside the agent's clone after its turn, with the network on and no hardening. An agent that wrote its own .git during the run could make those commands run its code: client hooks (a vetoing pre-commit silently lost the produced branch), an agent-set core.hooksPath, a core.fsmonitor program, and textconv or external diff drivers selected through .gitattributes or diff.external. Every git command over the agent clone now goes through one builder, AgentCloneGitCommand: -c core.hooksPath=/dev/null, -c core.fsmonitor= and --no-ext-diff --no-textconv on diffs, with no network and no credential. The fsmonitor value is empty rather than "false" because git before 2.36 reads the key only as a program path and runs a PATH-resolved "false" on every index refresh. The hooks path is /dev/null rather than an empty value, which git resolves to the filesystem root. The -c values also reach the gc --auto a commit can start, so an agent that lowers gc.auto and re-enables the maintenance gc task (off by default since git 2.54) cannot run pre-auto-gc either. A repo filter..clean still runs on add -A because git has no command-line switch for it. It runs at the agent's own uid with no secret and, under bubblewrap, with no egress. Global and system config stay in force for git-lfs and operator CA or proxy settings. The sandbox lane's floor counts the bubblewrap case that proves the egress is severed, and the lane requires that case to have passed, because a skipped case reads as green. The authenticated push and its ls-remote readback still run in the agent clone. Moving them to a platform-owned repository is a separate change. --- .github/workflows/sandbox-isolation.yml | 10 +- .../Agents/Workspace/AgentCloneGitCommand.cs | 54 +++ .../Providers/LocalGitWorkspaceProvider.cs | 70 ++-- .../AgentCloneGitHookIsolationFlowTests.cs | 321 ++++++++++++++++++ .../GitWorkspaceIsolationE2ETests.cs | 34 ++ .../Workflows/AgentCloneGitCommandTests.cs | 64 ++++ .../LocalGitWorkspaceProviderTests.cs | 63 ++++ 7 files changed, 588 insertions(+), 28 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/AgentCloneGitHookIsolationFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/AgentCloneGitCommandTests.cs diff --git a/.github/workflows/sandbox-isolation.yml b/.github/workflows/sandbox-isolation.yml index 32f6f4823..bbb1ad4a0 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 96 ]; then - echo "::error::Expected >=96 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 + a goal handed to the real Claude CLI as text it cannot act on, fresh and resumed, against the text channel as its control), 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 97 ]; then + echo "::error::Expected >=97 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 + 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), 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 @@ -284,6 +284,12 @@ jobs: assert len(cases) == rows and all(r.get('outcome') == 'Passed' for r in cases), f'{method}: all {rows} case(s) must pass' print('All 4 goal-channel E2E arms ran and passed.') + # The capture cannot switch off a repository's filter..clean, so the only proof its run is harmless is this + # case: a KernelFact that skips without bubblewrap, and a skip is no evidence. Require it ran once and passed. + clean_filter = [r for r in results if 'GitWorkspaceIsolationE2ETests.A_repo_clean_filter_runs_during_capture_with_its_egress_severed' in r.get('testName', '')] + assert len(clean_filter) == 1 and clean_filter[0].get('outcome') == 'Passed', 'the clean-filter capture case must run once and pass' + print('The clean-filter capture case ran and passed.') + # The sealed-egress E2E returns early on a host that cannot confine, which reads as Passed; require each arm's marker. for arm in ('durable', 'non-durable', 'relay-ipv6', 'restart', 'relay-refused', 'relay-policy-route'): assert f'[sealed-egress-e2e] ran {arm}' in text, f'sealed-egress E2E arm "{arm}" did not run — this lane is root with bwrap and the relay helper, so it must relay' diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs new file mode 100644 index 000000000..167226774 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs @@ -0,0 +1,54 @@ +using CodeSpace.Messages.Agents; + +namespace CodeSpace.Core.Services.Agents.Workspace; + +/// +/// Builds every git spec the platform runs over an agent's clone after the agent's turn: the capture +/// (add -A and the diff --cached trio), the re-attach capture, and the branch steps (checkout -B, +/// add -A, commit, diff --quiet, rev-parse HEAD). The agent can write its own .git +/// during the run, so by then its config, hooks and attributes are the agent's. Each spec built here makes sure a +/// tampered .git cannot run code with anything the agent does not already have: +/// +/// -c core.hooksPath=/dev/null — a command-line value beats the repo's own, so neither a planted +/// .git/hooks/* nor an agent-set hooks directory runs, and a vetoing pre-commit can no longer lose the +/// produced branch. git looks hooks up as /dev/null/<name>, which can never exist. Not an empty value: +/// git resolves core.hooksPath= to the filesystem root. +/// -c core.fsmonitor= — the empty value turns off a repo core.fsmonitor program on every git +/// version. Not false: before 2.36 git reads the key only as a program path and runs false from PATH on +/// every index refresh. +/// a diff gets --no-ext-diff --no-textconv, so diff.external, diff.<driver>.command +/// and diff.<driver>.textconv (selected through .gitattributes) never run. +/// no network and no credential: is false and the environment is empty. +/// A repo filter.<driver>.clean still runs on add -A — git has no command-line switch for it — +/// but only at the agent's own uid, with no secret and, under a confining runner, no egress. +/// +/// The -c values reach child git processes too (a commit's gc --auto), through +/// GIT_CONFIG_PARAMETERS. Global and system config are left alone: the worker image registers git-lfs with +/// git lfs install --system, and operators may set a CA bundle or proxy globally. Commands that reach the remote +/// (clone, fetch, ls-remote, the authenticated push) are not built here. +/// +internal static class AgentCloneGitCommand +{ + /// The config every command gets ahead of its own arguments. + internal static readonly IReadOnlyList HardeningConfig = new[] { "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=" }; + + /// The flags a diff gets right after the subcommand. + private static readonly IReadOnlyList DiffDriverSuppression = new[] { "--no-ext-diff", "--no-textconv" }; + + /// The hardened spec for one git command in an agent clone; is the command as written without hardening, e.g. ["add","-A"] or a commit with its inline -c identity flags. + internal static SandboxSpec Build(IReadOnlyList args, string workingDirectory, IReadOnlyList readOnlyPaths, int timeoutSeconds) => new() + { + Command = "git", + Args = HardenedArgs(args), + WorkingDirectory = workingDirectory, + ReadOnlyPaths = readOnlyPaths, + TimeoutSeconds = timeoutSeconds, + AllowNetwork = false, + }; + + /// ahead of the command, plus right after a leading diff. + internal static IReadOnlyList HardenedArgs(IReadOnlyList args) => + IsDiff(args) ? [.. HardeningConfig, args[0], .. DiffDriverSuppression, .. args.Skip(1)] : [.. HardeningConfig, .. args]; + + private static bool IsDiff(IReadOnlyList args) => args.Count > 0 && args[0] == "diff"; +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs index fc72efef1..821824332 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs @@ -224,7 +224,8 @@ public async Task CaptureChangesFromPathAsync(string directory async Task RunOrThrowAsync(IReadOnlyList args) { - var result = await runner.RunAsync(new SandboxSpec { Command = "git", Args = args, WorkingDirectory = directory, TimeoutSeconds = CaptureTimeoutSeconds, AllowNetwork = true }, cancellationToken).ConfigureAwait(false); + // The agent may have tampered this .git during its run: hardened like the live capture (AgentCloneGitCommand). + var result = await runner.RunAsync(AgentCloneGitCommand.Build(args, directory, Array.Empty(), CaptureTimeoutSeconds), cancellationToken).ConfigureAwait(false); if (result.Status != SandboxStatus.Success) throw new WorkspaceException($"git {string.Join(' ', args)} failed (exit {result.ExitCode}): {Summarize(result.Stderr)}"); @@ -662,11 +663,11 @@ private async Task CaptureRepoChangesAsync(MaterializedRepo re // Stage everything (new, modified, deleted) so the diff vs the cloned base is complete, then // read the patch + the changed-file names. `--cached ` captures committed AND uncommitted // work, so it's robust whether the agent committed or just edited the working tree. - await RunGitOrThrowAsync(repo, new[] { "add", "-A" }, cancellationToken).ConfigureAwait(false); + await RunAgentCloneGitOrThrowAsync(repo, new[] { "add", "-A" }, cancellationToken).ConfigureAwait(false); - var patch = await RunGitOrThrowAsync(repo, new[] { "diff", "--cached", "--no-color", repo.BaseSha }, cancellationToken).ConfigureAwait(false); - var names = await RunGitOrThrowAsync(repo, new[] { "diff", "--cached", "--name-only", repo.BaseSha }, cancellationToken).ConfigureAwait(false); - var numstat = await RunGitOrThrowAsync(repo, new[] { "diff", "--cached", "--numstat", repo.BaseSha }, cancellationToken).ConfigureAwait(false); + var patch = await RunAgentCloneGitOrThrowAsync(repo, new[] { "diff", "--cached", "--no-color", repo.BaseSha }, cancellationToken).ConfigureAwait(false); + var names = await RunAgentCloneGitOrThrowAsync(repo, new[] { "diff", "--cached", "--name-only", repo.BaseSha }, cancellationToken).ConfigureAwait(false); + var numstat = await RunAgentCloneGitOrThrowAsync(repo, new[] { "diff", "--cached", "--numstat", repo.BaseSha }, cancellationToken).ConfigureAwait(false); return new WorkspaceChanges { @@ -704,8 +705,8 @@ private async Task CaptureRepoChangesAsync(MaterializedRepo re // pass in the same workspace — the branch already exists locally from the first push, and -b would throw. // Round 1 is byte-identical (-B creates when absent); a re-push resets the branch to the CURRENT head, // which is exactly the force-overwrite semantics the remote half (push --force) already promises. - await RunGitOrThrowAsync(repo, new[] { "checkout", "-B", branchName }, cancellationToken).ConfigureAwait(false); - await RunGitOrThrowAsync(repo, new[] { "add", "-A" }, cancellationToken).ConfigureAwait(false); + await RunAgentCloneGitOrThrowAsync(repo, new[] { "checkout", "-B", branchName }, cancellationToken).ConfigureAwait(false); + await RunAgentCloneGitOrThrowAsync(repo, new[] { "add", "-A" }, cancellationToken).ConfigureAwait(false); // A run that changed nothing has nothing to push. The agent may either leave its edits for us to commit // OR commit them itself — so push when we just made a commit, OR when the branch tip already differs @@ -741,7 +742,7 @@ private async Task CaptureRepoChangesAsync(MaterializedRepo re { try { - var localTip = (await RunGitOrThrowAsync(repo, new[] { "rev-parse", "HEAD" }, cancellationToken).ConfigureAwait(false)).Trim(); + var localTip = (await RunAgentCloneGitOrThrowAsync(repo, new[] { "rev-parse", "HEAD" }, cancellationToken).ConfigureAwait(false)).Trim(); var readback = await RunGitAsync(repo, new[] { "ls-remote", authedUrl, $"refs/heads/{branchName}" }, cancellationToken, PushTimeoutSeconds).ConfigureAwait(false); @@ -769,7 +770,7 @@ private async Task CaptureRepoChangesAsync(MaterializedRepo re /// Commit everything staged under a fixed CodeSpace identity; returns false (no commit) when there was nothing to commit. The identity AND commit.gpgsign=false are set inline via -c so the clone's git config is never mutated — and the automated capture commit can never inherit a host/global commit.gpgsign=true that would make it block on a signing key the unattended agent does not have (which would fail the branch push → the produced branch is silently lost). An internal automation commit under a synthetic identity has no meaningful signature, so signing is always disabled here. private async Task CommitOrDetectEmptyAsync(MaterializedRepo repo, string branchName, CancellationToken cancellationToken) { - var result = await RunGitAsync(repo, new[] { "-c", "commit.gpgsign=false", "-c", "user.name=CodeSpace", "-c", "user.email=agent@codespace.local", "commit", "-m", $"Agent run {branchName}" }, cancellationToken, PushTimeoutSeconds).ConfigureAwait(false); + var result = await RunAgentCloneGitAsync(repo, new[] { "-c", "commit.gpgsign=false", "-c", "user.name=CodeSpace", "-c", "user.email=agent@codespace.local", "commit", "-m", $"Agent run {branchName}" }, cancellationToken, PushTimeoutSeconds).ConfigureAwait(false); if (result.Status == SandboxStatus.Success) return true; @@ -790,39 +791,56 @@ private async Task CommitOrDetectEmptyAsync(MaterializedRepo repo, string /// genuine git error it fails toward pushing rather than silently dropping the agent's work). private async Task HeadDiffersFromBaseAsync(MaterializedRepo repo, CancellationToken cancellationToken) { - var result = await RunGitAsync(repo, new[] { "diff", "--quiet", repo.BaseSha, "HEAD" }, cancellationToken, CaptureTimeoutSeconds).ConfigureAwait(false); + var result = await RunAgentCloneGitAsync(repo, new[] { "diff", "--quiet", repo.BaseSha, "HEAD" }, cancellationToken, CaptureTimeoutSeconds).ConfigureAwait(false); return result.ExitCode != 0; } - private async Task RunGitOrThrowAsync(MaterializedRepo repo, IReadOnlyList args, CancellationToken cancellationToken, int timeoutSeconds = CaptureTimeoutSeconds) - { - var result = await RunGitAsync(repo, args, cancellationToken, timeoutSeconds).ConfigureAwait(false); + // ── Commands over the agent-writable clone (capture, checkout, add, commit, diff, rev-parse) ── + // Built by AgentCloneGitCommand: no hook, fsmonitor, textconv or external diff runs, with no network and no + // credential, so a .git the agent tampered during its run cannot run code with the platform's access. - if (result.Status != SandboxStatus.Success) - // Redact any echoed token (the push argv embeds the authed URL) so it never reaches a log / exception. - throw new WorkspaceException($"git {string.Join(' ', RedactArgs(args, repo.Token))} failed (exit {result.ExitCode}): {Redact(result.Stderr.Trim(), repo.Token)}"); + private Task RunAgentCloneGitOrThrowAsync(MaterializedRepo repo, IReadOnlyList args, CancellationToken cancellationToken, int timeoutSeconds = CaptureTimeoutSeconds) => + EnsureSuccessAsync(repo, args, RunAgentCloneGitAsync(repo, args, cancellationToken, timeoutSeconds)); - SandboxOutputCompleteness.RequireStdout(result); - return result.Stdout; - } + /// Returns the raw result so a caller can classify it (e.g. detect "nothing to commit") rather than always throw. + private Task RunAgentCloneGitAsync(MaterializedRepo repo, IReadOnlyList args, CancellationToken cancellationToken, int timeoutSeconds) => + ExecuteGitAsync(repo, args, AgentCloneGitCommand.Build(args, repo.Directory, repo.ReadOnlyPaths, timeoutSeconds), cancellationToken); + + // ── Commands that reach the remote (the authenticated push and its ls-remote readback) ── + // Network on, and the clone credential re-injected into the argv. + + private Task RunGitOrThrowAsync(MaterializedRepo repo, IReadOnlyList args, CancellationToken cancellationToken, int timeoutSeconds = CaptureTimeoutSeconds) => + EnsureSuccessAsync(repo, args, RunGitAsync(repo, args, cancellationToken, timeoutSeconds)); + + /// Run a git command in a SPECIFIC repo's clone (its directory as cwd) with explicit remote-network access. Returns the raw result so a caller can classify it (e.g. an unreadable remote on the readback) rather than always throw. + private Task RunGitAsync(MaterializedRepo repo, IReadOnlyList args, CancellationToken cancellationToken, int timeoutSeconds) => + ExecuteGitAsync(repo, args, new SandboxSpec { Command = "git", Args = args, WorkingDirectory = repo.Directory, ReadOnlyPaths = repo.ReadOnlyPaths, TimeoutSeconds = timeoutSeconds, AllowNetwork = true }, cancellationToken); - /// Run a git command in a SPECIFIC repo's clone (its directory as cwd) through the same confined batch path with explicit remote-network access. Returns the raw result so a caller can classify it (e.g. detect "nothing to commit") rather than always throw. - private async Task RunGitAsync(MaterializedRepo repo, IReadOnlyList args, CancellationToken cancellationToken, int timeoutSeconds) + /// The one place a built spec is handed to the runner: maps any infrastructure failure (git not on PATH, the working directory removed mid-run) onto a redacted so no raw Win32Exception/IOException — and no echoed token — leaks to the caller. + private async Task ExecuteGitAsync(MaterializedRepo repo, IReadOnlyList args, SandboxSpec spec, CancellationToken cancellationToken) { try { - return await _runner.RunAsync( - new SandboxSpec { Command = "git", Args = args, WorkingDirectory = repo.Directory, ReadOnlyPaths = repo.ReadOnlyPaths, TimeoutSeconds = timeoutSeconds, AllowNetwork = true }, cancellationToken).ConfigureAwait(false); + return await _runner.RunAsync(spec, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { - // Honour the IWorkspaceHandle contract: a git failure — including an INFRASTRUCTURE failure the - // runner throws (git not on PATH, the working directory removed mid-run) — surfaces as a - // WorkspaceException, never a raw Win32Exception/IOException leaking to the caller. throw new WorkspaceException($"git {string.Join(' ', RedactArgs(args, repo.Token))} could not run: {Redact(ex.Message, repo.Token)}", ex); } } + /// Await a git result and throw a redacted on a non-success status; the argv is redacted because the push command carries the authed URL. + private static async Task EnsureSuccessAsync(MaterializedRepo repo, IReadOnlyList args, Task run) + { + var result = await run.ConfigureAwait(false); + + if (result.Status != SandboxStatus.Success) + throw new WorkspaceException($"git {string.Join(' ', RedactArgs(args, repo.Token))} failed (exit {result.ExitCode}): {Redact(result.Stderr.Trim(), repo.Token)}"); + + SandboxOutputCompleteness.RequireStdout(result); + return result.Stdout; + } + /// Redact the token from the echoed argv (the push command carries the authed URL) before it lands in an exception message. private static IEnumerable RedactArgs(IReadOnlyList args, string? token) => args.Select(a => Redact(a, token)); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentCloneGitHookIsolationFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentCloneGitHookIsolationFlowTests.cs new file mode 100644 index 000000000..d454db471 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentCloneGitHookIsolationFlowTests.cs @@ -0,0 +1,321 @@ +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Agents.Sandbox.Runners; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Core.Services.Agents.Workspace.Providers; +using CodeSpace.Messages.Agents; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +/// +/// HIGH fidelity (Rule 12): drives the REAL + real git against a real +/// local bare-repo remote. After the clone — as a tampering agent could during its turn — each test plants one git +/// code-execution vector in the clone, then runs the production capture, the re-attach capture and the branch +/// checkout/commit, and asserts the vector did not run while the agent's work still reaches the remote. +/// +/// The vectors are the ones that fire during capture, commit or checkout: the client hooks those commands run +/// (a vetoing pre-commit among them, and pre-auto-gc, which a commit triggers once the agent lowers gc.auto and +/// enables the maintenance gc task that git 2.54+ leaves off by default), an +/// agent-set core.hooksPath, an agent-set core.fsmonitor program, and textconv / external-diff drivers +/// selected through .gitattributes or diff.external. A positive-control theory over the same list runs the +/// commands the platform used to run, unhardened, and asserts every plant fires — so the hardened theory's silence is +/// the hardening, never a plant that does nothing. The pre-push hook and the remote-redirect config ride the +/// authenticated push, which these tests do not cover. +/// +/// One vector is accepted rather than closed: a repo filter.<driver>.clean has no command-line off +/// switch, so it still runs on add -A. It runs with no credential (asserted on the filter's own environment) and +/// on a spec that declares no network; the kernel-enforced half of that (the filter cannot reach a host listener) is +/// asserted where bubblewrap really confines, in GitWorkspaceIsolationE2ETests. +/// +/// Every marker lives inside the clone's .git, which capture never stages. Each test owns a GUID-named root +/// and the provider's clone, both removed on every path; tests skip on Windows or without git. +/// +[Trait("Category", "Integration")] +public sealed class AgentCloneGitHookIsolationFlowTests +{ + public static TheoryData Vectors => new() + { + "pre-commit", "prepare-commit-msg", "commit-msg", "post-commit", "post-checkout", "post-index-change", "reference-transaction", "pre-auto-gc", + "hooksPath-redirect", "fsmonitor", "diff-textconv", "diff-external", "diff-driver-command", + }; + + [Theory] + [MemberData(nameof(Vectors))] + public async Task Capture_reattach_capture_and_commit_run_no_agent_planted_git_code(string vector) + { + if (OperatingSystem.IsWindows()) return; + if (!await GitAvailableAsync()) return; + + using var ctx = new HookVectorContext(); + await ctx.SeedBareRemoteAsync(); + await using var handle = await ctx.CloneWithTokenAsync(); + await ctx.PlantAsync(vector, handle.Directory); + await ctx.WriteAgentChangeAsync(handle.Directory); + + var changes = await handle.CaptureChangesAsync(CancellationToken.None); + var reattached = await ctx.Provider.CaptureChangesFromPathAsync(handle.Directory, handle.Repositories.Single().BaseSha!, CancellationToken.None); + var branch = await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None); + + changes.ChangedFiles.ShouldContain(HookVectorContext.AgentFile, $"[{vector}] the hardened capture still diffs the agent's change"); + changes.Patch.ShouldContain(HookVectorContext.AgentContent, customMessage: $"[{vector}] the patch carries the real content, not a textconv or external-diff rendering"); + reattached.Patch.ShouldBe(changes.Patch, $"[{vector}] the re-attach capture reads the same change"); + branch.ShouldBe(ctx.BranchName, $"[{vector}] a planted hook or veto must not lose the produced branch"); + (await ctx.RemoteFileAsync(ctx.BranchName, HookVectorContext.AgentFile)).ShouldBe(HookVectorContext.AgentContent, $"[{vector}] the pushed branch carries the agent's file"); + ctx.FiredVectors().ShouldBeEmpty($"[{vector}] agent-planted git code ran during capture / commit / checkout — see {ctx.MarkerPath}"); + } + + [Theory] + [MemberData(nameof(Vectors))] + public async Task Each_planted_vector_fires_under_the_unhardened_commands_the_platform_used_to_run(string vector) + { + if (OperatingSystem.IsWindows()) return; + if (!await GitAvailableAsync()) return; + + using var ctx = new HookVectorContext(); + await ctx.SeedBareRemoteAsync(); + await using var handle = await ctx.CloneWithTokenAsync(); + await ctx.PlantAsync(vector, handle.Directory); + await ctx.WriteAgentChangeAsync(handle.Directory); + + await ctx.RunUnhardenedCaptureAndCommitAsync(handle.Directory, handle.Repositories.Single().BaseSha!); + + ctx.FiredVectors().ShouldContain(vector, $"[{vector}] the plant must fire under plain git, or the hardened theory proves nothing — see {ctx.MarkerPath}"); + } + + [Fact] + public async Task A_repo_clean_filter_still_runs_on_capture_but_with_no_credential_and_no_declared_network() + { + if (OperatingSystem.IsWindows()) return; + if (!await GitAvailableAsync()) return; + + using var ctx = new HookVectorContext(); + await ctx.SeedBareRemoteAsync(); + await using var handle = await ctx.CloneWithTokenAsync(); + await ctx.PlantCleanFilterAsync(handle.Directory); + await ctx.WriteAgentChangeAsync(handle.Directory); + + var changes = await handle.CaptureChangesAsync(CancellationToken.None); + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + + changes.Patch.ShouldContain(HookVectorContext.AgentContent, customMessage: "the clean filter passes content through, so the diff is intact"); + + // Accepted: filter..clean has no -c off switch, so it still runs on `add -A` — at the agent's own uid, + // which already controls those bytes. What must hold is that it gets nothing the agent does not already have. + var filterSaw = ctx.CleanFilterRecord(); + filterSaw.ShouldNotBeEmpty("the repo-local clean filter runs on add -A — this vector is accepted, not closed"); + filterSaw.ShouldNotContain(HookVectorContext.Token, customMessage: "no clone credential reaches the filter's argv or environment"); + filterSaw.ShouldNotContain(Uri.EscapeDataString(HookVectorContext.Token), customMessage: "nor its URL-encoded form"); + + var adds = ctx.RecordedSpecs.Where(s => s.Args.Contains("add")).ToList(); + adds.Count.ShouldBe(2, "capture and commit each stage once"); + adds.ShouldAllBe(s => !s.AllowNetwork && s.Environment.Count == 0, "the add -A a surviving filter rides declares no network and carries no credential"); + } + + [Fact] + public async Task The_hardened_argv_starts_no_fsmonitor_program_not_even_one_named_false() + { + // `core.fsmonitor=false` disables the monitor only on git 2.36+; older git reads the value as a program and runs + // `false` from PATH on every index refresh. The empty value the hardening uses disables it on every version — a + // PATH shim named `false` must never run. + if (OperatingSystem.IsWindows()) return; + if (!await GitAvailableAsync()) return; + + using var ctx = new HookVectorContext(); + await ctx.SeedBareRemoteAsync(); + await using var handle = await ctx.CloneWithTokenAsync(); + var shim = ctx.PlantPathShim("false"); + await ctx.WriteAgentChangeAsync(handle.Directory); + + var spec = AgentCloneGitCommand.Build(new[] { "add", "-A" }, handle.Directory, Array.Empty(), 60); + var result = await new LocalProcessRunner().RunAsync(spec with { Environment = new Dictionary { ["PATH"] = shim + ":" + Environment.GetEnvironmentVariable("PATH") } }, CancellationToken.None); + + result.Status.ShouldBe(SandboxStatus.Success, result.Stderr); + ctx.FiredVectors().ShouldBeEmpty($"the hardened add -A started a program named `false` — see {ctx.MarkerPath}"); + } + + private static async Task GitAvailableAsync() + { + try { return (await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = new[] { "--version" }, TimeoutSeconds = 10 }, CancellationToken.None)).Status == SandboxStatus.Success; } + catch { return false; } + } + + /// A bare remote and the provider's clone of it, with plant helpers that write every vector's executable inside the clone's .git and have it append its vector name to . Records the specs the provider hands the runner. + private sealed class HookVectorContext : IDisposable + { + public const string Token = "fake-push-token-0123456789"; + public const string AgentFile = "agent-change.txt"; + public const string AgentContent = "produced by the agent\n"; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "cs-hookvec-" + Guid.NewGuid().ToString("N")); + private readonly string _bareRemote; + private readonly RecordingRunner _runner = new(); + private string? _clone; + + public HookVectorContext() + { + Directory.CreateDirectory(_root); + _bareRemote = Path.Combine(_root, "remote.git"); + Provider = new LocalGitWorkspaceProvider(new SandboxRunnerRegistry(new ISandboxRunner[] { _runner }), NullLogger.Instance); + } + + public LocalGitWorkspaceProvider Provider { get; } + + public string BranchName { get; } = "codespace/agent/" + Guid.NewGuid().ToString("N"); + + public IReadOnlyList RecordedSpecs => _runner.Specs; + + public string MarkerPath => Path.Combine(GitDir, "vector-fired"); + + private string GitDir => Path.Combine(_clone ?? throw new InvalidOperationException("clone first"), ".git"); + + private string CleanFilterRecordPath => Path.Combine(GitDir, "clean-filter-saw"); + + public IReadOnlyList FiredVectors() => File.Exists(MarkerPath) ? File.ReadAllLines(MarkerPath).Where(l => l.Length > 0).Distinct().ToList() : Array.Empty(); + + public string CleanFilterRecord() => File.Exists(CleanFilterRecordPath) ? File.ReadAllText(CleanFilterRecordPath) : ""; + + public async Task SeedBareRemoteAsync() + { + await GitAsync(_root, "init", "--bare", "-b", "main", _bareRemote); + + var seed = Path.Combine(_root, "seed"); + await GitAsync(_root, "clone", _bareRemote, seed); + await File.WriteAllTextAsync(Path.Combine(seed, "README.md"), "base\n"); + await GitAsync(seed, "add", "."); + await GitAsync(seed, "-c", "user.name=Test", "-c", "user.email=test@codespace.dev", "-c", "commit.gpgsign=false", "commit", "-m", "seed"); + await GitAsync(seed, "push", "origin", "main"); + } + + public async Task CloneWithTokenAsync() + { + var handle = await Provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest { RepositoryUrl = new Uri(_bareRemote).AbsoluteUri, Token = Token, TokenUsername = "x-access-token" }), CancellationToken.None); + _clone = handle.Directory; + return handle; + } + + public Task WriteAgentChangeAsync(string cloneDir) => File.WriteAllTextAsync(Path.Combine(cloneDir, AgentFile), AgentContent); + + /// Plant one vector into the clone exactly as a tampering agent could between the clone and the platform's capture. + public async Task PlantAsync(string vector, string cloneDir) + { + switch (vector) + { + case "hooksPath-redirect": + var redirect = Path.Combine(GitDir, "agent-hooks"); + WriteScript(Path.Combine(redirect, "pre-commit"), vector, "exit 1"); // a veto in the agent-chosen hooks dir + await GitAsync(cloneDir, "config", "core.hooksPath", redirect); + break; + + case "fsmonitor": + await GitAsync(cloneDir, "config", "core.fsmonitor", WriteScript(Path.Combine(GitDir, "fsmonitor.sh"), vector, "exit 0")); + break; + + case "diff-textconv": + await File.WriteAllTextAsync(Path.Combine(cloneDir, ".gitattributes"), "* diff=agent\n"); + await GitAsync(cloneDir, "config", "diff.agent.textconv", WriteScript(Path.Combine(GitDir, "textconv.sh"), vector, "cat \"$1\"")); + break; + + case "diff-external": + await GitAsync(cloneDir, "config", "diff.external", WriteScript(Path.Combine(GitDir, "extdiff.sh"), vector, "exit 0")); + break; + + case "diff-driver-command": + await File.WriteAllTextAsync(Path.Combine(cloneDir, ".gitattributes"), "* diff=agent\n"); + await GitAsync(cloneDir, "config", "diff.agent.command", WriteScript(Path.Combine(GitDir, "driverdiff.sh"), vector, "exit 0")); + break; + + case "pre-auto-gc": + // A second pack + gc.autoPackLimit=1 makes the next commit run `gc --auto`, synchronously. Git 2.54+ + // defaults auto maintenance to the geometric strategy, which skips the gc task, so the agent turns it + // back on with maintenance.gc.enabled, which overrides the strategy and which older git honours too. + await GitAsync(cloneDir, "-c", "user.name=Agent", "-c", "user.email=agent@example.test", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "agent commit"); + await GitAsync(cloneDir, "repack", "-q"); + foreach (var (key, value) in new[] { ("gc.auto", "1"), ("gc.autoPackLimit", "1"), ("gc.autoDetach", "false"), ("maintenance.autoDetach", "false"), ("maintenance.gc.enabled", "true") }) + await GitAsync(cloneDir, "config", key, value); + WriteScript(Path.Combine(GitDir, "hooks", vector), vector, "exit 0"); + break; + + default: // a client hook by name; pre-commit vetoes to prove a bypassed veto never loses the branch + WriteScript(Path.Combine(GitDir, "hooks", vector), vector, vector == "pre-commit" ? "exit 1" : "exit 0"); + break; + } + } + + /// A repo clean filter that records its argv + environment and passes content through. + public async Task PlantCleanFilterAsync(string cloneDir) + { + await File.WriteAllTextAsync(Path.Combine(cloneDir, ".gitattributes"), "* filter=agent\n"); + var script = Path.Combine(GitDir, "clean.sh"); + File.WriteAllText(script, $"#!/bin/sh\n{{ echo \"argv: $*\"; env; }} >> '{CleanFilterRecordPath}'\ncat\n"); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + await GitAsync(cloneDir, "config", "filter.agent.clean", script); + } + + /// A directory inside the clone's .git holding one executable named that records its name when run; returns the directory for PATH. + public string PlantPathShim(string program) + { + var shim = Path.Combine(GitDir, "path-shim"); + WriteScript(Path.Combine(shim, program), program, "exit 1"); + return shim; + } + + /// The argv the platform ran over the clone before the hardening, run as plain git. Failures are ignored: a veto aborting the commit still fired. + public async Task RunUnhardenedCaptureAndCommitAsync(string cloneDir, string baseSha) + { + var steps = new[] + { + new[] { "add", "-A" }, + new[] { "diff", "--cached", "--no-color", baseSha }, + new[] { "diff", "--cached", "--name-only", baseSha }, + new[] { "diff", "--cached", "--numstat", baseSha }, + new[] { "checkout", "-B", BranchName }, + new[] { "add", "-A" }, + new[] { "-c", "commit.gpgsign=false", "-c", "user.name=CodeSpace", "-c", "user.email=agent@codespace.local", "commit", "-m", $"Agent run {BranchName}" }, + }; + + foreach (var args in steps) + await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = args, WorkingDirectory = cloneDir, TimeoutSeconds = 60 }, CancellationToken.None); + } + + public async Task RemoteFileAsync(string branch, string file) => await GitAsync(_root, "--git-dir", _bareRemote, "show", $"{branch}:{file}"); + + /// Write an executable shell script that appends to the marker, then runs ; returns its path. + private string WriteScript(string path, string name, string tail) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, $"#!/bin/sh\necho '{name}' >> '{MarkerPath}'\n{tail}\n"); + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return path; + } + + /// Test-side git with hooks off, so planting never fires a vector early. + private static async Task GitAsync(string workdir, params string[] args) + { + var result = await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = new[] { "-c", "core.hooksPath=/dev/null" }.Concat(args).ToList(), WorkingDirectory = workdir, TimeoutSeconds = 60 }, CancellationToken.None); + + if (result.Status != SandboxStatus.Success) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}"); + + return result.Stdout; + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { /* best-effort */ } + } + + /// Records every spec the provider submits, then runs it on the real local runner. + private sealed class RecordingRunner : ISandboxRunner + { + private readonly LocalProcessRunner _inner = new(); + public string Kind => "local"; + public List Specs { get; } = new(); + public Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + return _inner.RunAsync(spec, cancellationToken); + } + } + } +} diff --git a/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs index a1f73915a..71165661e 100644 --- a/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs @@ -13,6 +13,8 @@ using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using System.Diagnostics; +using System.Net; +using System.Net.Sockets; namespace CodeSpace.SandboxTests; @@ -253,6 +255,38 @@ public async Task Explicit_local_source_is_read_only_and_siblings_stay_invisible await Should.ThrowAsync(() => provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(request with { LocalSource = null }), CancellationToken.None)); } + [KernelFact] + public async Task A_repo_clean_filter_runs_during_capture_with_its_egress_severed() + { + // The one agent-plantable git vector the capture cannot switch off: a repo filter..clean runs on + // `add -A`. The capture spec declares no network, so under bubblewrap the filter cannot reach a host listener. + BubblewrapSandbox.Available.ShouldNotBeNull(); + await using var origin = new GitHttpFixture(); + await origin.StartAsync(); + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var provider = NewProvider(new RecordingRunner()); + await using var handle = await provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest { RepositoryUrl = origin.Url, Token = "fixture-only-token" }), CancellationToken.None); + var verdicts = Path.Combine(handle.Directory, ".git", "clean-filter-egress"); + var filter = Path.Combine(handle.Directory, ".git", "clean.sh"); + // Python prints the verdict itself, so only a connection the kernel refused reads as "severed"; a probe that + // could not run at all (no python, a crash) writes "probe-failed" and fails the assertion below. + await File.WriteAllTextAsync(filter, $"#!/bin/sh\n/usr/bin/python3 -c \"import socket\ntry:\n socket.create_connection(('127.0.0.1', {port}), timeout=2).close()\n print('reached')\nexcept OSError:\n print('severed')\" >> '{verdicts}' 2>/dev/null || echo probe-failed >> '{verdicts}'\ncat\n"); + File.SetUnixFileMode(filter, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + await File.WriteAllTextAsync(Path.Combine(handle.Directory, ".gitattributes"), "*.txt filter=probe\n"); + await GitHttpFixture.GitAsync(handle.Directory, new[] { "config", "filter.probe.clean", filter }); + await File.WriteAllTextAsync(Path.Combine(handle.Directory, "produced.txt"), "artifact\n"); + + var capture = await handle.CaptureChangesAsync(CancellationToken.None); + + capture.Patch.ShouldContain("+artifact", customMessage: "the filter passes content through, so the capture is intact"); + var seen = await File.ReadAllLinesAsync(verdicts); + seen.ShouldNotBeEmpty("the clean filter runs on add -A: accepted, not closed"); + seen.ShouldAllBe(verdict => verdict == "severed", "the capture spec declares no network, so the kernel must refuse the filter's connection"); + listener.Pending().ShouldBeFalse("nothing inside the capture reached the host listener"); + } + private static LocalGitWorkspaceProvider NewProvider(ISandboxRunner runner) => new(new SandboxRunnerRegistry(new[] { runner }), NullLogger.Instance); private sealed class RecordingRunner : ISandboxRunner diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentCloneGitCommandTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentCloneGitCommandTests.cs new file mode 100644 index 000000000..dfac5dc51 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentCloneGitCommandTests.cs @@ -0,0 +1,64 @@ +using CodeSpace.Core.Services.Agents.Workspace; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +/// +/// — the pure spec builder (no git, no runner) for every command the platform runs +/// over an agent-writable clone. Pins the hardening argv exactly, so a dropped or re-spelled flag is a visible test +/// failure rather than a silent re-opening of the hook / fsmonitor / textconv / external-diff vectors the integration +/// tests (AgentCloneGitHookIsolationFlowTests) prove closed against real git. +/// +[Trait("Category", "Unit")] +public sealed class AgentCloneGitCommandTests +{ + private static readonly string[] Hardening = { "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=" }; + + [Fact] + public void The_hardening_values_are_pinned_literally() + { + // /dev/null, not an empty value: git resolves `core.hooksPath=` to the filesystem root (`/pre-commit`), while + // `/dev/null/` can never exist. An empty fsmonitor, not `false`: git before 2.36 reads core.fsmonitor only + // as a program path, so `false` runs a PATH-resolved `false` on every index refresh (seen on git 2.33). + AgentCloneGitCommand.HardeningConfig.ShouldBe(Hardening); + } + + [Theory] + [InlineData("add", "-A")] + [InlineData("checkout", "-B", "codespace/run")] + [InlineData("rev-parse", "HEAD")] + [InlineData("-c", "commit.gpgsign=false", "-c", "user.name=CodeSpace", "commit", "-m", "Agent run")] + public void A_non_diff_command_gets_only_the_config_prefix(params string[] args) + { + // A commit starts with its own -c identity flags, not the subcommand: the hardening still goes first and no + // diff-only flag is added. + AgentCloneGitCommand.HardenedArgs(args).ShouldBe(Hardening.Concat(args)); + } + + [Theory] + [InlineData("--cached", "--no-color", "BASE")] + [InlineData("--cached", "--name-only", "BASE")] + [InlineData("--cached", "--numstat", "BASE")] + [InlineData("--quiet", "BASE", "HEAD")] + public void A_diff_gets_the_driver_suppressions_right_after_the_subcommand(params string[] diffArgs) + { + var args = new[] { "diff" }.Concat(diffArgs).ToArray(); + + AgentCloneGitCommand.HardenedArgs(args).ShouldBe(Hardening.Concat(new[] { "diff", "--no-ext-diff", "--no-textconv" }).Concat(diffArgs)); + } + + [Fact] + public void Build_severs_the_network_and_carries_no_credential() + { + var spec = AgentCloneGitCommand.Build(new[] { "add", "-A" }, "/work/clone", new[] { "/ro/source" }, 120); + + spec.Command.ShouldBe("git"); + spec.Args.ShouldBe(Hardening.Concat(new[] { "add", "-A" })); + spec.AllowNetwork.ShouldBeFalse("an agent-writable .git never meets the host network"); + spec.EgressAllowlist.ShouldBeNull(); + spec.Environment.ShouldBeEmpty("no clone token, askpass or credential-helper value reaches this git"); + spec.WorkingDirectory.ShouldBe("/work/clone"); + spec.ReadOnlyPaths.ShouldBe(new[] { "/ro/source" }); + spec.TimeoutSeconds.ShouldBe(120); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs index 0d4b3aed8..0d1dfe658 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs @@ -461,6 +461,69 @@ await Should.ThrowAsync(async () => [Fact] public void Kind_is_local() => NewProvider().Kind.ShouldBe("local"); + // ─── Git-command routing: agent-clone hardening vs remote access ────────── + + [Theory] + [InlineData(false)] // the platform commits the agent's edits + [InlineData(true)] // the agent committed itself: "nothing to commit", then `diff --quiet` decides the push + public async Task Every_git_command_after_the_agent_turn_runs_hardened_except_the_push_and_its_readback(bool agentCommittedItself) + { + // Everything the provider runs over the clone AFTER the agent's turn — capture, re-attach capture, checkout, + // add, commit, diff --quiet, rev-parse — must be hooks/fsmonitor-suppressed, off the network and credential-free. + // Only the authenticated push and its ls-remote readback still carry the token and the network. + var runner = new PostTurnRunner(agentCommittedItself); + var provider = new LocalGitWorkspaceProvider(new SandboxRunnerRegistry(new[] { runner }), NullLogger.Instance); + const string token = "fixture-token"; + + await using var handle = await provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest { RepositoryUrl = "https://example.test/repo.git", Token = token }), CancellationToken.None); + var prepared = runner.Specs.Count; + + await handle.CaptureChangesAsync(CancellationToken.None); + await provider.CaptureChangesFromPathAsync(handle.Directory, handle.Repositories.Single().BaseSha!, CancellationToken.None); + (await ((IWorkspacePushHandle)handle).PushChangesAsync("codespace/run", CancellationToken.None)).ShouldBe("codespace/run"); + + var postTurn = runner.Specs.Skip(prepared).ToList(); + static bool ReachesTheRemote(SandboxSpec s) => s.Args.Contains("push") || s.Args.Contains("ls-remote"); + var remote = postTurn.Where(ReachesTheRemote).ToList(); + var local = postTurn.Where(s => !ReachesTheRemote(s)).ToList(); + + remote.Count.ShouldBe(2, "exactly one push and one ls-remote readback reach the remote"); + remote.ShouldAllBe(s => s.AllowNetwork && s.Args.Any(a => a.Contains(token))); + + foreach (var spec in local) + { + var argv = string.Join(' ', spec.Args); + spec.Args.Take(AgentCloneGitCommand.HardeningConfig.Count).ShouldBe(AgentCloneGitCommand.HardeningConfig, argv); + spec.AllowNetwork.ShouldBeFalse(argv); + spec.Environment.ShouldBeEmpty(argv); + spec.Args.ShouldNotContain(a => a.Contains(token), argv); + spec.WorkingDirectory.ShouldBe(handle.Directory, argv); + } + + var subcommands = local.Select(s => s.Args.Skip(AgentCloneGitCommand.HardeningConfig.Count).First(a => !a.StartsWith('-') && !a.Contains('='))).Distinct().ToList(); + subcommands.ShouldBe(new[] { "add", "diff", "checkout", "commit", "rev-parse" }, ignoreOrder: true); + local.Count(s => s.Args.Contains("--quiet")).ShouldBe(agentCommittedItself ? 1 : 0, "`diff --quiet` runs only when the platform had nothing to commit"); + } + + /// Records every spec and answers success with a fixed 40-char sha; optionally answers the platform commit with "nothing to commit" and `diff --quiet` with "differs", as git does after an agent committed its own work. + private sealed class PostTurnRunner(bool agentCommittedItself) : ISandboxRunner + { + public string Kind => "local"; + public List Specs { get; } = new(); + public Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + + if (agentCommittedItself && spec.Args.Contains("commit")) + return Task.FromResult(new SandboxResult { Status = SandboxStatus.Failed, ExitCode = 1, Stdout = "nothing to commit, working tree clean", Stderr = "" }); + + if (agentCommittedItself && spec.Args.Contains("--quiet")) + return Task.FromResult(new SandboxResult { Status = SandboxStatus.Failed, ExitCode = 1, Stdout = "", Stderr = "" }); + + return Task.FromResult(new SandboxResult { Status = SandboxStatus.Success, ExitCode = 0, Stdout = new string('a', 40), Stderr = "" }); + } + } + // ─── Change capture ────────────────────────────────────────────────────── // ── S1: PinnedSha — the immutable-base substrate ─────────────────────────────────