Skip to content

fix: harden the guard, the release scanner and abcd's git calls - #883

Merged
REPPL merged 50 commits into
mainfrom
fix/security-batch-261009
Oct 10, 2026
Merged

REPPL merged 50 commits into
mainfrom
fix/security-batch-261009

Conversation

@REPPL

@REPPL REPPL commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

A batch of hardening fixes, landed together ahead of the v0.13.4 cut.

  • The shell guard reads a script a command points a shell at (an operand, source, a stdin redirect, a startup file the line selects) and judges it, and judges the text a line hands bash through trap, prompt variables and exported functions. The decision is recorded in a new ADR.
  • The release secret scanner no longer counts a file as scanned when only part of it was read, reads stacked encodings and archive extension bodies, and refuses a file a skip entry would only skim unless an exclusion with a reason names it.
  • abcd's git calls no longer start a program the repository's own configuration names: filters, diff drivers, merge drivers, signing and verification programs, automatic maintenance, and submodule children. A partial clone on a git too old to switch lazy fetching off is refused rather than fetched from.
  • Repository root discovery refuses an ancestor a checkout's own setting names, and the implement loop refuses a worktree or branch it did not make.

Each fix carries a test watched failing before it and passing after, and each change was reviewed independently. make preflight is clean on this head.

Assisted-by: Claude:claude-opus-5-5

REPPL added 30 commits October 9, 2026 23:35
The guard will read a shell script that the command line points a
shell at and judge it with the registry's Tier 1 rules, warning where
it cannot be sure which bytes run.

Assisted-by: Claude:claude-opus-5-5
The guard judged only the text of a command line, so a script file a
shell ran was an allow whatever it held: `bash s.sh`, `source s.sh` and
`BASH_ENV=e bash -c true`, the file form the stream refusal itself
recommended. The guard now reads a shell's script operand, a source
operand, BASH_ENV, and ENV for an interactive shell, and judges each
with the registry's command-position matches, carrying a match out
under script-runs-hazard with the script, line and entry. A script
written earlier on the same line is refused (script-written-then-run);
a binary handed to a shell blocks and an unreadable or oversized one
warns (script-unread); a direct run is classified by its first bytes;
reading shares the payload depth and a per-check budget. The hook
reads from the workdir or the session directory, the check verb from
its own. The stream and over-long refusals no longer recommend a file
the guard does not read.

Resolves: iss-2610090821484829
Assisted-by: Claude:claude-opus-5-5
bash knew --rcfile and --init-file only to step over their value, so
`bash --init-file <(...) -i -c true` ran a blocker while the checked
command read `bash -i -c true`. The value is now read and judged like a
script, and a process substitution there is refused as a stream. The
sibling sweep reads every startup file a shell-family shell takes from
a place the line names: the zsh files under an assigned ZDOTDIR or
HOME, a login or interactive bash's under an assigned HOME, the files a
login bash or zsh reads as it exits, and the profile and rc files of
dash, ksh, mksh and yash under an assigned HOME.

Resolves: iss-2610090821489740
Assisted-by: Claude:claude-opus-5-5
A shell with no -c string and no script operand reads its script from
standard input, and `bash < f` was an allow while `cat f | bash`
blocked: the stream record was set for a pipe, a here-document and a
here-string, never for a `<`. The guard now follows the descriptors a
line opens on files and reads the one a shell's standard input comes
from: a `<` or `0<` redirect, a descriptor duplicated onto stdin from a
file opened earlier on the line, an exec that redirects the shell's own
stdin, and the stdin of the shell that runs a string.

Resolves: iss-2610090932257904
Assisted-by: Claude:claude-opus-5-5
`function NAME { ...; }` defines a function exactly as `NAME() { ...; }`
does, but the keyword form kept `function` and the name in front of the
body in one segment, so the walk read `function` as the program and the
body as its arguments: a warn, which the hook lets run, where the POSIX
form's body was judged. The walk now steps over the keyword and its name,
as it steps over `coproc NAME`, so the body reaches command position. The
sibling sweep found zsh's `repeat COUNT`, the same shape, and steps it too.

