Skip to content

Publish agent work from a clean repository - #2072

Merged
ppXD merged 1 commit into
mainfrom
fix/publish-agent-work-from-a-clean-repository
Oct 6, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/publish-agent-work-from-a-clean-repository

Conversation

@ppXD

@ppXD ppXD commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • The problem. The branch push and its ls-remote readback ran inside the agent-writable clone, with the network on and the clone token in the argv. Vectors planted in that clone could redirect or steal the token: a pre-push hook, url.insteadOf / pushInsteadOf, http.proxy / http.extraHeader, credential.helper, core.askPass, and a work-tree .lfsconfig.
  • How the branch leaves the clone. It now leaves as two hardened, network-off, credential-free bundles, built with the clone bound read-only:
    • the cloned base
    • the objects the branch adds to it
  • Where the push runs. A fresh platform-owned repo outside the workspace imports the bundles, then runs the push, git lfs push and the readback (LocalGitWorkspaceProvider.PublishFromCleanRepoAsync). The agent's own commits are preserved.
  • Object checks.
    • Only the objects the branch adds are imported under transfer.fsckObjects.
    • The base is imported unchecked because the remote already holds it. Checking it would lose every branch of a repo whose history carries an object strict fsck rejects, such as a zero-padded file mode.
    • git checks a fetched bundle only from 2.46. Older git, including git 2.43 in the current worker image, imports unchecked and leaves the remote's own receive checks, as before.
  • Host-side copies. The .git/shallow and .git/lfs/objects copies run outside any sandbox:
    • They follow no link and open no FIFO or other special file.
    • They take only paths shaped like LFS objects.
    • A failure surfaces as a WorkspaceException, which every push caller handles.
  • LFS upload. git lfs push runs with -c lfs.locksverify=false, so git-lfs never writes the authed URL into the publish repo's config.
  • End-to-end check with a real agent. A real agent that plants a pre-push hook and an insteadOf redirect in its .git runs against the publish:
    • Codex runs in the root sandbox lane (AgentPublishIsolationE2ETests).
    • Claude runs in the non-root lane (NonRootWorkerE2ETests). The pinned Claude CLI refuses bypassPermissions, which is a Standard run's mode, to uid 0.

Test plan

  • Unit, LocalGitWorkspaceProviderTests routing Theory. Three rows: platform commit, agent self-commit, multi-repo. It checks:
    • credential and network never touch the clone: not as cwd, not as an argument, not as a bind
    • the publish repo and the bundle files sit outside the workspace root
    • only the branch import carries transfer.fsckObjects
    • both imports get the 300 s push budget
  • Unit: IsLfsObjectPath rows. Full UnitTests at the top of the stack: 11776 passed, 1 skipped.
  • Integration, AgentPublishFromCleanRepoFlowTests (14 cases), on git 2.33.0, 2.50.1 and 2.56.0 with git-lfs 3.7.1:
    • every planted vector stays silent, at depth 0 and 1
    • the shallow fixture check passes
    • LFS upload, with no token on disk at readback
    • a base reusing a legacy object still publishes
    • a malformed object is refused (git ≥ 2.46)
    • a branch reset behind its base still lands
    • planted links, a FIFO and stray files in the LFS store are ignored
    • a linked shallow boundary fails closed
    • an unreadable LFS object surfaces as WorkspaceException
    • a failed publish still removes its repo
  • Integration lane on CI runs git 2.55.0 and ran the malformed-object case instead of logging [publish-fsck] and returning early.
  • Integration, wide workspace / push / acceptance filter: 315/315 on git 2.33.0 and on git 2.50.1
  • Sandbox, Claude arm: the shared arm passed with Claude 2.1.263 on macOS, without confinement, called through a temporary local entry point. In CI it runs from NonRootWorkerE2ETests as uid 1654.
  • Sandbox, Codex arm: passed confined in the root lane on CI. It runs only under bubblewrap, so on macOS it returns early.
  • Workflow: sandbox-isolation.yml parses, and its embedded python blocks compile.
  • Mutation: each of 13 guards, reverted on its own, turns at least one test red. Guards: shallow copy, fsck, fsck scope, push cwd and args, cleanup, containment, locksverify, link-following, object-path filter, IO mapping, ancestor path, empty-file filter, shallow link.
  • Sandbox lane (Linux, bubblewrap):
    • root lane: ≥ 98 executed (main 92 + 4 goal channel + 1 capture + 1 here), with the Codex publish-isolation arm passing
    • non-root lane: ≥ 16 passed, including [publish-isolation-e2e] ran non-root claude-code uid=1654 confined=True
  • Cost: each publish attempt costs time and disk in proportion to the clone, not to the change. The base bundle and the LFS copy are both as large as the clone, and this repeats on each of up to 3 push attempts and on every revise round. Measured in review on a 335 MB repo with the earlier single-bundle form: about 2 s at depth 1 and about 18 s at depth 0, against about 0.3 s for the old in-clone push.

