Skip to content

F56 — the private clone is the real boundary - #121

Merged
munchist merged 8 commits into
mainfrom
aflow/f56-private-clone
Oct 16, 2026
Merged

munchist merged 8 commits into
mainfrom
aflow/f56-private-clone

Conversation

@munchist

Copy link
Copy Markdown
Contributor

Fix round on F56, closing the review's reproduced blocker and two minors:

  • The blocker — the clone's git directory lived inside the sandbox's writable checkout as a plain directory, and the sandbox policy denies writes only to .git/config and .git/hooks, so a coding agent could write .git/commondir pointing at a repository it built in its scratch whose config defines a filter.<x>.clean, add a .gitattributes naming the filter, and the executor's own unsandboxed git (collectChanges, checkApplies) would read that config through commdir and run the agent's program with the executor's PATH and HOME. The clone's git directory now lives where the sandbox cannot write — git clone --separate-git-dir into the executor's own run scratch outside the writable root, only a gitfile in the checkout — and every git the executor runs in the checkout after the run names that directory itself (--git-dir/--work-tree or GIT_DIR/GIT_WORK_TREE), never resolving through the gitfile the agent could rewrite; the harness's git inside the sandbox may follow the gitfile, since a rewritten gitfile only moves the agent's own git. The comment claiming the clone's config cannot be written is rewritten to say what is true.
  • The test — plants a rewritten .git gitfile, a .git/commondir, and a .gitattributes filter backed by an agent-built repository, and asserts that collection and the apply check run no program and read the executor's git directory; the reviewer's reproduction steps are its shape.
  • Minors — packages/platform-artifacts/src/skillCatalog/commissionChange.ts catalog version becomes 4 (the refChanges promotion, state variable and guidance restored), and the lane's readiness regains a minimum git version at 2.29 whose message names --no-write-fetch-head, in the shape the deleted check had.

…F56)

prepareWorktree clones the folder into a private checkout — own .git,
own refs, alternates borrowing the folder's objects, detached at the
base commit — instead of git worktree add --detach from the root, so a
git update-ref, git branch -D, git push <folder-path> or a hooks-path
override inside the run can reach only the clone's refs.

The ref guard and the before-and-after ref snapshot are removed rather
than kept beside the clone (no compatibility layer): installRefGuard
and the snapshotRefs/changedRefs pair go away, and refChanges leaves
the result shape.

The publication reads the run's commit from the clone: the executor's
own git fetches the result commit from the clone into the folder under
the branch the publication names (branchCommit.ts) — the one write to
the folder's refs the loop makes, made by the executor and never by
the harness. The clone is removed as the worktree is today
(removeWorktree and the orphan sweep in orphans.ts).

Tests state the properties: a run whose harness pushes into the
folder's path, deletes a branch and overrides core.hooksPath leaves
every ref of the folder as it was, with no hook involved; the
publication of such a run lands exactly the commit the run made on the
named branch; a clone is removed when the run ends and swept when it
is orphaned.
…t write

The clone's git directory moves out of the sandbox's writable checkout
(git clone --separate-git-dir into the executor's own run scratch, only
a gitfile in the checkout), and every git the executor runs in the
checkout after the run names that directory itself (--git-dir and
--work-tree, or GIT_DIR/GIT_WORK_TREE) and never resolves through the
gitfile the agent could rewrite; the harness's git inside the sandbox
may follow the gitfile, since a rewritten gitfile only moves the
agent's own git. The comment at worktree.ts claiming the clone's
config cannot be written is rewritten to say what is true.

A sandboxed test plants a rewritten .git gitfile, a .git/commondir and
a .gitattributes filter backed by an agent-built repository, and
asserts that collection and the apply check run no program and read
the executor's git directory.

Minors: the commissionChange catalog version becomes 4 (the refChanges
promotion, state variable and guidance restored), and the lane's
readiness regains a minimum git version at 2.29 whose message names
--no-write-fetch-head, in the shape the deleted check had.
…, and the sweep decides by the scratch's marker