Resolves: iss-2610090821313095
Assisted-by: Claude:claude-opus-5-5
`trap ACTION SIGNAL…` stores ACTION and the shell runs it as a command
line when the signal arrives, EXIT included, but the guard read ACTION
as a plain argument. ACTION is now read the way eval's arguments are; the
reset and list forms carry no command, and text the guard cannot read
keeps eval's verdict. mapfile/readarray -C evaluates its callback the
same way, so it is read the same way.

Resolves: iss-2610090821476887
Assisted-by: Claude:claude-opus-5-5
env and sudo take every operand that carries `=` before the command as
an environment assignment, but the walk stepped only identifier-named
ones, so it read a non-identifier word as the program. bash imports a
BASH_FUNC_<name>%% variable whose value starts `()` as a function, and
a command of that name runs its body. The walk now steps every such
operand, and an exported function's body is judged with the inline
rules.

Resolves: iss-2610090925399967
Assisted-by: Claude:claude-opus-5-5
bash expands PS4 before each traced command and PS0, PS1 and PS2 at an
interactive prompt, command substitutions included, and runs
PROMPT_COMMAND before each prompt, so a line that sets one and turns
tracing on or starts an interactive shell runs the text in it. The
guard now judges such a value wherever the line assigns it, decoding a
prompt's octal escapes first, and the guard page states what it reads.

Resolves: iss-2610090925390900
Assisted-by: Claude:claude-opus-5-5
… line wrote

A command the registry only warns on, met inside a script the guard
reads, no longer makes running the script warn, so the repository's own
scripts warn on none. A file the line writes and then runs by path now
blocks whatever it is, as running it through a shell already did. The
ADR records both refinements, and the guard page states them.

Refs: iss-2610090821484829
Assisted-by: Claude:claude-opus-5-5
The site bound now holds before any filesystem lookup, and lookups are
cached across the hook's two registries, so a line naming thousands of
scripts costs what sixty-four do. declare -x and typeset -x export a
file-selecting variable as export does, and export -n takes it away. A
script chain past the depth now blocks with the script named and its
own fix, a depth-exceeded classification reads through the budget, and
ln is a listed writer.

Refs: iss-2610090821484829
Assisted-by: Claude:claude-opus-5-5
The script reading's word dequoting reads a backtick as the start of a
command substitution, a shell grammar the allowlist entry already names,
so the tokenizer's count rises from 23 to 25.

Assisted-by: Claude:claude-opus-5-5
…ns in

`ln -s /path/s.sh` makes ./s.sh, but the writer list took the source as
the written file, so a script linked in and then run was allowed unread.
ln now has its own target rule, and a comment on declare states what the
code does.

Refs: iss-2610090821484829
Assisted-by: Claude:claude-opus-5-5
Assisted-by: Claude:claude-opus-5-5
…nned

The scanner's text branch sniffed the first 8 KiB for NUL bytes and
UTF-8 validity, then counted the whole file as read by the text rules.
Bytes past the window that are not text (a compressed member appended
to prose) were never decoded and never named as a coverage gap. The
branch now checks every byte; a file that fails is Unscanned with a
reason that says the head looked like text.

Siblings: the decoded-region rule (cover) already checks the whole
region. The lifeboat conventions and repolint privacy sniffs only skip
binary-looking files and claim no coverage, so they are unchanged.

Resolves: iss-2610090821502084
Assisted-by: Claude:claude-opus-5-5
…ream

Inflating a compressed PNG chunk stopped at the zlib checksum, and the
walk covered only what came out. Bytes the chunk's length still claimed
after the stream were read by nobody while the image was reported
decoded. inflate now returns that tail, and the zTXt, iTXt, iCCP and
IDAT arms cover it as a region, the same treatment a top-level zlib
trailer and a zip entry's post-stream bytes already get.

Siblings: decodeStream (zlib, bzip2) and zipEntryBody (deflate) already
cover their tails; gzip refuses trailing bytes. The tar extension
headers are fixed separately.