@ppXD
ppXD force-pushed the fix/keep-agent-git-hooks-out-of-capture branch from 8b87a80 to f68742f Compare October 6, 2026 11:04
@ppXD
ppXD force-pushed the fix/publish-agent-work-from-a-clean-repository branch from ee80f5f to 81250ad Compare October 6, 2026 11:04
@ppXD
ppXD changed the base branch from fix/keep-agent-git-hooks-out-of-capture to main October 6, 2026 16:30
After the agent's turn the branch push and its ls-remote readback still
ran inside the agent-writable clone, with the network on and the clone
credential re-injected into the argv. An agent that wrote its own .git
during the run could then redirect or exfiltrate the token: a planted
pre-push hook received the authed URL with working egress;
url.insteadOf / pushInsteadOf, http.proxy and http.extraHeader rerouted
or tampered the request; credential.helper and core.askPass ran code at
credential resolution; and a work-tree .lfsconfig could send LFS objects
elsewhere.

The branch now leaves the clone as two git bundles, built by the same
hardened, network-off, credential-free command the capture uses (the
clone bound read-only, the bundles written outside the workspace): the
cloned base, and the objects the branch adds to it. A fresh
platform-owned repository imports the base as is and the added objects
under transfer.fsckObjects, then runs the authenticated push, the LFS
upload and the readback, so the credential and the network never meet
the agent's .git. Only the added objects are checked because the remote
already holds the base: a repository whose history carries an object
strict fsck rejects, such as a zero-padded file mode, would otherwise
lose every branch. git checks a fetched bundle from 2.46; older git
imports it unchecked and leaves the remote's own receive checks, as
before. A branch reset behind its base adds no object and travels in
the base bundle.

The clean repo checks out nothing, so a committed .lfsconfig cannot
redirect LFS, whose endpoint comes from the explicit authed URL. Lock
verification is off for that upload: against a remote without the
locks API, git-lfs would write the authed URL, token included, into the
publish repo's config.

The shallow boundary and the LFS objects are copied on the host,
outside any sandbox, from paths the agent controlled. The copy reads
only real files inside the clone: it follows no link, opens no FIFO or
other special file, and takes only paths shaped like LFS objects. A
failure there surfaces as a WorkspaceException, which every caller of
the push handles, rather than a raw IO exception that would fail a run
that succeeded. Each attempt costs time and disk in proportion to the
clone, not to the change.

The agent's own commits are preserved. Revise rounds re-publish
idempotently, and the publish repo is removed whether the publish
succeeds or fails; a leak from a crash is reclaimed by the workspace
janitor.

A real agent that plants these vectors runs end to end against the
publish in the sandbox lanes: Codex in the root lane, and Claude in the
non-root lane, because the pinned Claude CLI refuses bypassPermissions,
a Standard run's mode, to uid 0.
@ppXD
ppXD force-pushed the fix/publish-agent-work-from-a-clean-repository branch from 81250ad to 0fa166e Compare October 6, 2026 16:30
@ppXD
ppXD merged commit a489a6c into main Oct 6, 2026
@ppXD
ppXD deleted the fix/publish-agent-work-from-a-clean-repository branch October 6, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant