Skip to content

fix(workspace): keep VCS internals and binary files out of the staging copy - #220

Open
dmmdea wants to merge 2 commits into
kunchenguid:mainfrom
dmmdea:fix/staging-skip-vcs-binaries
Open

dmmdea wants to merge 2 commits into
kunchenguid:mainfrom
dmmdea:fix/staging-skip-vcs-binaries

Conversation

@dmmdea

@dmmdea dmmdea commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Intent

The operator reported "backpass is blocked" and asked for an upstream fix PR. A user-scope run (backpass --scope user: analysis, then synthesis) could not finish. Synthesis died while preparing the staging workspace with "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory" (exit 134, after 479 s). prepareWorkspace walked, read and copied every file under every skill directory in a skills tree of about 1.7 GB. Several of those skills are their own git checkouts, with full .git object stores and pack files, and some ship compiled tool binaries (.exe). None of that is text an edit can apply to.

Goal: unblock the run with the narrow fix that the #75 triage points to. Content that is not the memory surface stays out of the staging copy, and nothing is hidden:

  1. Version-control internals (.git, .hg, .svn) under a skill directory are never walked, copied or read. Each one is named in unstageable with the reason READ_ONLY_VCS, so the existing contract holds: anything not staged is named and read-only, never silently dropped.
  2. Binary files are not copied. The test is git's own (xdiff buffer_is_binary): a NUL byte in the first 8000 bytes. Each binary file is named in unstageable with READ_ONLY_BINARY. A file that cannot be opened is not classified; the copy's existing error handling deals with it as before.
  3. Deliberately out of scope: no size cap, no ignore-list setting, no special case for node_modules. Text files there are still staged as before. No CHANGELOG.md or release-manifest edits, because release-please owns them. The README gets one sentence saying VCS internals and binary files stay out of the copy and are read-only.
  4. One test was added, because this failed for real. It stages a skill with its own .git pack and a NUL-bearing bin/tool.exe. It asserts that both stay out of the copy, that both are named in unstageable with their reasons, and that measureWorkspace reports no changes.

The real run of this branch, on the same store: backpass --scope user --limit 3 --jobs 1 completed analysis and synthesis end to end, exit 0 in 631 s. The proposal was "0 edit(s) from 3 session(s)": the evidence did not clear the thresholds, which is correct for that store. The staging copy held 24,596 text files (305 MB), with no VCS directories, no .exe files and no file containing a NUL byte. Before the fix, the same command died with the out-of-memory abort above.

Environment note for the test step: this host is Windows without Developer Mode. The existing workspace tests whose fixtures call fs.symlink fail in fixture setup with "EPERM: operation not permitted, symlink", before any code under test runs. That is a host privilege limit, independent of this change, and upstream CI on Linux runs those tests.