Resolves: iss-2610090821499579
Assisted-by: Claude:claude-opus-5-5
tar.Reader.Next consumes GNU long-name and long-link bodies and PAX
record sets ahead of the entry it returns, keeping only what it parses:
the string before a long name's first NUL, the last of two long names,
the last value of a repeated PAX key. The rest was read by nobody and
the archive was reported decoded. decodeTar now walks the raw bytes
Next consumed: a long name's string is a structural field, the bytes
after its NUL are covered as a region (a compressed member there is
decoded and scanned), a PAX body is a structural field whole, and each
body's block padding must be zero.

Siblings out of scope: header-block bytes the reader never parses (the
tail of a V7 header, the slack of an old GNU sparse extension block)
are covered by the raw byte scan only.

Resolves: iss-2610090821512707
Assisted-by: Claude:claude-opus-5-5
The percent view and the JSON escape layers each decoded the raw line,
and neither ever read the other's output, so a value spelled with both
stacked (a JSON escape of the percent sign, a percent encoding of the
backslash) was read by no view, by ScanText nor by Redact. lineViews
now also builds the JSON layers of the percent view and the percent
view of each JSON layer, each mapped back to the raw line through both
position maps, within the existing layer caps.

Siblings: the glued sweep, the literal-home backstop and DecodedViews
(the harness-leak and privacy lint rules) read lineViews and inherit
the composition. The pre-commit name guard already decodes a superset;
its comment saying the scanner does not is corrected in both copies.

Resolves: iss-2610090821491948
Assisted-by: Claude:claude-opus-5-5
… sparse header

Two windows of bytes tar.Reader.Next consumes were still read by nobody
while the archive was reported decoded. An extension header with no
entry after it is consumed by the Next call that then reports io.EOF,
and the walk broke on io.EOF before covering that call's window, so a
long name placed last carried an unread member. And Next reads past an
entry's own header for a sparse map (the extension blocks of an old GNU
sparse entry, the map block of a PAX sparse 1.0 entry), parsing only
part of it; the walk stopped at the entry's header. decodeTar now covers
the window on io.EOF too, and the bytes past the entry's header must be
zero beyond what the reader parsed, or the archive is not promoted.

Siblings out of scope: header-block bytes the reader never parses (the
tail of a V7 header, the slack of the four sparse entries inside an old
GNU header) are still covered by the raw byte scan only.

Refs: iss-2610090821512707
Assisted-by: Claude:claude-opus-5-5
Composing the two decoders once each way left a third alternation
unread: a percent escape of the backslash of a JSON escape of the
percent sign, and its mirror, each need three decodes in turn, and no
view read them. lineViews now runs two alternating chains, one opening
with each decoder, one pass at a time, turning to the other decoder
after each pass and staying with the same one only when the other
decodes nothing, up to four passes in all. Every pass is a view mapped
back to the raw line; a view an earlier one already holds is dropped.
The work per line stays a constant number of linear passes, and a
spelling deeper than four passes stays raw, the stated residual.

Siblings: the glued sweep, the literal-home backstop and DecodedViews
read lineViews and inherit the alternation.

Refs: iss-2610090821491948
Assisted-by: Claude:claude-opus-5-5
A skip fragment sent every path it matched to the byte-only branch,
which drops the identity and network rules, and the file was never
counted as a coverage gap: a fragment of "." matched every dotted path,
so an address in an included markdown file shipped while an undotted
LICENSE kept the zero-coverage sentinel quiet. A file a skip fragment
alone matches is now Unscanned with its reason, so the launch refuses
and names it; a file whose extension or name is on the reviewed skip
lists takes the byte branch as before.

A file left out on purpose is declared in the scanner config's new
exclude_path_fragments field, each entry a fragment and a required
reason. A matched file is reported excluded by choice with that reason
(scan.excluded, scan.excluded_why, and a count in the gate row), is
never read or counted as scanned, and does not refuse; a bundle the
exclusions leave with no file scanned in full still trips the
zero-coverage sentinel. A blank fragment or a missing reason is a
config fault and the scanner fails closed. commands/launch.md documents
both report fields and the config field.

Siblings: skip_filenames match a whole name and skip_dirs is parsed but
unused. A config-added skip extension still takes the byte branch.

