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 ─────────────────────────────────