What Changed

  • walkFiles in src/workspace.js no longer descends into .git, .hg or .svn under a skill directory. Each one is named in unstageable with the new READ_ONLY_VCS reason. prepareWorkspace also skips binary files, which it detects with git's own test (a NUL byte in the first 8000 bytes), and names each in unstageable with the new READ_ONLY_BINARY reason. A file that cannot be opened is not classified and goes through the copy's existing error handling. There is no size cap, ignore-list setting or node_modules special case, so text files are staged as before.
  • Together these stop user-scope synthesis from walking, reading and copying a skills tree with nested git checkouts and compiled .exe tools, which ran out of heap and aborted staging (prepareWorkspace copies the entire skillsDir tree (incl. .git) — EACCES crash and multi-MB annotate prompts on large skill stores #75).
  • README.md gains one sentence saying VCS internals and binary files stay out of the staging copy and are read-only. test/workspace.test.js adds one test: a skill with its own .git pack and a NUL-bearing bin/tool.exe keeps both out of the copy, names both in unstageable with their reasons, and measureWorkspace reports no changes.

Risk Assessment

✅ Low: The change is a small, additive filter in staging (prune .git/.hg/.svn, skip files with a NUL in the first 8000 bytes). Both kinds of skipped entry are named in unstageable, and it matches the stated intent with no extra components. Tracing it through measureWorkspace, the read-only skill index and the repo fingerprint found no wrong-result path. The skipped entries never enter originals, so they cannot be measured as deleted. The one new test drives the real prepareWorkspace and measureWorkspace.

Testing

I ran the new regression test, which passes. I also drove the real prepareWorkspace and measureWorkspace on a disposable repo with realistic and adversarial skill content, on both the target and the base commit. On the target, .git, .hg and .svn are pruned and named READ_ONLY_VCS, and binaries are named READ_ONLY_BINARY. A NUL at byte 7999 is classified binary and a NUL at byte 8000 is not. The empty file, UTF-8 file and node_modules text file are still staged, and measureWorkspace reports no changes. The base commit staged all 29 nested .git files, the .exe and the binaries, and named nothing in unstageable. The full test/workspace.test.js run has 13 failures: 11 are the documented symlink EPERM and the other 2 also fail identically on the base commit. I did not run the full backpass --scope user end to end: it needs an agent and a 1.7 GB store, so the author's own 631 s run stands unverified here.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Skill with its own real git checkout (gc'd pack): the .git is never copied and is named READ_ONLY_VCS; the working files are still staged ✅ pass live drive-target.txt: .agents/skills/vendored/.git <- is version-control internals, not skill content; SKILL.md and f0..f19.txt staged. drive-base.txt: 29 files from .git (pack, idx, hooks) were stage…
.hg, .svn and a submodule-style .git file are also pruned and named ✅ pass live drive-target.txt lists .hg, .svn and sub/.git as READ_ONLY_VCS; sub/readme.txt is still staged.
A 3 MB .exe with a NUL byte is not copied and is named READ_ONLY_BINARY ✅ pass live drive-target.txt: bin/tool.exe <- is a binary file...; absent from STAGED. drive-base.txt staged it.
Binary test boundary is git's: a NUL at byte 7999 is binary, a NUL at byte 8000 is not ✅ pass live drive-target.txt: nul7999.bin unstageable as binary; nul8000.txt staged.
Out-of-scope behaviour is unchanged: node_modules text, an empty file and a UTF-8 file are still staged, and no size cap applies ✅ pass live drive-target.txt STAGED includes node_modules/pkg/index.js, edge/empty.txt and edge/utf8.md.
Files left out are never measured as deleted (measureWorkspace reports no changes), and the regression test passes ✅ pass live drive-target.txt: measure changes: []; node --test --test-name-pattern="object store and compiled tools" test/workspace.test.js passes (1/1).
Full backpass --scope user run on the author's 1.7 GB store completes without heap OOM ⏸️ untested no It needs the operator's real skills store and a live ACP agent (acpx plus model credentials) and would read real user data, which is off-limits here. The staging stage that caused the OOM was driven l…
Evidence: Driver output, target commit (fix applied)
ms: 156
STAGED:
.agents/skills/db/SKILL.md
.agents/skills/db/edge/empty.txt
.agents/skills/db/edge/nul8000.txt
.agents/skills/db/edge/utf8.md
.agents/skills/db/node_modules/pkg/index.js
.agents/skills/db/notes.txt
.agents/skills/db/sub/readme.txt
.agents/skills/vendored/SKILL.md
.agents/skills/vendored/f0.txt
.agents/skills/vendored/f1.txt
.agents/skills/vendored/f10.txt
.agents/skills/vendored/f11.txt
.agents/skills/vendored/f12.txt
.agents/skills/vendored/f13.txt
.agents/skills/vendored/f14.txt
.agents/skills/vendored/f15.txt
.agents/skills/vendored/f16.txt
.agents/skills/vendored/f17.txt
.agents/skills/vendored/f18.txt
.agents/skills/vendored/f19.txt
.agents/skills/vendored/f2.txt
.agents/skills/vendored/f3.txt
.agents/skills/vendored/f4.txt
.agents/skills/vendored/f5.txt
.agents/skills/vendored/f6.txt
.agents/skills/vendored/f7.txt
.agents/skills/vendored/f8.txt
.agents/skills/vendored/f9.txt
AGENTS.md
UNSTAGEABLE:
.agents/skills/db/bin/tool.exe  <- is a binary file, not text an edit can apply to
.agents/skills/db/edge/nul7999.bin  <- is a binary file, not text an edit can apply to
.agents/skills/db/.hg  <- is version-control internals, not skill content
.agents/skills/db/.svn  <- is version-control internals, not skill content
.agents/skills/db/sub/.git  <- is version-control internals, not skill content
.agents/skills/vendored/.git  <- is version-control internals, not skill content
nested .git files on disk (source): 29
measure changes: []
Evidence: Driver output, base commit (before fix)
ms: 301
STAGED:
.agents/skills/db/.hg/store/data
.agents/skills/db/.svn/entries
.agents/skills/db/SKILL.md
.agents/skills/db/bin/tool.exe
.agents/skills/db/edge/empty.txt
.agents/skills/db/edge/nul7999.bin
.agents/skills/db/edge/nul8000.txt
.agents/skills/db/edge/utf8.md
.agents/skills/db/node_modules/pkg/index.js
.agents/skills/db/notes.txt
.agents/skills/db/sub/.git
.agents/skills/db/sub/readme.txt
.agents/skills/vendored/.git/COMMIT_EDITMSG
.agents/skills/vendored/.git/HEAD
.agents/skills/vendored/.git/config
.agents/skills/vendored/.git/description
.agents/skills/vendored/.git/hooks/applypatch-msg.sample
.agents/skills/vendored/.git/hooks/commit-msg.sample
.agents/skills/vendored/.git/hooks/fsmonitor-watchman.sample
.agents/skills/vendored/.git/hooks/post-update.sample
.agents/skills/vendored/.git/hooks/pre-applypatch.sample
.agents/skills/vendored/.git/hooks/pre-commit.sample
.agents/skills/vendored/.git/hooks/pre-merge-commit.sample
.agents/skills/vendored/.git/hooks/pre-push.sample
.agents/skills/vendored/.git/hooks/pre-rebase.sample
.agents/skills/vendored/.git/hooks/pre-receive.sample
.agents/skills/vendored/.git/hooks/prepare-commit-msg.sample
.agents/skills/vendored/.git/hooks/push-to-checkout.sample
.agents/skills/vendored/.git/hooks/sendemail-validate.sample
.agents/skills/vendored/.git/hooks/update.sample
.agents/skills/vendored/.git/index
.agents/skills/vendored/.git/info/exclude
.agents/skills/vendored/.git/info/refs
.agents/skills/vendored/.git/logs/HEAD
.agents/skills/vendored/.git/logs/refs/heads/master
.agents/skills/vendored/.git/objects/info/commit-graph
.agents/skills/vendored/.git/objects/info/packs
.agents/skills/vendored/.git/objects/pack/pack-ea116963639ff05534b3024caa0d00cccc88d45b.idx
.agents/skills/vendored/.git/objects/pack/pack-ea116963639ff05534b3024caa0d00cccc88d45b.pack
.agents/skills/vendored/.git/objects/pack/pack-ea116963639ff05534b3024caa0d00cccc88d45b.rev
.agents/skills/vendored/.git/packed-refs
.agents/skills/vendored/SKILL.md
AGENTS.md
UNSTAGEABLE:

nested .git files on disk (source): 29
measure changes: []
Evidence: Driver script
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const src = process.argv[2];
const ws = await import(pathToFileURL(path.join(src, "src/workspace.js")).href);
const { State } = await import(pathToFileURL(path.join(src, "src/state.js")).href);
const { readMemoryFile } = await import(pathToFileURL(path.join(src, "src/memory.js")).href);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bp-drive-"));
const w = (p, c) => {
  const f = path.join(root, p);
  fs.mkdirSync(path.dirname(f), { recursive: true });
  fs.writeFileSync(f, c);
};
const SKILL = "---\nname: db\ndescription: Load before touching the database.\n---\n\n## Body\n";
execFileSync("git", ["init", "-q", root]);
w("AGENTS.md", "# M\n\n- one\n");
w(".agents/skills/db/SKILL.md", SKILL);
w(".agents/skills/db/notes.txt", "text note\n");
w(".agents/skills/db/node_modules/pkg/index.js", "module.exports = 1;\n"); // text under node_modules: still staged
w(".agents/skills/db/bin/tool.exe", Buffer.concat([Buffer.from("MZ"), Buffer.from([0, 1, 2]), Buffer.alloc(3000000, 7)]));
w(".agents/skills/db/edge/nul7999.bin", Buffer.concat([Buffer.alloc(7999, 65), Buffer.from([0])])); // NUL at index 7999 (inside sniff)
w(".agents/skills/db/edge/nul8000.txt", Buffer.concat([Buffer.alloc(8000, 65), Buffer.from([0])])); // NUL at index 8000 (outside sniff)
w(".agents/skills/db/edge/empty.txt", "");
w(".agents/skills/db/edge/utf8.md", "héllo — ünïcode ✓\n");
w(".agents/skills/db/.hg/store/data", "hg store text\n");
w(".agents/skills/db/.svn/entries", "svn\n");
w(".agents/skills/db/sub/.git", "gitdir: ../../.git/modules/x\n"); // submodule-style .git FILE
w(".agents/skills/db/sub/readme.txt", "r\n");
// a real nested git checkout with a real pack
const nested = path.join(root, ".agents/skills/vendored");
fs.mkdirSync(nested, { recursive: true });
fs.writeFileSync(path.join(nested, "SKILL.md"), SKILL.replace("db", "vendored"));
for (let i = 0; i < 20; i++) fs.writeFileSync(path.join(nested, `f${i}.txt`), `file ${i}\n`.repeat(500));
const g = (...a) => execFileSync("git", ["-C", nested, "-c", "user.email=a@b", "-c", "user.name=n", ...a], { stdio: "pipe" });
g("init", "-q");
g("add", ".");
g("commit", "-q", "-m", "x");
g("gc", "-q", "--aggressive");
const state = new State(root).ensure();
const repo = { root };
const memoryFile = readMemoryFile(root, "AGENTS.md");
const t0 = Date.now();
const workspace = ws.prepareWorkspace({ state, repo, memoryFile, skillsDir: ".agents/skills" });
const walk = (d, p = "") =>
  fs
    .readdirSync(d, { withFileTypes: true })
    .flatMap((e) =>
      e.isDirectory() ? walk(path.join(d, e.name), p ? p + "/" + e.name : e.name) : [p ? p + "/" + e.name : e.name],
    )
    .sort();