Resolves: iss-2610090821506490
Assisted-by: Claude:claude-opus-5-5
…unscanned

An extension or filename a repository added through skip_extensions or
skip_filenames in its scanner config joined the reviewed skip sets, so a
file it matched took the byte-only branch, which drops the identity and
network rules, and was never counted as a coverage gap: a repo-added
".md" let an address in a markdown file ship. Only abcd's bundled
binary lists now count as reviewed. A file a repo-added entry alone
matches is Unscanned with the entry named in its reason, exactly like a
file a skip fragment alone matches, so the launch refuses and names it;
a declared exclusion (exclude_path_fragments) still leaves it out by
choice, and a bundled extension such as .png takes the byte branch as
before. commands/launch.md says so, and notes that an extension-shaped
fragment such as ".jar" is enough to exclude a kind of file, matching
anywhere in the path.

The plaintext ScannedBinary tier is now reachable only from the bundled
lists, which carry no plaintext name; its test places the name itself.

Refs: iss-2610090821506490
Assisted-by: Claude:claude-opus-5-5
An exclusion fragment such as "." matches nearly every path, so it names
no part of the tree a reviewer can weigh; it now makes the scanner
unavailable, as a blank fragment does. The decoders' pass-cap comments
now say the alternating chain may read one layer deeper than the
pre-passes.

Refs: iss-2610090821506490
Assisted-by: Claude:claude-opus-5-5
Every isolated git command now carries log.showSignature=false, kept with
the hooks and fsmonitor pins in one shared list (gitutil.ExecPins), so a
log or show over a signed commit no longer starts the repository's
gpg.program. The lifeboat probe, the site history load, the mention walk
and the decisions-append subject read all reach git through it.

Resolves: iss-2610090821531394
Assisted-by: Claude:claude-opus-5-5
The launch dirty check's diff against HEAD re-hashes the working tree when
the index stat no longer matches, and did so through the repository's own
filter.<name>.clean or .process program. gitutil.FilterOverrides now
blanks every configured filter before the subcommand, so the check
compares bytes, still lists a real edit, and fails closed on a filter the
repository marks required. DirtyPayloadFiles reads through the same call.

Resolves: iss-2610090821548169
Assisted-by: Claude:claude-opus-5-5
The armed agent-contract bump check parsed a unified diff that git handed
to the repository's diff.external, diff.<driver>.command or
diff.<driver>.textconv program, so that program ran and its stdout decided
whether a prompt_version bump was seen. The diff now passes --no-ext-diff
and --no-textconv, as the decisions-append diff already does, and both of
the check's diffs blank the repository's content filters first, since a
single-revision range compares the working tree.

Resolves: iss-2610090821531570
Assisted-by: Claude:claude-opus-5-5
The pick's record commit and a sync's merge commit run through pickGit,
which keeps the repository's config, so a repository-local
commit.gpgsign=true started gpg.program, gpg.ssh.program or
gpg.ssh.defaultKeyCommand under a commit that otherwise runs no hook.
commit.gpgsign=false joins the shared pin list (gitutil.ExecPins), and
pickGit now prepends that list rather than its own copy of the pins.
Content filters still run on the commit.

Resolves: iss-2610090821520843
Assisted-by: Claude:claude-opus-5-5
A repository that sets merge.verifySignatures=true makes git merge verify
the merged tip's signature, which starts gpg.program or gpg.ssh.program
for a tip whose commit object carries a gpgsig header. The sync's merge
goes through pickGit, which keeps the repository's config, so it ran that
program. merge.verifySignatures=false joins the shared pin list
(gitutil.ExecPins), which pickGit and every isolated command prepend.

Refs: iss-2610090821520843
Assisted-by: Claude:claude-opus-5-5
The consistency pass's dirty check diffs the working tree against the
pinned commit, and over a corpus whose index stat no longer matches git
re-hashes each document through the repository's clean filter, a program
the repository names. dirtyCorpusPaths now prepends
gitutil.FilterOverrides, as the launch dirty check does, so the diff
compares bytes; a filter git still insists on fails the read rather than
reading as clean. The emit and the ingest both reach it.