clonePrivately ran `git clone -- <root>`, which accepts only a
repository's top level or its .git, while isGitRepository also accepts
a subdirectory of a repository — so a folder connected as a
subdirectory of a repository (a package in a monorepo) passed the
repository check and then failed every commission, folder check and
publication with "Could not create a checkout". The clone is now made
from the repository's top level (rev-parse --show-toplevel), and the
run's working directory is the folder's path relative to that top
level inside the clone, so the harness, the checks, collectChanges and
the publication all see the folder where the operator connected it. A
test connects a subdirectory and gets a checkout.

The boot sweep's clonesIn read a missing or symbolic HEAD in a scratch
git directory as the operator's and skipped that scratch for good,
leaving behind a clone from a run killed between the gitfile write and
the detached checkout and any worktree an earlier executor left there.
Whether a directory is the executor's is now decided by the scratch's
own naming and marker, never by the shape of HEAD. A test plants a
half-made clone and a stale worktree in an executor-named scratch and
the sweep removes both.

Note on the prior fix commit 8d82b20: its message says Commission
Change's refChanges promotion, state variable and guidance are
"restored"; the range removes all three and that commit only bumps
the version. This commit does not repeat the claim.
…and the mirror walks to the top level

(a) The boot sweep and removeWorktree followed a symlink entry of a
marked scratch — checkoutsIn resolved <entry>/.git through it — so the
sweep could delete the symlinks inside a node_modules outside the
scratch, the operator's own project included, and an agent could plant
such a symlink in the scratch it writes. The sweep and the removal now
act only on a real directory (lstat, never stat), refuse a symlink
entry, and unlinkDependencies resolves nothing outside the checkout it
was given. A test plants a symlink entry in a marked scratch pointing
at a directory with symlinked node_modules and asserts the sweep
leaves that directory untouched.

(b) For a folder connected as a subdirectory of a repository, the
dependency mirroring linked only the folder's own node_modules;
hoisted dependencies in the repository's top-level node_modules were
never linked and links to sibling workspace packages resolved outside
the folder. The mirroring now walks from the folder up to the
repository's top level and links every node_modules it finds at the
same relative position in the clone, so resolution inside the clone
sees what it sees in the folder. A test with a top-level and a
package-level node_modules asserts both are linked.

(c) The boot sweep took only scratch carrying .aflow-scratch, a marker
no executor before this range wrote, so scratch left by the previous
executor and the worktrees it registered in operators' repositories
were never removed. The sweep now also prunes, for every connected
folder, the worktrees `git worktree list --porcelain` reports whose
path lies under the executor's scratch root — the only place the
previous executor ever put one — and the comment says exactly what is
swept.
…andbox

The run's scratch held the checkout `work` and `git-work` inside the
sandbox's writable set, and only the git directory and
<writableRoot>/.git/{config,hooks} were denied — so
`rm -rf <scratch>/work && ln -s ~/.ssh <scratch>/work` was permitted,
and the executor's unsandboxed git, naming --work-tree=<checkout.path>
explicitly, accepted the symlinked directory as its work tree:
collectChanges staged that directory's contents into the returned diff
and mergeIntoCheckout wrote into it.

Two parts, both needed:

1. The directory that holds the checkout is never writable by the
   sandbox: the writable set is the checkout itself and a scratch
   subdirectory of its own for the agent, and the parent holding `work`
   and `git-work` is denied, so the checkout cannot be replaced from
   inside.
2. Every executor git on a Checkout first proves, in one shared place
   (gitLocation), that checkout.path and workDir are real directories
   by lstat whose realpath is the path recorded at prepareWorktree, and
   refuses otherwise — collectChanges, mergeIntoCheckout and
   checkApplies all go through it.

A sandboxed test shows the policy refusing the replacement, and a
second test plants the symlink from outside the policy and asserts
collection and merge refuse.

Docs: D18 no longer lists refChanges among Commission Change's
promoted outputs, and the F56 entry names the separate git directory
beside the checkout instead of <checkout>/.git.
…covers the borrowed objects and the linked modules

