Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .abcd/work/DECISIONS.md

Large diffs are not rendered by default.

85 changes: 73 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,74 @@ called out in a **Breaking** section. See

### Fixed

- **A customised Apple Terminal profile no longer trips the capture secret
gate.** The whole-plist export carries profile colours and fonts as base64
`<data>` archives, whose entropy the scanner read as credential material,
so any customised profile blocked both repo routes and offered only reject
or the out-of-repo store — leaving the domain effectively uncapturable, and
non-portable via the store. The gate scans a view of the plist with
`<data>` payloads masked: a pasteable token lives in a `<string>` value,
which stays fully scanned, while base64-encoded binary the text scanner
could never read into stops feeding the entropy detector. An unterminated
`<data>` element ends the masking, so malformed input is scanned in full.
- **A secret-routed terminal capture supersedes the local overlay.** A
shared accept removes a superseded per-machine overlay, but the secret
route wrote its placeholder behind one, so `apply` kept importing the
stale overlay — silently reverting the settings the capture had just
routed out of band — while `status` and `capture` re-offered the domain
forever. The placeholder write removes the overlay exactly as a shared
accept does, reporting the removal; a symlinked overlay is left untouched.
- **`status` and `capture` honour an extensionless terminal overlay.**
`apply` probes both overlay spellings (`<id>.plist` and bare `<id>`); the
status-side resolver probed only the `.plist` spelling, so a hand-authored
extensionless overlay was what `apply` imported while `status` and
`capture` compared against the shared copy. Both spellings are probed
everywhere.
- **A failed apt probe cannot record a pre-existing package as
ferry-installed.** `dpkg-query` reports a not-installed package with a
diagnostic in its output; an exec-level probe failure returns no output at
all, which the installed-set prober read as "absent" — so a probe failure
in the before-snapshot could hand `restore --packages` a package the user
already had, and a lookup failure hitting both snapshots silently lost the
run's record with no warning. A probe error with no output marks the
snapshot unreliable, suppressing the restore record and saying so.
- **`work pack --acknowledge` never echoes a withheld path.** A finding on a
secret-shaped file name renders as "(name withheld)", but the acknowledge
retry's "covered some findings, but not: …" list printed the raw path in
the same message. The unmatched list masks the secret-shaped component,
keeping the surrounding path visible so the finding stays identifiable.
- **`bundle import` refuses a secret-shaped path component.** Export
withholds such entries, but import validated only the canonical path
shape, so a handcrafted bundle could plant a token-named file that wedged
every later `ferry sync` far from the cause. Import applies the same
per-component gate export uses — shared as one predicate so the two sides
cannot drift — refusing with the path withheld.
- **gitconfig section headers honour backslash escapes.** The inline-header
split and the subsection derivation both mis-read a `\"` (and an unquoted
`]` inside quotes) in a quoted subsection name, swallowing a same-line
assignment into the header's raw bytes, where the identity firewall could
pass it through to the shared repo. One shared scanner honours git's
in-quote escapes for both; parsing stays total and reassembly byte-exact.
- **`release.sh` refuses a version that is not the newest dated CHANGELOG
section.** The driver tags HEAD, so accepting an older still-untagged
version would point an immutable tag at newer main code under an older
name — the mis-pointing the automatic path already refuses. The driver
also checks NEXT.md's carry markers before the tag push, so a malformed
local file fails the run before the irreversible act instead of after it.
- **`ferry sync`'s branch-rename hint covers the remote side.** The hint
named only the local `git branch -M main`; it adds setting `main` as the
remote's default branch so other clones follow.
- **Docs corrected against the code.** The drift explanation subjects
first-touch adoption to the risky-change gate (confirmed interactively,
refused with a non-zero exit unattended); the scaffold help names NEXT.md
in the runtime layout both modes create; the release how-to documents the
driver's post-tag NEXT.md reset and the real re-release condition
(missing, still a draft, or short of its assets); the configuration
reference documents the terminal secret route and its placeholder, and
scopes the bundle's local-layer gate to `local/**` and `ferry.local.toml`;
deps/README names the `brew = true` gate; the evals file map lists the
deps-overlay eval; roadmap clauses leave the reference and tutorial; the
commands reference notes `init --fresh`.
- **The pre-commit secret scan never reads through a repo symlink.**
`ferry sync`'s changed-file scan opened paths with a symlink-following
read, so an untracked or modified symlink in the config repo — for
Expand All @@ -60,13 +128,6 @@ called out in a **Breaking** section. See
read aborted every sync with advice ("re-run once the file is readable")
a directory can never satisfy. Such entries are skipped — a gitlink's
content never enters the push range.
- **The untracked-directory secret scan matches what `git add -A`
stages.** The walk over a collapsed untracked directory read every file
beneath it, including gitignored files and nested repositories' `.git`
internals, so a token in a file git would never commit blocked the sync
with no way forward. The directory is now enumerated with
`git ls-files --others --exclude-standard`, scanning exactly the
stageable set; a failed enumeration still aborts.
- **A shared terminal capture behind a local overlay converges.**
Accepting a terminal preference domain to shared wrote the shared
plist, but the per-machine overlay from an earlier local capture kept
Expand Down Expand Up @@ -150,7 +211,6 @@ called out in a **Breaking** section. See
the cargo-store guard; 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.