Refs: iss-2610090821548169
Assisted-by: Claude:claude-opus-5-5
The release receipts check lists what is uncommitted under the reviews
directory with git status, and over receipts whose index stat no longer
matches git re-hashes each one through the repository's clean filter, a
program the repository names. The status now runs under
gitutil.FilterOverrides, so it compares bytes; a filter git still insists
on fails the check rather than reading as committed.

Refs: iss-2610090821548169
Assisted-by: Claude:claude-opus-5-5
The command reference now says, in present tense, that the pick's record
commit and a sync's merge commit run no hook and are unsigned even where
the operator's git configuration signs every commit, that the sync's
merge does not verify the merged commit's signature, and that the land
commit is signed as that configuration says. abcd launch and abcd launch
ship now carry a long help stating that the dirty-tree comparison runs
with the repository's content filters switched off, and that a filter
marked required refuses the comparison over a tree whose saved file
timestamps do not match, which the gate and the cut report as unreadable
rather than clean.

Refs: iss-2610090821520843
Refs: iss-2610090821548169
Assisted-by: Claude:claude-opus-5-5
REPPL added 20 commits October 9, 2026 23:35
The implement loop's lane sync merges the default branch in through
pickGit, which keeps the repository's config, so a merge driver the
repository configures, selected by an attribute or by merge.default,
ran as the operator wherever both sides changed a path and its bytes
became the merge result.

gitutil.MergeDriverOverrides enumerates every merge.<name> the
repository configures, through the same isolated config view and the
same refusal of a name git -c cannot carry as FilterOverrides (both now
share configNames), and replaces each driver with git merge-file over
the three versions, with git's marker size, conflict labels and
histogram diff, and pins merge.default to the built-in text driver. The
result, clean or conflicted, is byte for byte git's built-in merge. A
merge the operator runs is untouched. pickGit names the subcommand past
any leading -c overrides in its errors.

Resolves: iss-2610090821510097
Assisted-by: Claude:claude-opus-5-5
Four test files this branch added spawned git without the shared gittest
helper, which TestTestGitCallsAreHermetic refuses. The two in core take
their environment from gittest.Env; the two in gitutil move to the
external test package, where gittest can be imported without a cycle,
and call the exported functions they test.

Refs: iss-2610090821531394
Refs: iss-2610090821548169
Assisted-by: Claude:claude-opus-5-5
gitutil.Status, the one reader of git status behind abcd peers, the
capture ledger's uncommitted marks, the cold-reading assembler's dirty
check and the interview's tree watch, ran a working-tree status with
the repository's content filters live, so a file whose saved stat no
longer matched was re-read through filter.<name>.clean, a program the
repository names.

Status now blanks every configured filter (gitutil.FilterOverrides)
unless the person lists the checkout in ~/.abcd.noindex/filter-roots,
read through fsutil.HomeDeclarationNames as trusted-roots is: one
absolute path per line, honoured only while the file is a regular file
this account owns that no one else can write, reached through no
symlinked folder. The file lives in the home because a repository could
otherwise switch its own filters on. The install guide documents it.

gitutil now imports abcdhome, so abcdhome's two worktree-repair tests,
which build their fixtures with gittest, move to the external test
package to keep the import graph acyclic.

Refs: iss-2610090821548169
Assisted-by: Claude:claude-opus-5-5
gitutil.Status, the launch dirty check, the consistency pass's dirty
check and the release-receipts status blanked every content filter the
superproject configures, but without --ignore-submodules git starts a
status inside each checked-out submodule, which reads the submodule's
own config and so ran a clean filter the overrides could not name.

Each now passes --ignore-submodules=dirty (the flag, since a
repository's submodule.<name>.ignore=none beats the diff.ignoreSubmodules
config), and so does the agent-diff path list, whose one-revision range
compares the working tree. A submodule moved to another commit still
reads as a change; uncommitted content inside one no longer does, which
the launch and launch ship help, the generated command reference and the
install guide now state. gittest gains AddSubmodule for the fixtures.

