Skip to content

fix: bug-hunt round 9 — ssh-safe secret scan, terminal capture convergence, npm stream split, truthful apply preview, deps overlay ignore, recoverable release publish - #38

Merged
REPPL merged 8 commits into
mainfrom
bughunt/round-9
Aug 15, 2026

Conversation

@REPPL

@REPPL REPPL commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Bug-hunt round 9. Fresh hunt across four dimensions (code, docs-vs-code, infrastructure, internal-doc consistency): 36 candidates, each adversarially reviewed by an independent refuter before any fix — 28 confirmed (13 substantive, 15 nitpick), 8 refuted/rejected.

Confirmed substantive, fixed

  1. Secret scan reads through symlinks — into ~/.ssh (cmd/sync.go:1172): the changed-file scan used a symlink-following read, so an untracked repo symlink at ~/.ssh/id_ed25519 was read in full on the default sync path — a direct breach of the untouchable-~/.ssh boundary. Changed paths are now Lstat-gated; non-regular entries skip (link text is covered by the push-range blob scan).
  2. Directory-shaped entries wedge every sync (cmd/sync.go:1128): a dirty submodule ( M sub) or symlink-to-directory (?? link) arrives without the trailing slash the scan keyed on, EISDIR-failing fail-closed with unsatisfiable advice. Same Lstat gate; gitlink content never enters the push range.
  3. Untracked-directory scan over-blocks (cmd/sync.go:1128-1171): the walk read gitignored files and nested .git trees that git add -A would never stage (a nested repo's https://user:token@ remote was a dead end). Enumeration now uses git ls-files --others --exclude-standard.
  4. Shared terminal capture never converges behind a local overlay (cmd/capture.go:1063/1079/1114): the compare used the local-wins source while the shared route wrote the shared path — permanent re-offers, stale overlay kept applying. A shared accept removes the superseded overlay (reported; symlinked overlays untouched).
  5. Secret-routed terminal capture reports drift forever (cmd/status.go:201-218, cmd/capture.go:1079-1091): status/capture byte-compared the raw {{ferry.secret …}} placeholder against the live export while apply rendered it. Both now render via the secret store before comparing; the placeholder write round-trips byte-exactly.
  6. npm-globals domain dies on any peer-dep warning (internal/deps/npm.go:53-64, runner.go): CombinedOutput fused npm's stderr into the JSON parse, making the documented non-zero-exit tolerance dead code. Stdout-only parse; stderr spliced into real errors.
  7. ferry diff cannot predict the empty-over-substantial refusal (cmd/apply.go, internal/dotfile/apply.go): the preview showed "would update" for an item apply refuses; abort left scrollback claiming writes that were rolled back. Preview renders "would refuse"; the abort states its rollback. The abort policy itself was adjudicated deliberate and is unchanged.
  8. Per-machine deps overlay is committed and synced (cmd/init.go:918, cmd/init_github.go:355): deps/Brewfile.*.local — documented "one machine only" and code-commented "gitignored" — matched no generated ignore pattern, so it was pushed, cloned, installed fleet-wide, and bundled. Pattern ships from the single chokepoint on every init route; the --github gate model is rendered from the writer's pattern set. End-to-end eval reproduced the publish pre-fix.
  9. Post-publish release failure is unrecoverable and the heal is blind (.github/workflows/release.yml:418-489, auto-release.yml:97-99): a red post-publish check could not be re-run (create 422s on the existing release) while a full re-run went green with the bypassed gates unrun. Publish is idempotent (view → upload --clobber + edit, else create); the heal predicate requires a published, asset-complete release; recovery documented.
  10. Hand-publish recipe hashes unstamped binaries (docs/how-to/cutting-a-release.md:161-166, Makefile:61-62): make checksums re-runs the phony build without VERSION, silently discarding stamped binaries under a self-consistent manifest. Recipe is the single make checksums VERSION=vX.Y.Z; Makefile comment corrected.
  11. Compatibility contract's versioning claim is false for snapshots (docs/reference/compatibility.md:45-62 vs internal/backup/snapshot.go:21-23,93-97): the page granted one exception (baseline) while the snapshot manifest (snapshots/ID/manifest.json) is unversioned and restore --undo replays it ungated. Snapshots are now the named second exception.
  12. Docs direct users to "extend" a compile-time allowlist (docs/reference/configuration.md:187,193-194): no manifest surface exists. The page now states the allowlist is compiled in and documents the route that works (committed plist / local/ overlay, imported verbatim by apply; capture rewrites to the filtered set).
  13. Main-only ferry sync is stated nowhere user-facing (cmd/sync.go:23,290-293): every sibling precondition was documented; the single-branch constraint was not. Now in the commands reference, tutorial (with the remote-side rename steps), and sync help.

Confirmed nitpicks, fixed

Conflict items in the guided walkthrough say confirming will not overwrite them (and the commands reference lists the fourth risky class); clean skip-always targets are silent and counted in-sync; receive's unlock accumulates releases (latent double-lock leak); consistency-lint invariant 3 exempts only *_test.go; zizmor's SARIF upload skips fork PRs (audit still gates); install.sh names a failed download; ci.yml's cross-compile comment stops claiming a pre-tag gate (auto-release tags the same push in ~20s, measured); the release how-to documents step 7's local handover reset; allowlist categories completed; route jargon replaced; ssh.md's enumeration made universal (incl. the cargo-store guard); scaffold help names what it creates; AGENTS.md's CI list names gitleaks/zizmor; the tutorial distinguishes drift from conflict; the wizard row states the tty fallback; evals/harness.go's ApplyConfirmed comment corrected.

Considered and rejected (refuted)

  • restore --packages over-count: unreachable via ferry-written state (both rails record bare names); tampered-input direction is under-removal, the safe side.
  • auto-merge retitle never disarms: same end state as the earlier edited-arming refutation — no privilege delta, one-admin actor set, and a disarm step is racy and clobbers hand-armed PRs.
  • Green terminal-state annotation after the release retry bound: the three counted runs were each red and notified; a transition-fail fires unreliably on a non-monotonic two-counter sum.
  • +build-metadata tag becomes latest + escapes retention: round-8 adjudication stands — the workflow comment weighs exactly this consequence, and retention-escape applies to every suffixed tag by documented design.
  • Stale-binary checksums in local rehearsal: gitignored throwaway file; CI builds on fresh runners.
  • Doubled push/pull_request CI runs: different trees (branch tip vs merge ref); a shared concurrency group would cancel required checks.
  • --wizard=interactive silent fallback as substantive: disclosed twice on the same reference page; the fallback is conservative and secret-safe by deliberate design (redirected runs must not paint a TUI into a file); wording tightened as a nitpick instead.
  • fileDomainIsRepoAuthoritative registry rebuild: hunter-self-refuted (allocation-only, no I/O, once per plan item).

Carried forward

terminalRepoStatusSource's extensionless/poisoned-overlay divergences from terminalExportBlob (hand-authored state only; one shared resolver would close it); Apple Terminal's unfiltered export routinely trips the entropy gate on NSKeyedArchiver base64 — the domain may be uncapturable on customised machines (new lead from the refutation pass, unadjudicated); the deps-overlay glob duplicated in cmd/init.go rather than shared from internal/deps; release.sh's render_next_md "also sourced by tests" claim matches nothing, and a NEXT.md missing its carry markers exits the driver non-zero after a successful tag push; the heal predicate hardcodes the 5-asset count; sync's branch-rename hint omits the remote-side default-branch step; managed carry remains path-keyed.

Gates

make build, gofmt -l (empty), go vet ./..., go test ./..., go test -race ./internal/..., full eval suite with FERRY_BIN set, scripts/consistency-lint.sh, and make gen-docs (no drift) — all green locally. Every behaviour change carries a test watched failing before the fix; the overlay fix additionally carries an end-to-end eval reproduced against a pre-fix binary.

claude added 8 commits August 15, 2026 08:00
…le set

The changed-file scan opened paths with a symlink-following read, so an
untracked repo symlink pointing at ~/.ssh/id_ed25519 was read in full,
breaching the ssh boundary, and a dirty submodule or symlink-to-directory
EISDIR-wedged every sync fail-closed. Changed paths are now Lstat-gated
(git commits a symlink as its link text, which the push-range blob scan
covers; a gitlink's content never enters the push range). The collapsed
untracked-directory walk is replaced by git ls-files --others
--exclude-standard, so gitignored files and nested .git trees no longer
false-block a sync that would never publish them.

Assisted-by: Claude:claude-fable-5
A shared-route terminal capture wrote the shared plist while a
per-machine overlay from an earlier local capture kept winning both the
drift comparison and apply, so the domain re-offered forever and the
shared bytes did nothing; a shared accept now removes the superseded
overlay and says so, leaving symlinked overlays untouched. A
secret-routed capture wrote a placeholder that status and capture
compared raw against the live export, reporting drift forever while
apply rendered it in-sync; both compare sites now render placeholders
through the secret store first (raw-compare fallback when the store or
ref is unavailable), and the placeholder write round-trips byte-exactly.

Assisted-by: Claude:claude-fable-5
The dump parsed CombinedOutput, so npm's stderr made the documented
non-zero-exit tolerance unreachable and any peer-dependency warning
disabled the domain. The listing query now captures streams separately
(optional SeparateRunner on the exec runner; plain fakes fall back
unchanged) and a genuine failure splices npm's stderr into the error
instead of a bare exit status.

Assisted-by: Claude:claude-fable-5
The data-loss guard that aborts apply when a near-empty repo file would
replace a substantial live file was invisible to the preview, which
showed a plain would-update; the plan now renders such items as would
refuse and the abort path states that the changes reported earlier in
the run were rolled back (the abort policy itself is deliberate and
unchanged). The guided walkthrough tells the user that confirming a
conflict does not overwrite it, a clean skip-always target is silent
and counted in sync, and the receive planner accumulates lock releases
so a second union-merge item cannot leak a lock.

Assisted-by: Claude:claude-fable-5
deps/Brewfile.<os>.local is documented as belonging to one machine
only, but the generated .gitignore covered only ferry.local.toml and
local/, so sync committed the overlay, every clone installed it via
apply --deps, and bundle export carried it. The ignore pattern now
ships from the single chokepoint every init route uses, and the
init --github pre-create gate model is rendered from the writer's own
pattern set so the two can never diverge.

Assisted-by: Claude:claude-fable-5
A failed post-publish check left the release run permanently red (the
create errors once the Release exists) while a full auto-release re-run
saw the release as done and skipped the bypassed gates; publishing is
now idempotent and the heal predicate requires a published, asset-
complete release. The zizmor SARIF upload is skipped on fork pull
requests where the token is read-only (the audit still gates), the
consistency lint's old-path check exempts only Go test files,
install.sh names a failed binary download, the checksums Makefile
comment states that the build prerequisite rebuilds with this
invocation's VERSION, and the ci.yml cross-compile comment stops
claiming a pre-tag gate that auto-release's concurrent tag outruns.

Assisted-by: Claude:claude-fable-5
The compatibility contract names restore snapshots as the second
version-independent store; the configuration reference states the
iTerm2 allowlist is compiled in, lists all eight kept categories,
gives the committed-plist route for extra keys, and documents the
gitignored deps overlay with its untracking step; the single-branch
main constraint on sync enters the commands reference, the tutorial,
and the sync help; route jargon is replaced with plain language;
ssh.md states the ~/.ssh invariant universally including the cargo-
store guard; the release how-to corrects the failed-check claim,
documents post-publish recovery and the local NEXT.md reset, and
stamps the by-hand checksum recipe; the scaffold help names exactly
what it creates; AGENTS.md's CI list names the gitleaks and zizmor
gates; the tutorial distinguishes local drift from a conflict.

Assisted-by: Claude:claude-fable-5
Assisted-by: Claude:claude-fable-5
@REPPL
REPPL merged commit e1c2346 into main Aug 15, 2026
15 checks passed
@REPPL
REPPL deleted the bughunt/round-9 branch August 15, 2026 08:23
@REPPL REPPL mentioned this pull request Aug 15, 2026
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