For a folder connected as a subdirectory of a repository under the
operator's home, the sandbox re-allowed reading only binding.root, the
scratch, the writable root and the git directory, while the --mirror
--shared clone owns no objects and borrows every one through
objects/info/alternates naming <top>/.git/objects, and the mirrored
top-level node_modules links point into <top>/node_modules — both
under the denied home — so inside the run the harness's git could not
read HEAD's tree and the hoisted dependencies did not resolve.

When the folder is a subdirectory, the sandbox's read-only widening
now names the repository's object store (<top>/.git/objects) and every
node_modules the mirroring linked, for the commission and the folder
checks alike; and the sandbox policy states the whole boundary in one
place — for a top-level folder and for a subdirectory, what the run
may read and what it may write, enumerated — with a test over that
enumeration. A sandboxed test connects a subdirectory of a repository
under a denied home and, inside the run, runs git log -1 and resolves
a hoisted package.

The boot sweep's loop for the previous executor's registered worktrees
(removeLinkedWorktree) goes — the migration bridge CLAUDE.md forbids,
half-cleaning anyway, leaving the unmarked scratch around each removed
worktree forever. The sweep handles marked scratch only, the doc
comment says so, the operator prunes the old executor's worktrees once
by hand, and the F56 entry in the plan says that in one sentence.

The git 2.29 floor moves to where the flag is used: commitPatchOnBranch
checks it before fetchFromClone, with its message, and the check
leaves the start of every harness run.
… location, not by prefix

borrowedReads returned nothing whenever the folder is a repository's
top level, on the reasoning that a top-level folder's object store is
inside the folder — false when the folder is a linked worktree (which
this repository's own conventions encourage), a submodule, or a
repository with a separate git dir: the clone's alternates then name
an object store outside the folder, under the denied home, and the
harness's git cannot read a single object. The decision is now made
by location, never by prefix: git rev-parse --git-path objects
resolved to its realpath, reading widened to it whenever it lies
outside binding.root, for the commission and the folder checks alike;
the doc comment says that, and the sandboxed boundary test gains a
case where the connected folder is a linked worktree under the denied
home and git log -1 succeeds inside the run.

For a subdirectory folder, linkDependencies mirrored the folder's own
node_modules and those on the path up to the top level, but workspace
links resolve into sibling packages in the clone that have no
node_modules, so a sibling's non-hoisted dependencies did not resolve
inside the run. For a subdirectory folder the dependency mirroring
now runs from the repository's top level exactly as it does for a
top-level folder, so every installation in the repository is mirrored
and readable; the worktreeDependencies test gains a sibling package
with its own node_modules that resolves inside the clone.
… the walk, whatever the depth

Since e6c2d12, linkDependencies and borrowedReads found installations
only through dependencyDirs(top), which stops at DEPENDENCY_SCAN_DEPTH
3, so a folder connected three or more directories below the top
(packages/group/pkg) got neither its own node_modules nor any
installation below that depth mirrored or made readable — the walk up
from the folder that 8a242f4 had was lost, and the comment promising
every installation whichever folder is connected was false.

The set of installations is now the union of the scan from the top and
the walk from the connected folder up to the top, so the folder's own
installation and those on its path are always in it whatever the
depth, and borrowedReads reads the same set; the comment says that;
the worktreeDependencies test connects a folder three levels down and
resolves its own dependency inside the clone.

The journal records the outer scratch — passed to recordSpawn
separately from the sandbox's agent/ subdirectory — so a crashed run's
clone goes with its run as the plan says, with a test; reapOrphans
removing only <scratch>/agent no longer leaves the clone to the boot
sweep.

D18a drops refChanges from Commission Change's promoted outputs and
its guidance — 5970b1a corrected line 53 and not this one.
@munchist
munchist merged commit 7f2547f into main Oct 16, 2026
3 checks passed
@munchist
munchist deleted the aflow/f56-private-clone branch October 16, 2026 20:34
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