Resolves: iss-2610091935327982
Assisted-by: Claude:claude-opus-5-5
With diff.submodule=diff in a repository's config, a patch diff over a
moved submodule pointer starts a second git diff inside the submodule.
That child reads the submodule's own config and is passed none of the
parent's --no-ext-diff or --no-textconv, so record-lint's patch diffs
(decisions-append, agent-diff) could run a diff.external or textconv
program the submodule names and parse its output.

gitutil.ExecPins now forces diff.submodule=short, so every isolated
command, and pickGit which prepends the same list, shows a submodule as
its pointer change alone.

Resolves: iss-2610091935325886
Assisted-by: Claude:claude-opus-5-5
pickGit keeps the repository's config through ScrubbedEnv, which lacks
the isolated environment's maintenance pins, so a repository with a low
gc.auto or gc.autoPackLimit made the pick commit or the sync merge start
maintenance run --auto and gc --auto, and with them gc.recentObjectsHook,
a program the repository names; a merge in a partial clone could also
fetch a missing object through the promisor remote.

pickGit now passes -c gc.auto=0 -c maintenance.auto=false, where they
outrank the repository's config, and runs with GIT_NO_LAZY_FETCH=1.

Resolves: iss-2610091935334207
Assisted-by: Claude:claude-opus-5-5
A ~/.abcd.noindex/filter-roots file that fails its ownership, mode or
symlink checks was ignored without a word: Status discarded the reason,
so a checkout its owner listed to keep its content filters running read
with them off and nothing said why.

FiltersSwitchedOn now renders the reason as one line naming the file in
tilde form and the check it failed, the way the trusted-roots and
local-transcript-roots readers do, and gitutil.FilterRootsIgnored gives
it to a front door. abcd ahoy reports it from any folder as the
report-only machine-scope gap filter_roots.ignored. The status reads
themselves have no output channel and still drop it. The ahoy command
page and the install guide name the gap.

Resolves: iss-2610091920437492
Assisted-by: Claude:claude-opus-5-5
The command reference now says, in present tense, that the pick's record
commit passes the entry it stages through the repository's clean filter
and that a sync's merge passes each file it writes through the smudge
filter, as Git LFS needs, in the lane's worktree, where the implementer
already runs the repository's own code.

Refs: iss-2610090821520843
Assisted-by: Claude:claude-opus-5-5
The board named an ignored filter-roots file by its title alone, while
the docs promise the check it failed; the check and its repair now print
on a filters line, as the unlinked-worktree line does.

Refs: iss-2610091920437492
Assisted-by: Claude:claude-opus-5-5
A repo-local core.worktree is resolved relative to the .git directory, so
`core.worktree=../..` named the checkout's parent as the toplevel. That
answer contains the directory asked about, so the containment check let it
through and every store addressed through CheckoutRoot (decide, intent,
spec, memory, capture) was laid in the parent.

Toplevel now asks git for --absolute-git-dir in the same invocation and
accepts the toplevel only when its .git is that directory, or a gitfile
naming it (linked worktree, submodule, separate git dir).

iss-2610090821543020

