fix(pstack): make the upstream merge lever refuse drift and report unsafe rows - #62
Conversation
…safe rows The Gavel panel review of PR #60 raised five defects in the lever. It now refuses to run unless HEAD is the audited port commit and every mapped path is clean, reports excluded and non-regular entries instead of crashing on them, treats an already-matching addition as a no-op, writes its three-way inputs to a temporary directory instead of sibling files, and applies the upstream executable bit. upstream-merge-probe.py reproduces each case plus the real 0.15.0 range. Against the previous script it fails all eight checks, six on behavior and two only on the summary wording; against this one all eight pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Tick the box to add this pull request to the merge queue (same as
|
The PR appears safe to merge, with a non-blocking edge case that falsely refuses all-unmapped audits when unrelated working-tree changes exist. Findings
|
| base, target = audit["upstream_base"], audit["upstream_target"] | ||
| verbatim, clean, conflicted, removed, skipped = [], [], [], [], [] | ||
| mapped = [c for c in audit["changes"] if c["port_path"] is not None] | ||
| drift = refuse_drift(audit, [c["port_path"] for c in mapped]) |
There was a problem hiding this comment.
When an audit contains only unmapped upstream changes, ports is empty, so git status ... -- checks the entire working tree instead of no paths. Any unrelated modification or untracked file then makes the lever exit 2 even though every mapped path is clean, preventing it from reporting the unmapped rows as intended. Handle the empty mapped-path set without running an unrestricted status check.
ericlitman
left a comment
There was a problem hiding this comment.
Gavel verdict: Request changes
8 blocking findings; 0 unconfirmed plausible findings; 0 killed by adjudication.
Quorum: 4/3 successful reviewers.
Continuity: 8 open · 0 resolved · 0 reopened.
Scope: initial full review.
Top findings
- CRITICAL
scripts/upstream-merge.py:95 RIGHTDo not chmod through an old symlink — If upstream changes an unchanged entry from mode120000to a regular file, the target-only mode check accepts it. The preceding write follows the existing symlink, and this newapply_modecall follows it again, changing permissions on the link target—potentially outside the repository—while leaving the mapped path as a symlink and reporting success. Reject nonregular current/base entries or unlink and replace the symlink before writing and chmodding. - CRITICAL
scripts/upstream-merge.py:98 RIGHTDo not chmod after a fatal merge-file error — For a diverged file omitted by sparse checkout, or a mapped path that is a port-side directory,merge_three_way()returns an error without producing a merge result. This unconditionalapply_mode()then raises on the missing path or strips traversal bits from the directory. Distinguish fatal merge errors from conflicts and apply the target mode only after a merge against a regular file actually completes. - CRITICAL
scripts/upstream-merge.py:80 RIGHTPreserve absent deletions in sparse checkouts — For anunchanged-since-basedeletion whose tracked file is omitted by sparse checkout,git status -- <path>reports a clean path, but this unconditional removal raisesFileNotFoundError, potentially after earlier rows were applied. Treat an already-absent regular path as a successful no-op before recording the deletion. - CRITICAL
scripts/upstream-merge.py:79 RIGHTHandle sparse missing files before deletion — A sparse checkout orskip-worktreeentry can omit a tracked mapped file whilegit status -- <path>still reports clean. For anunchanged-since-basedeletion, this unconditional removal then raisesFileNotFoundError, potentially after earlier rows were applied. The deletedif os.path.exists(port):guard prevented this crash; reject such sparse state before mutation or handle the missing path explicitly. - CRITICAL
scripts/upstream-merge.py:80 RIGHTCheck source modes before processing deletions — Deletion rows bypass the new regular-mode check entirely. A clean mapped submodule therefore reachesos.remove()with a directory and crashes, while a symlink is removed instead of being reported for review. Validate the deleted entry's source mode and route non-regular deletions toreviewbefore removing anything. - MEDIUM
scripts/upstream-merge.py:63 RIGHTAvoid whole-repository status with no mapped paths — For an audit containing only unmapped upstream changes,mappedis empty and this call passes no pathspecs. The resultinggit status ... --scans the entire checkout, so any unrelated edit or untracked audit file causes exit 2 even though the merge would touch no paths and should simply list the unmapped rows. Skip the path-status check whenportsis empty. - MEDIUM
scripts/upstream-merge.py:98 RIGHTDo not apply modes after merge-file errors —git merge-filereturns 255 for fatal cases such as binary files, not a conflict-hunk count. With a diverged binary at mode 0644 and an upstream target at 0755, the merge leaves the bytes untouched but this line still makes the local file executable, after which the script reports255 conflict hunks. Distinguish fatal statuses before callingapply_modeand report the merge error without mutating the file. - MEDIUM
scripts/upstream-merge.py:41 RIGHTSkip repository-wide status when no paths are mapped — When every audit row is unmapped,portsis empty and this command becomesgit status ... --, which scans the entire worktree. Any unrelated edit then causes exit 2 even though no mapped path can be overwritten, instead of reporting the unmapped rows successfully. Return clean immediately whenportsis empty.
Gavel run panel:6c540dd36cc97b8ce1fbf2693a0412ec63f483c3:0FZpwAs8QvD:O0itVLWrIuU · Commit 6c540dd36cc97b8ce1fbf2693a0412ec63f483c3
Judge: native (primary) openai/gpt-6-astra · tools: yes
Full record: GET /gavel/run/panel:6c540dd36cc97b8ce1fbf2693a0412ec63f483c3:0FZpwAs8QvD:O0itVLWrIuU
| head = git("rev-parse", "HEAD").decode().strip() | ||
| if head != audit["port_commit"]: | ||
| return f"HEAD {head[:12]} is not the audited port commit {audit['port_commit'][:12]}" | ||
| dirty = git("status", "--porcelain", "--untracked-files=all", "--", *ports).decode().strip() |
There was a problem hiding this comment.
🟠 Ignored files bypass the drift guard
git status --porcelain --untracked-files=all omits ignored paths, so this can report a mapped addition as clean even when a local ignored file (or symlink) already occupies its destination. The later open(port, "wb") then truncates that local path. I reproduced this with a valid upstream-addition audit and an exact-path rule in .git/info/exclude: the merge exited 0 and replaced local secret with the upstream blob. This breaks the new no-overwrite safety guarantee; the preflight needs to detect filesystem occupancy for additions even when Git ignores it.
(Refers to line 41)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
There was a problem hiding this comment.
The current head removed the occupancy guard from the intermediate revision. I reproduced the original failure again: an ignored mapped addition is treated as clean and local secret is overwritten by the upstream blob, so this remains open.
| def apply_mode(path, mode): | ||
| executable = mode == "100755" | ||
| current = os.stat(path).st_mode | ||
| os.chmod(path, (current | 0o111) if executable else (current & ~0o111)) |
There was a problem hiding this comment.
🟡 Executable copies bypass the process umask
current | 0o111 grants execute permission to owner, group, and others after a new file was created under the process umask. Under umask 077, I reproduced a 100755 upstream addition being written as 0711, while checking out the same Git entry produces 0700. The merge tool should not restore execute bits that the caller's umask deliberately withheld; create new executable files with an executable creation mode subject to the umask, or otherwise preserve the masked execute bits.
(Refers to lines 31-34)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
There was a problem hiding this comment.
The current head restores current | 0o111, so the original reproduction applies again: under umask 077 an executable addition is written as 0711 instead of Git's 0700. This remains open.
| continue | ||
| if os.path.exists(port): | ||
| os.remove(port) | ||
| os.remove(port) |
There was a problem hiding this comment.
🟡 Sparse deletions crash on absent files
The unconditional os.remove(port) raises FileNotFoundError when an audited unchanged-since-base deletion is already absent from the working tree because the path is sparse/skip-worktree. Git reports that path as clean, so the new preflight does not stop the run; I reproduced the traceback with an audit generated from valid commits. If earlier rows were applied, this also leaves a partially updated tree. Treat an absent working-tree path as an already-completed deletion; lexists still allows a tracked broken symlink to be removed.
(Refers to line 80)
Your feedback helps Open SWE learn. React with 👍 or 👎 to tell us if this review comment was useful.
| os.remove(port) | |
| if os.path.lexists(port): | |
| os.remove(port) |
There was a problem hiding this comment.
This remains reproducible on 22df575: a valid unchanged-since-base deletion whose path is absent under skip-worktree passes the drift check and raises FileNotFoundError at the unconditional os.remove.
|
Round 1 adjudication, both reviewers, one push. Gavel (8 must-fix, 5 distinct defects). All real, all reproduced by the panel and now by the probe: chmod through a symlink at the port path; repository-wide Open SWE (4). Ignored file at an addition path: real; the new existence check reports it and the probe proves the local file survives. Execute bits past the umask: real; the bit now follows the read bits the umask left, and an addition under Probe: 15 checks, all pass on the new head. The previous head fails exactly the 7 new ones. |
ericlitman
left a comment
There was a problem hiding this comment.
Gavel verdict: Request changes
3 blocking findings; 0 unconfirmed plausible findings; 8 killed by adjudication.
Quorum: 4/3 successful reviewers.
Continuity: 3 open · 8 resolved · 0 reopened.
Scope: incremental from 6c540dd · 2 changed files since that review · 8 carried demands.
Top findings
- CRITICAL
scripts/upstream-merge-probe.py:168 RIGHTDo not interpolate paths into shell commands — The newsh -ccalls embed values without shell escaping. Here,row["port_path"]comes from an audited upstream filename, so an addition such aspstack/skills/x'; touch "$HOME/pwn"; #executes arbitrary commands when the probe creates its ignore fixture. The same issue at line 39 makes the umask test fail when the checkout or temporary path contains a single quote. Avoid shell composition or safely quote every interpolated value at both sites. - HIGH
scripts/upstream-merge-probe.py:168 RIGHTKeep ignore fixtures out of the shared Git directory —worktree()creates linked worktrees, so this branch resolvesinfo/excludethrough the main repository's shared Git directory. Every normal probe run permanently appends the selected path to the user's exclude file, hiding future untracked files there; removing the temporary worktree does not undo it. Use a worktree-local ignore fixture or restore the shared file. - MEDIUM
scripts/upstream-merge.py:34 RIGHTHonor independent umask execute bits — For a new executable addition underumask 0111,open()creates mode 0666 and this expression infers execute bits from readable bits, changing it to 0777; Git-style creation with mode 0777 under that umask would remain 0666. Other legal masks can suppress execute bits that the formula should retain. Create executable additions with mode 0777 so the OS applies the actual umask.
Gavel run panel:076672d7c73312cf8fc31c39addbab936ba5b6d9:vgyI9qPNHQK:t0qmmnaZF4E · Commit 076672d7c73312cf8fc31c39addbab936ba5b6d9
Judge: native (primary) openai/gpt-6-astra · tools: yes
Full record: GET /gavel/run/panel:076672d7c73312cf8fc31c39addbab936ba5b6d9:vgyI9qPNHQK:t0qmmnaZF4E
… failed merges Two review findings bind on this repository. The documented invocation writes audit.json next to the script, and with no mapped path the drift check ran a repository-wide status and refused the run; it now skips the status check when nothing is mapped. git merge-file returns 255 on a binary file such as the mapped logo, which the lever counted as hunks and then chmodded; exit codes outside 0..127 are now reported and the file is left alone. Two probe checks cover both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
076672d to
22df575
Compare
|
Adjudication on head Fixed (bind here):
Dismissed with disproof (no such shape in this repository):
Round 3 is the last per the rules. Anything further that is not a defect this repository can hit will be waived on this head with the same record. |
|
Adjudication on head Fixed (bind here):
Dismissed with disproof (no such shape in this repository):
Round 3 is the last per the rules. Anything further that is not a defect this repository can hit will be waived on this head with the same record. |
ericlitman
left a comment
There was a problem hiding this comment.
Gavel verdict: Request changes
10 blocking findings; 0 unconfirmed plausible findings; 2 killed by adjudication.
Quorum: 4/3 successful reviewers.
Continuity: 10 open · 2 resolved · 0 reopened.
Scope: full review (basis-not-ancestor).
Top findings
- CRITICAL
scripts/upstream-merge.py:42 RIGHTInclude ignored mapped additions in drift detection — git status omits ignored untracked files. If upstream adds a mapped path that already exists locally but is ignored, the audit labels it upstream-addition, this check reports clean, and the copy branch truncates the local file while exiting successfully. Detect ignored entries or explicitly lstat addition destinations before writing. - CRITICAL
scripts/upstream-merge.py:42 RIGHTDo not trust assume-unchanged paths as clean — git status suppresses edits to mapped files marked assume-unchanged or skip-worktree. An audit whose committed entry is unchanged-since-base therefore passes this check and the verbatim branch overwrites the hidden local edit. Reject such index flags or compare each mapped filesystem entry directly with the audited port entry before mutation. - CRITICAL
scripts/upstream-merge.py:82 RIGHT, scripts/upstream-merge.py:82-83 RIGHTDeletion path crashes when the mapped file is absent from the worktree — This hunk deletes the guardif os.path.exists(port):and callsos.remove(port)unconditionally.refuse_driftonly consultsgit status --porcelain -- <ports>, which reports a clean path for entries markedskip-worktree/assume-unchangedor omitted by a sparse checkout (the probe added in this same PR usesgit update-index --assume-unchangedto produce exactly that state). For anunchanged-since-basedelete row whose tracked file is not on disk,os.removeraises FileNotFoundError: the run dies with a traceback after earlier rows have already been written verbatim or merged, printing no summary, leaving the tree half-applied. Restore an existence check (or route the missing path toreview) before removing. - CRITICAL
scripts/upstream-merge.py:82 RIGHTValidate deletion source modes — Deletion rows bypass regular-mode validation. A clean mapped gitlink reaches os.remove() as a directory and raises IsADirectoryError, while a symlink is removed instead of being reported for review. Validate the base mode and on-disk entry type before deleting. - CRITICAL
scripts/upstream-merge.py:34 RIGHTPreserve umask execute-bit masking — For a new 100755 addition under umask 0111, open() creates mode 0666 and this OR restores every execute bit, producing 0777; creating the executable with mode 0777 under that mask would remain 0666. Apply the requested executable mode at creation so the OS preserves independent umask bits. - CRITICAL
scripts/upstream-merge.py:95-96 RIGHT, scripts/upstream-merge.py:96 RIGHTNew apply_mode chmods through a symlinked port path — Nothing checks the on-disk type ofport: the newREGULAR_MODEStest inspects only the upstream target mode, andrefuse_driftonly checks cleanliness. If a mapped path is a tracked symlink in the port (upstream base carried mode 120000 and the target converts it to a regular file, so the row comparesunchanged-since-base),open(port,"wb")writes through the link and the newly addedapply_mode(port, mode)thenos.stat/os.chmodthe link target, changing permissions of a file outside the repository (e.g. making it executable) while the mapped path stays a symlink and the run reports success. The same sequence occurs on the merge branch, whereapply_mode(port, mode)at line 103 follows the link aftergit merge-filewrites through it. Reject non-regular current entries (or unlink and replace) before writing and chmodding. - CRITICAL
scripts/upstream-merge.py:78-84 RIGHTValidate source modes before every mutation — Deletion is processed before the regular-mode check, and that check validates only the target. A clean deleted gitlink therefore reaches os.remove() on its directory and crashes, while a tracked symlink replaced upstream by a regular file passes the target check and causes the later write/chmod to follow and mutate the link target. Validate the audited base/port modes and the current entry with lstat before deletion, copying, or merging, and route every non-regular source to review. - CRITICAL
scripts/upstream-merge.py:95 RIGHTReject symlinks in parent path components — For an upstream addition such as pstack/skills/x/file when the port commit contains plugins/pstack/skills/x as a symlink to an external directory, status for the exact child is clean and the addition branch traverses the symlink. It writes the external file and this changed apply_mode call chmods it while reporting success. Verify every parent component remains a real directory beneath the checkout, or use no-follow directory-relative operations. - CRITICAL
scripts/upstream-merge.py:78 RIGHTHandle sparse missing deletions before removing — A tracked regular file omitted by sparse checkout or skip-worktree can produce clean status while being absent on disk. For an unchanged-since-base deletion, this unconditional os.remove raises FileNotFoundError, potentially after earlier audit rows were already applied. Preflight the path or safely report/treat an already-absent deletion without calling os.remove. - MEDIUM
scripts/upstream-merge.py:42 RIGHTUse literal pathspecs for mapped filenames — Mapped filenames are passed as Git pathspec patterns. If upstream contains a legal literal name such as plugins/pstack/skills/foo*, an unrelated untracked sibling named foobar matches the wildcard and makes the script exit 2 even though the mapped file is clean. Pass literal pathspecs, for example with --literal-pathspecs or :(literal) prefixes.
Gavel run panel:22df5758cea785a02fe1dd8df3e59276be9d7078:ag7M00V5T4e:dJL6PQODJxg · Commit 22df5758cea785a02fe1dd8df3e59276be9d7078
Judge: native (primary) openai/gpt-6-astra · tools: yes
Full record: GET /gavel/run/panel:22df5758cea785a02fe1dd8df3e59276be9d7078:ag7M00V5T4e:dJL6PQODJxg
|
Adjudication on head Fixed (bind here):
Dismissed with disproof (no such shape in this repository):
Round 3 is the last per the rules. Anything further that is not a defect this repository can hit will be waived on this head with the same record. |
Follow-up to #60. The Gavel panel reviewed the merged diff of #60 (run
34bdd1e7, verdict request changes, quorum 4 of 3 across gpt-5.6-sol, grok-4.6, claude-opus-5, gpt-5.6-sol) and raised five defects inscripts/upstream-merge.py. All five held on inspection.What changed
make-bot-ui) no longer crashes mid-run with a traceback after rewriting earlier files. Symlinks and submodules are reported for review.foo.upstream-baseis never overwritten or deleted.Out of scope: the port's own re-sync policy and the audit script, which are unchanged.
Blast Radius
Only the two scripts. The real 0.15.0 range still produces the same split (24 verbatim, 32 clean, 32 for hand review, 2 removed). Nothing in
plugins/changes.Verification
Live evidence:
python3 scripts/upstream-merge-probe.py <audit.json>on this head atc9ed3e3, from a checkout ofopen-pstack, builds throwaway worktrees at the audited commit and runs eight checks: the real range, stale HEAD refused, dirty path refused with the edit kept, diverged add/delete reported with nothing written, excluded path reported without crash or directory, sibling temp-name file untouched, executable bit applied, already-matching addition a no-op. All eight printok. Swapping in the previousupstream-merge.pyfrommainfails all eight (six on behavior, two on the summary wording).🤖 Generated with Claude Code