console.log("ms:", Date.now() - t0);
console.log("STAGED:\n" + walk(workspace.root).join("\n"));
console.log("UNSTAGEABLE:\n" + (workspace.unstageable || []).map((u) => `${u.path}  <- ${u.reason}`).join("\n"));
console.log("nested .git files on disk (source):", walk(nested).filter((f) => f.startsWith(".git/")).length);
console.log("measure changes:", JSON.stringify(ws.measureWorkspace(workspace).changes));
fs.rmSync(root, { recursive: true, force: true });

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Skill with its own real git checkout (gc'd pack): the .git is never copied and is named READ_ONLY_VCS; the working files are still staged ✅ pass live drive-target.txt: .agents/skills/vendored/.git &lt;- is version-control internals, not skill content; SKILL.md and f0..f19.txt staged. drive-base.txt: 29 files from .git (pack, idx, hooks) were stage…
.hg, .svn and a submodule-style .git file are also pruned and named ✅ pass live drive-target.txt lists .hg, .svn and sub/.git as READ_ONLY_VCS; sub/readme.txt is still staged.
A 3 MB .exe with a NUL byte is not copied and is named READ_ONLY_BINARY ✅ pass live drive-target.txt: bin/tool.exe &lt;- is a binary file...; absent from STAGED. drive-base.txt staged it.
Binary test boundary is git's: a NUL at byte 7999 is binary, a NUL at byte 8000 is not ✅ pass live drive-target.txt: nul7999.bin unstageable as binary; nul8000.txt staged.
Out-of-scope behaviour is unchanged: node_modules text, an empty file and a UTF-8 file are still staged, and no size cap applies ✅ pass live drive-target.txt STAGED includes node_modules/pkg/index.js, edge/empty.txt and edge/utf8.md.
Files left out are never measured as deleted (measureWorkspace reports no changes), and the regression test passes ✅ pass live drive-target.txt: measure changes: []; node --test --test-name-pattern=&#34;object store and compiled tools&#34; test/workspace.test.js passes (1/1).
Full backpass --scope user run on the author's 1.7 GB store completes without heap OOM ⏸️ untested no It needs the operator's real skills store and a live ACP agent (acpx plus model credentials) and would read real user data, which is off-limits here. The staging stage that caused the OOM was driven l…
  • node --test --test-name-pattern=&#34;object store and compiled tools&#34; test/workspace.test.js (the new regression test)
  • node drive.mjs &lt;worktree&gt; and node drive.mjs &lt;base 2c6af2b archive&gt;: real prepareWorkspace and measureWorkspace on a disposable repo with a real nested git init/gc --aggressive checkout, a 3 MB .exe, .hg/.svn dirs, a submodule-style .git file, boundary-NUL files, an empty file, a UTF-8 file and a node_modules text file
  • node --test test/workspace.test.js on the worktree and on the base archive (only to classify the 13 failures as host-related)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…g copy

prepareWorkspace walked every skill directory whole, so a skill checked out
with its own .git, or one that ships compiled tools, was copied into
.backpass/synthesis/ and read into `originals` as utf-8 text. On a 1.7 GB
user skills directory (one skill carried a 173 MB object store and six
~82 MB executables) synthesis exhausted Node's 4 GB heap while building the
staging copy, before the edit turn opened, and the process died with exit
134.

The walk now prunes `.git`, `.hg` and `.svn` without descending into them,
and a file whose first 8,000 bytes hold a NUL (git's own binary test) is not
staged. Both are named in `unstageable` with their reason, so nothing is
dropped silently. A binary read as utf-8 was never a faithful original, and
no text edit applies to it. Supporting text files such as notes.txt are
still copied whole.

Fixes kunchenguid#75.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The PR appears safe to merge; the previous finding is fixed and no new actionable issues were found.

Reviews (2) · Last reviewed commit: "no-mistakes(ci): Fixed ci-1 in src/works..." · Reviewed by Greptile

Comment thread src/workspace.js
…t nothing synthesis writes is silently dropped. measureWorkspace now passes a collector to its walkFiles call, so VCS entries (.git, .hg, .svn, directories or a .git file) that the walk prunes are reported in `stray`. The reason is a new named constant, STRAY_VCS: "synthesis wrote version-control internals, which are never carried or applied". Staging never copies a VCS entry, so any one found at measure time was written during synthesis. Pruning in walkFiles is unchanged: those entries are still never descended into or copied. Paths are named the way other stray notes are, mapped back to the logical repo path. To do that I pulled the existing mapping lookup and logical-path code into two small local helpers, `mappingFor` and `logicalFor`, which the normal stray loop now uses too. Binary files, confinement reasons and the other stray rules are untouched, and I added no tests. Checks: eslint and tsc are clean and prettier --write was applied. I ran test/workspace.test.js, test/synthesize.test.js and test/synthesis-cli.test.js: the same 46 tests fail before and after the change (compared by name). The ones I looked at fail with EPERM on fs.symlink, the Windows host limit noted in the intent. A one-off script confirmed the behaviour: prepareWorkspace still reports `.agents/skills/foo/.git` as unstageable, measureWorkspace shows no stray before a write, and after writing `.agents/skills/foo/.git/config` and a root `.git` file it reports both in `stray` with STRAY_VCS and zero changes
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

This is on hold for a decision from Kun, not on you. Nothing is needed from your side right now. The PR is otherwise ready: head 7a56e8b is CLEAN against latest main, CI is green on ubuntu and macOS, the no-mistakes attestation passes, and the live staging evidence (base vs fix) is in the body. The diff only touches src/workspace.js, one test, and one README sentence. No workflow, dependency, or network changes, so no security concerns.

VISION verdict (per rule)

  • Purpose (owns the backward pass): aligns. It keeps the staging copy to the memory surface.
  • Evidence is the only currency: aligns. .git objects and compiled tools are not evidence.
  • The human owns the weights: aligns. No change to the review gate or the apply path. Synthesis writes into .git are now named as stray and never carried.
  • Nothing the model says is taken on faith: aligns. Skipped entries never enter originals, so they cannot be measured as deleted, and the new test asserts that.
  • The budget is the constraint: aligns. A store full of VCS internals no longer floods the measured change list.
  • It reads what you already have and owns nothing: aligns.
  • Failure is loud and named: aligns. Each skipped entry is named in unstageable with its reason. There's no silent size cap.
  • Scope: aligns.

Contract-class: new-default (for the binary half); restore (for the VCS half).

  • Skipping .git, .hg, and .svn is the restore that the prepareWorkspace copies the entire skillsDir tree (incl. .git) — EACCES crash and multi-MB annotate prompts on large skill stores #75 triage drew: object stores were never the memory surface.
  • Skipping every file with a NUL in its first 8000 bytes goes past that line. The specified default today is "skill directories are copied whole," including supporting files. After this, an unconfigured run makes every binary supporting file in a skill read-only and leaves it out of the copy. For example, synthesis could no longer remove an image or tool that belongs to a skill it deletes. That may well be the right call, but it changes what the default run stages, so Kun decides it rather than an auto-merge.

I'm not rebasing or merging while it's with Kun. If the call is to keep only the VCS skip, I'll come back here and ask you to drop the binary part.

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.

2 participants