Resolves: iss-2610090821543020
Assisted-by: Claude:claude-opus-5-5
In a partial clone, git answers a read of a missing object by fetching it,
and the fetch runs the transport the repository's own config names
(remote.<name>.uploadpack for a local URL, core.sshCommand for ssh://).
gitEnv now sets GIT_NO_LAZY_FETCH=1, so such a read fails and starts no
program. A present object still reads. Run, RunLimited and IsolatedEnv
share gitEnv, so the tag listing is covered by the same line.

iss-2610090821527948

Resolves: iss-2610090821527948
Assisted-by: Claude:claude-opus-5-5
The brief stage refused a lane worktree that was not the path derived from
the run and lane, but the land stage only checked the string was non-empty,
so a hand-written state file had the close lay the local tier in another
directory. The check is now one helper applied wherever the lane's worktree
is acted on or handed out: land (before and after the landing is recorded),
implement and validate (the driver starts an agent there), and the two
worktree removals (hold discard and hand-back discard), which a planted
state could aim at a peer's worktree of the same repository.

iss-2610090821552801

Resolves: iss-2610090821552801
Assisted-by: Claude:claude-opus-5-5
GIT_NO_LAZY_FETCH, which the isolated environment sets so a missing object
in a partial clone is an error rather than a fetch through the transport the
repository configures, is honoured from git 2.44; an older git ignores it,
and Apple's Command Line Tools ship 2.39. One check in gitutil now runs
before every isolated git command: it reads `git version` once per process
and, below 2.44, refuses a command that can read objects in a repository
declaring extensions.partialClone or a true remote.<name>.promisor (read
through the same isolated config view), naming the floor. Commands that
read only config, refs or the index (flag-only rev-parse, config,
check-ignore, symbolic-ref, ls-files, worktree list) are exempt, so root
discovery still answers; a repository with no promisor remote is
unaffected. The tag listing now goes through gitutil.Run and so takes the
same check. The resolved record states the floor, and the README's
requirements name it.

Refs: iss-2610090821527948
Assisted-by: Claude:claude-opus-5-5
The hand-back discard refused a branch outside the loop's prefix, but the
hold discard (`implement step --discard`) deleted the lane's branch from the
state alone, so a hand-written state naming `main` deleted the default
branch. It now refuses such a branch before it removes anything. The land
stage, which pushes the lane's branch and deletes it once landed, takes the
same refusal: with main's tip as the judged head it pushed `main`.

Refs: iss-2610090821552801
Assisted-by: Claude:claude-opus-5-5
… directory

git follows a .git that is a symlink to the repository's git directory, but
the toplevel identity check read the entry without following it, so such a
checkout had no root. A symlink whose target is the discovered git
directory is now held as a gitfile naming it is.

Refs: iss-2610090821543020
Assisted-by: Claude:claude-opus-5-5
The partial-clone floor exempted check-ignore and ls-files as reading only
the index, but git reads a skip-worktree .gitignore or .gitattributes that is
missing from disk out of the object store. check-ignore is now exempt only
with --no-index; ls-files only when every flag is an index-only listing flag
and no pathspec carries attr magic, so the exclude flags, --with-tree, --eol,
--format and -m count as reading objects; config --blob counts too. On git
older than 2.44 a repository declaring a promisor remote refuses them before
git starts; a repository with none runs every form as before.

Resolves: iss-2610091935324732
Assisted-by: Claude:claude-opus-5-5
… 2.44

Below git 2.44, in a partial clone whose config enables core.sparseCheckout
or index.sparse (read through the same isolated config view, or set by a -c
on the command line), every ls-files now counts as reading objects and is
refused: expanding a sparse index reads tree objects. A partial clone
without those settings keeps the index-only allowance.

The lifeboat probe's ignore listing and lint's ignore pruning no longer
treat that refusal as "git could not answer". Probe and Plan refuse with the
floor named instead of widening the default scan to ignored files, and a
walk that runs anyway narrows every path; IgnoredUnder returns the refusal,
so Lint, DocumentsInRoots and PrunedInRoots report it rather than silently
not pruning. Other git errors keep their handling. SwapGitVersionForTest
lets a test outside gitutil put git on either side of the floor.

Refs: iss-2610091935324732
Assisted-by: Claude:claude-opus-5-5
git expands a sparse index by reading tree objects whenever the index
file carries the sparse-directory extension, whatever the config says,
so the config could not vouch for an index-only ls-files: below the
floor, a partial clone now refuses every ls-files.

Refs: iss-2610091935324732
Assisted-by: Claude:claude-opus-5-5
The restart derived and checked the lane's worktree path with its own
copy of the check every other stage makes through loopWorktree. Both now
ask derivedLaneWorktree; the restart still also holds the branch to the
loop's.

Assisted-by: Claude:claude-opus-5-5
The test committed with no user.name or user.email, which passes where git
can guess an identity and fails on a CI runner where it cannot.

Assisted-by: Claude:claude-opus-5-5
@REPPL
REPPL added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 3f21f58 Oct 10, 2026
14 checks passed
@REPPL
REPPL deleted the fix/security-batch-261009 branch October 10, 2026 04:20
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.

1 participant