- **A cargo store under `~/.ssh` is refused.** The `[work] store` path is
hand-configured, and every other configurable path ferry writes through — the
repo path, `bundle import --out` — is guarded against resolving into `~/.ssh`
Expand All @@ -163,9 +223,11 @@ called out in a **Breaking** section. See
collapsed entry, which the pre-commit secret gate tried to read as a file;
the resulting fail-closed abort ("could not read a changed file … is a
directory") recurred on every run, with advice a directory can never
satisfy, until the user staged the files by hand. The gate now walks a
collapsed directory and scans every file inside, exactly as the sync backup
pass already did.
satisfy, until the user staged the files by hand. The gate enumerates a
collapsed directory with `git ls-files --others --exclude-standard`,
scanning exactly the set `git add -A` would stage — gitignored files and
nested repositories' `.git` internals stay outside the gate — and a
failed enumeration still aborts.
- **`ferry init <path>` naming the configured repo keeps `managed`.** The
carry-forward shipped earlier keyed on the no-argument re-run route, so
passing the already-configured repo's path positionally (`ferry init .` from
Expand Down Expand Up @@ -348,7 +410,6 @@ called out in a **Breaking** section. See
set, `make release` documentation passes `VERSION=vX.Y.Z`, and
CONTRIBUTING's commit-type list includes the `ci` type the history and
auto-merge workflow already use.

- **The configuration reference documents the Apple Terminal domain.** The
`terminal` key has been implemented end-to-end — apply, capture, restore,
and the eval suite — but appeared nowhere in the reference: the `[manage]`
Expand Down
2 changes: 1 addition & 1 deletion cmd/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ var agentsScaffoldCmd = &cobra.Command{
Short: "Set a project repo up for the multi-tool agent pipeline",
Long: `Set a project repo up for the multi-tool agent pipeline.

Both modes create the local-only runtime layout: .abcd/.work.local/{scratch,logs},
Both modes create the local-only runtime layout: .abcd/.work.local/{NEXT.md,scratch,logs},
hidden via the git info/exclude mechanism (never committed; .gitignore is
never touched).

Expand Down
3 changes: 2 additions & 1 deletion cmd/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -1082,7 +1082,8 @@ func applyNpmGlobals(ctx *cmdContext, out io.Writer) error {
return nil
}

// printPlan renders the planned actions (diff / status). For dotfile/overlay
// printPlan renders the planned actions (diff, and init's closing plan
// preview; status renders its own report). For dotfile/overlay
// targets it prints the REAL three-way classification computed during planning
// (it.state) — the same resolution apply acts on — rather than a blanket "would
// deploy": a clean target is shown clean, a conflict as conflict, a missing
Expand Down
99 changes: 85 additions & 14 deletions cmd/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -1097,7 +1097,7 @@ func captureTerminalDomain(cc captureCtx, domain string) (wrote bool, offered bo
// MANDATORY secret gate BEFORE any write: scan the WHOLE exported plist value. A
// high-confidence secret blocks every repo route (shared AND local); only reject
// / the out-of-repo secret store are offered.
gate := secret.GateValue(string(liveBlob))
gate := secret.GateValue(terminalGateInput(liveBlob))
if gate.BlockedFromRepo {
w, gerr := captureBlockedTerminal(cc, domain, prefID, repoDest, liveBlob)
return w, true, gerr
Expand Down Expand Up @@ -1135,6 +1135,62 @@ func captureTerminalDomain(cc captureCtx, domain string) (wrote bool, offered bo
}
}

// terminalGateInput reduces what the MANDATORY pre-write secret gate READS for a
// whole-plist terminal domain (both Apple Terminal's raw export and iTerm2's
// allowlist-filtered one): the base64 payload inside every XML plist
// <data>…</data> element is masked out. Only the gate INPUT changes — the routes,
// the gate's authority, and the bytes written on accept are all untouched; the
// blob that reaches the repo is still the raw export.
//
// A <data> element is base64 of binary NSKeyedArchiver output — how Apple
// Terminal stores every customised profile attribute (colours, font, cursor). The
// TEXT scanner cannot read into base64 anyway: a token encoded there is
// unmatchable by every named detector, so masking loses no real coverage. What it
// does remove is a permanent false positive — wrapped base64 lines clear the
// entropy detector's length and shape floors, so any customised Apple Terminal
// profile blocked the whole domain from the repo forever, leaving only reject and
// the (non-portable) secret store. <string> values, where a pasteable token would
// actually live, stay fully scanned.
func terminalGateInput(blob []byte) string {
const openTag, closeTag = "<data>", "</data>"
rest := string(blob)
var b strings.Builder
b.Grow(len(rest))
for {
open := strings.Index(rest, openTag)
if open < 0 {
break
}
payload := open + len(openTag)
end := strings.Index(rest[payload:], closeTag)
if end < 0 {
// UNTERMINATED <data>: the payload's extent is unknown, so nothing more
// is masked. Malformed input fails toward scanning MORE, never less.
break
}
b.WriteString(rest[:payload])
b.WriteString(maskBase64Payload(rest[payload : payload+end]))
rest = rest[payload+end:]
}
b.WriteString(rest)
return b.String()
}

// maskBase64Payload replaces every base64-alphabet character of a plist <data>
// body with 'x', leaving whitespace (and anything else) in place so the masked
// text keeps the original's line structure. A run of 'x' carries zero entropy and
// no digit, so it trips neither the entropy heuristic nor any named detector.
func maskBase64Payload(payload string) string {
return strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '+', r == '/', r == '=':
return 'x'
}
return r
}, payload)
}

// captureBlockedTerminal handles a terminal domain whose exported plist holds a
// high-confidence secret. It is NEVER written to shared or local (both live in the
// repo worktree); only reject or the out-of-repo secret store are offered, mirroring
Expand Down Expand Up @@ -1162,33 +1218,48 @@ func captureBlockedTerminal(cc captureCtx, domain, prefID, repoDest string, live
return false, err
}
fmt.Fprintf(cc.out, " %s: secret stored out-of-band in ~/.config/ferry/secrets-local; a placeholder was written to the repo\n", domain)
// The placeholder lands at the SHARED repo path, so a surviving per-machine
// overlay from an earlier [l]ocal capture would keep winning every comparison
// and apply would keep importing the stale overlay instead. Drop it on the same
// terms the shared accept does (the stored secret is neither re-read nor
// re-written here).
if err := removeSupersededLocalOverlays(cc.out, cc.repoPath, domain, prefID); err != nil {
return true, err
}
return true, nil
}

// acceptTerminalShared writes an accepted whole-domain export to the SHARED repo
// path and SUPERSEDES any per-machine local overlay for the same domain, so the
// shared accept actually converges.
func acceptTerminalShared(out io.Writer, repo, domain, prefID string, blob []byte) error {
dest := terminalRepoDest(repo, domain, prefID)
if err := writeRepoFile(repo, dest, blob); err != nil {
return err
}
fmt.Fprintf(out, " %s: captured -> shared (%s)\n", domain, relTo(repo, dest))
return removeSupersededLocalOverlays(out, repo, domain, prefID)
}

// removeSupersededLocalOverlays drops any per-machine local overlay for a terminal
// domain after something has been written to that domain's SHARED repo path (an
// accepted export, or the placeholder of a secret-routed capture).
//
// Every comparison of this domain (status's terminalLiveDiffers, capture's own
// compare, apply's terminalExportBlob) resolves LOCAL-WINS: while an overlay
// exists it shadows the shared copy. A shared capture written behind a surviving
// overlay therefore changed nothing observable — status reported drift forever,
// capture re-offered the domain forever, and apply kept importing the stale
// overlay. The accept is an explicit instruction to make THESE bytes the ones
// this machine carries, so the superseded overlay is removed and the removal is
// reported by path and reason.
// exists it shadows the shared copy. A shared write left behind a surviving
// overlay therefore changes nothing observable — status reports drift forever,
// capture re-offers the domain forever, and apply keeps importing the stale
// overlay. The capture is an explicit instruction to make the shared bytes the
// ones this machine carries, so the superseded overlay is removed and the removal
// is reported by path and reason.
//
// A removal failure is an ERROR, never a silent continue: leaving the overlay in
// place would keep the machine on the stale bytes while the capture reported
// success. An overlay that ferry REFUSES to read (symlinked/escaping — see
// regularRepoFile, which guards before it stats) never wins a comparison in the
// first place, so it is left exactly as found.
func acceptTerminalShared(out io.Writer, repo, domain, prefID string, blob []byte) error {
dest := terminalRepoDest(repo, domain, prefID)
if err := writeRepoFile(repo, dest, blob); err != nil {
return err
}
fmt.Fprintf(out, " %s: captured -> shared (%s)\n", domain, relTo(repo, dest))
func removeSupersededLocalOverlays(out io.Writer, repo, domain, prefID string) error {
// Probe BOTH overlay names apply's terminalExportBlob accepts (<id>.plist and
// the extensionless <id>), so no spelling of the overlay is left to shadow the
// shared copy on the next apply.
Expand All @@ -1206,7 +1277,7 @@ func acceptTerminalShared(out io.Writer, repo, domain, prefID string, blob []byt
if err := os.Remove(safe); err != nil {
return fmt.Errorf("remove superseded local overlay %s: %w", relTo(repo, cand), err)
}
fmt.Fprintf(out, " %s: removed the local overlay %s (superseded by this shared capture; it would otherwise keep winning over the shared copy)\n", domain, relTo(repo, cand))
fmt.Fprintf(out, " %s: removed the local overlay %s (superseded by this capture of the shared copy; it would otherwise keep winning over the shared copy)\n", domain, relTo(repo, cand))
}
return nil
}
Expand Down
11 changes: 5 additions & 6 deletions cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,12 @@ func isLocalLayerRel(slash string) bool {
// secretInPath reports whether any component of a forward-slash relative path is a
// high-confidence secret-shaped token (M10). Each component is scanned as an opaque
// value so a token used as a filename is caught.
//
// It delegates to secret.IsPathBlockedFromRepo, the ONE owner of the predicate:
// bundle import/validate applies the same function, so the export and import path
// gates cannot drift apart.
func secretInPath(slash string) bool {
for _, seg := range strings.Split(slash, "/") {
if secret.GateValue(seg).BlockedFromRepo {
return true
}
}
return false
return secret.IsPathBlockedFromRepo(slash)
}

// isProbablyText reports whether data is safe to run the text secret gate over. A
Expand Down
7 changes: 5 additions & 2 deletions cmd/guided_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,11 @@ func groupRisky(risky []planItem) []riskyGroup {
for _, it := range risky {
name := it.fileDomain
if name == "" {
// Non-FileDomain items (terminal preference domains) carry no
// fileDomain; they keep their pre-existing dotfiles-bucket home.
// Defensive default for an item that reached the walkthrough
// without a fileDomain stamp. Unreachable today — the plan driver
// stamps every FileDomain item, and preference items are filtered
// out before grouping — but a future planner that forgets the
// stamp degrades to a visible bucket instead of an unnamed group.
name = "dotfiles"
}
if !slices.Contains(order, name) {
Expand Down
15 changes: 10 additions & 5 deletions cmd/guided_apply_group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import "testing"
// groupRisky once bucketed every FileDomain it did not name into "dotfiles",
// so the walkthrough's group header and bulk consent named a domain the user
// was not reviewing. These tests pin the fix: every domain groups under its
// own name, in registry order, and only non-FileDomain items (which carry no
// fileDomain) fall back to the dotfiles bucket.
// own name, in registry order, and an item missing its fileDomain stamp
// (unreachable in production — the plan driver stamps every FileDomain item,
// and preference items are filtered out before grouping) degrades to the
// visible dotfiles bucket instead of an unnamed group.
func TestGroupRiskyGroupsEveryDomainUnderItsOwnName(t *testing.T) {
risky := []planItem{
{domain: "iterm2-profiles:p.json", fileDomain: "iterm2-profiles"},
Expand Down Expand Up @@ -44,12 +46,15 @@ func TestGroupRiskyNeverLumpsOtherDomainsIntoDotfiles(t *testing.T) {
}
}

func TestGroupRiskyFileDomainlessItemsKeepDotfilesBucket(t *testing.T) {
func TestGroupRiskyUnstampedItemsDegradeToDotfilesBucket(t *testing.T) {
// Hand-built state: no production path emits a risky item without a
// fileDomain stamp. The defensive default must keep such an item visible
// under a named bucket rather than an unnamed group.
risky := []planItem{
{domain: "iterm2 preference domain", fileDomain: ""},
{domain: "unstamped item", fileDomain: ""},
}
groups := groupRisky(risky)
if len(groups) != 1 || groups[0].name != "dotfiles" {
t.Fatalf("fileDomain-less item grouped as %+v, want the pre-existing dotfiles bucket", groups)
t.Fatalf("unstamped item grouped as %+v, want the dotfiles fallback bucket", groups)
}
}
Loading