Skip to content

session: a worker may not replace the git metadata of the folder it was given (#1351) - #2097

Open
7vignesh wants to merge 2 commits into
Agent-Field:devfrom
7vignesh:fix/1351-worker-git-metadata-boundary
Open

7vignesh wants to merge 2 commits into
Agent-Field:devfrom
7vignesh:fix/1351-worker-git-metadata-boundary

Conversation

@7vignesh

@7vignesh 7vignesh commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

What changed

A codeaf do --dir <repo> worker ran rm -rf <repo>/.git, then git init and git commit in the parent, and the repository's history was gone (#1351). On current dev the folder guard already refused the parent half, because the parent is outside the worker's ground. It allowed the deletion: a repository's .git sits inside the tree it belongs to, so refusedOutsideGround(ground, "rm -rf .git") returned no refusal, which is the gap @santoshkumarradha's audit found. The absolute spelling rm -rf <repo>/.git was stopped only by accident, by the critical-command floor's rm -rf /* pattern, which catches every absolute recursive delete and knows nothing about .git. The relative spelling a worker standing in its folder uses got through every check.

internal/session/taskoutside.go:

  • replacedGroundMetadata refuses any command that removes, moves, overwrites or rewrites the ground's own .git, or removes the ground itself (rm -rf .), with the boundary's existing is outside your copy sentence. It asks the same hands the path law already reads: the file-writing commands, in-place sed/perl, and redirections (echo ... > .git/HEAD).
  • namesGroundMetadata compares both the written and the symlink-resolved spelling, so a link to the ground or to its .git is not a way around it. I found that bypass while reviewing the first version.
  • Git's own verbs are untouched. git commit, checkout and reset write inside .git on the task's behalf and are answered by the git guard as before. Reading .git, .gitignore, .github, a nested vendor/x/.git, and every ordinary write still pass. A ground with no .git is unchanged.
  • The redirection loop moved into redirectionAimedOutside to keep outsideScopeAim under the endings ceiling (TestNoRoadInTheTaskEngineHasMoreEndingsThanItsLedgerRow).

The manual pages the issue names (permissions.md, choosing-a-folder.md) and how-tasks-run.md now explain the rule and quote the refusal.

How it was checked

internal/session does not test-build on my Windows box (internal/seniordev/engine/steploop is //go:build !windows), so everything below ran in a golang:1.26 Linux container, on a clean LF checkout with the patch applied.

  • e2e through the real headless door (cmd/codeaf/do_git_metadata_test.go, the issue's first two acceptance bullets): doErrand on the run engine, with a scripted worker sending the three recorded commands in a nested repository placed outside the scratch directories (a parent under the temp directory would be let through by the scratch exemption and test nothing). The repo's .git survives, HEAD does not move, the parent never gets a .git, and all three commands are answered with is outside your copy. The control writes add.py and test_add.py inside the folder, gets no refusal, and leaves the parent unchanged.
  • Unit (taskoutside_test.go): every refused spelling (absolute, relative, ./.git, .git/, after cd, cd sub && rm -rf ../.git, partial deletes, mv, cp -r, rsync --delete, sed -i, redirection, rm -rf ., and the four symlinked spellings) plus the work that must still pass, and a plain folder unchanged. The existing path-law tests are unchanged and pass.
  • Each new test fails with only the fix removed. Reverting taskoutside.go makes the e2e test fail with "the repository's .git is gone after the run". Removing only the symlink resolution makes the four symlink cases fail.
  • Full suites: cmd/codeaf ok; internal/manual ok; make test-packed-manual ok; go test -run Manual ./internal/tui3/ ./internal/session/ ok. internal/session fails exactly two tests, TestTheDailyCeilingBindsTaskAndErrandTurnsToo and TestTheBaselineIsBoundedByTheEntryCap, and clean dev fails the same two in the same container, so this change adds none.
  • gofmt, go vet (GOOS=linux) and git diff --check are clean.

Not done: the issue's third e2e bullet (a stub worker through the tmux suite) and its note that codeaf do should exit nonzero. Here the run continues after the refusals and the scripted worker finishes its task. I left the exit code as it is, since a worker that reads a refusal and carries on is the behaviour the rest of this file is built around.

Checklist

  • A change entry - docs/changes/unreleased/2097-worker-keeps-folder-git-metadata.md, whose invalidates names the old belief that --dir alone protected a repository's history.
  • The manual knows about it - a new refusal, so permissions.md, choosing-a-folder.md and how-tasks-run.md say what is refused and quote the sentence; TestTheGitMetadataQuestionsReachTheSectionsThatRefuseIt asserts the questions reach those sections.
  • Only my own paths are staged.

Closes #1351.

…as given (Agent-Field#1351)

A codeaf do --dir worker ran `rm -rf <repo>/.git`, then `git init` and
`git commit` in the parent, and the repository's history was gone. The
folder guard already refused the parent half, because the parent is
outside the worker's ground. It allowed the deletion, because a
repository's .git sits inside the tree it belongs to, so the path law read
`rm -rf .git` as ordinary work in the folder. The absolute spelling was
only stopped by accident, by the critical-command floor's `rm -rf /*`.

The guard now refuses any command that removes, moves, overwrites or
rewrites the ground's own .git, or removes the ground itself, with the
boundary's existing `is outside your copy` sentence. It asks the same hands
the path law reads (the file-writing commands, in-place sed/perl and
redirections), on both the written and the symlink-resolved spelling, so a
link to the ground or to its .git is not a way around it. Git's own verbs
are untouched: git commit, checkout and reset write inside .git on the
task's behalf and are answered by the git guard as before. A plain folder
with no .git is unchanged. The redirection check moves into its own
function to keep outsideScopeAim under the endings ceiling.

Tests: unit cases for every refused spelling (including symlinks and
walking back out of a subdirectory) and for the work that must still pass,
and two end-to-end tests through the real headless door with a scripted
worker sending the three recorded commands in a nested, non-scratch
repository. Each new test fails with the fix removed. The manual pages the
issue names now quote the refusal, with a retrieval test.

@AbirAbbas AbirAbbas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. The core fix works. On the head, every spelling from #1351 is refused with is outside your copy: relative and absolute rm -rf .git, rm -rf ., mv .git <x> and > .git/HEAD. Links to the folder or to its .git are refused too. The focused tests (./internal/session -run 'Outside|Ground|Metadata|Endings|Ledger', ./cmd/codeaf -run GitMetadata, ./internal/manual) passed 5 runs out of 5 with no flakes. Two things need changing before this can land.

1. Two spellings still replace .git through the worker's real tool path (Agent.executeTool, repository-shaped fixture):

{"command":"rm -rf .g?t",            "is_error":false, "HEAD_missing":true}
{"command":"cp -t .git payload/HEAD", "is_error":false, "HEAD_text":"broken\n"}
  • taskoutside.go:566-568: for writesItsLastOperand programs only the last operand is kept, so a destination given as -t .git / --target-directory=.git is never compared. install -t .git payload/HEAD gets through the same way. Please take the -t/--target-directory value as the destination when it's present.
  • taskoutside.go:571: operands are compared before the shell expands them, so .g?t and .[g]it don't match. Expanding an operand with glob characters against cwd before the comparison would close this. So would refusing a glob that can match .git.

Neither is new (dev allows both), but both are the #1351 defect under another name. Please add regression tests that assert a refusal and an intact .git for each spelling.

2. Ordinary upkeep is now refused, and dev allows all of these:

command dev head
mkdir -p . allowed refused
chmod -R u+w . allowed refused
rm -f .git/index.lock allowed refused
printf 'scratch/\n' >> .git/info/exclude allowed refused
cp <hook> .git/hooks/pre-commit allowed refused

The first two come from namesGroundMetadata(..., holding=true) applying to every write-family program. "A path that holds .git" only matters for programs that remove or move (rm, rmdir, mv). mkdir and chmod on the folder don't take its history. For the last three, refusing writes inside .git may be what you intend. If it is, please say so in permissions.md, since removing a stale index.lock is a common recovery step. If it isn't, limit the rule to removing or replacing .git itself and its HEAD, objects/ and refs/.

Once these are in, this needs one more hand test on a built codeaf before it can merge, plus a run on macOS, because it changes how tasks run.

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.

A worker can replace Git metadata outside its given folder

2 participants