Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
ddb9de7
docs: record how the guard judges a script a command runs
REPPL Oct 9, 2026
445e674
fix: read a script a shell runs before judging the command
REPPL Oct 9, 2026
a975439
fix: read the startup files a shell command line selects
REPPL Oct 9, 2026
721c124
fix: read the file a redirection makes a shell's script
REPPL Oct 9, 2026
b8f4f09
fix: judge the body of a function the function keyword defines
REPPL Oct 9, 2026
d51be16
fix: judge the command a trap action stores
REPPL Oct 9, 2026
593c1eb
fix: judge a function env exports into a shell's environment
REPPL Oct 9, 2026
a2e5d52
fix: judge the commands a prompt variable runs
REPPL Oct 9, 2026
a577c5d
fix: carry only blocks out of a script, and block a run of a file the…
REPPL Oct 9, 2026
e91de1a
fix: bound script lookups before they run, and follow declare -x
REPPL Oct 9, 2026
a549234
test: count the shell tokenizer's two new backtick scans
REPPL Oct 9, 2026
e701ef4
fix: place the link a single-operand ln writes in the directory it ru…
REPPL Oct 9, 2026
1b274b1
test: format the script review cases
REPPL Oct 9, 2026
226cc2d
fix: require a text file to be text throughout before counting it sca…
REPPL Oct 9, 2026
fa50ee7
fix: cover the bytes a compressed PNG chunk carries after its zlib st…
REPPL Oct 9, 2026
7058fd8
fix: cover the tar extension header bodies the archive reader consumes
REPPL Oct 9, 2026
1097681
fix: decode stacked JSON and percent escapes in the scanner's line views
REPPL Oct 9, 2026
345dbc0
fix: cover the tar header bytes read before the end marker and past a…
REPPL Oct 9, 2026
1439bd3
fix: alternate the scanner's JSON and percent decoders to a fixed point
REPPL Oct 9, 2026
3c527d1
fix: count a file a skip fragment alone matches as unscanned
REPPL Oct 9, 2026
22c3979
fix: count a file a repo-added skip extension or filename matches as …
REPPL Oct 9, 2026
95872f2
fix: refuse a scanner exclusion of punctuation alone
REPPL Oct 9, 2026
f5c472e
fix: keep read-only git log from verifying commit signatures
REPPL Oct 9, 2026
0ecd81f
fix: refuse a configured filter during the launch dirty check
REPPL Oct 9, 2026
c4228ee
fix: read the agent prompt bump from git's own diff
REPPL Oct 9, 2026
70807f6
fix: make the pick record commit without a signing program
REPPL Oct 9, 2026
60cc36c
fix: keep the sync merge from verifying the merged tip's signature
REPPL Oct 9, 2026
e91062f
fix: blank content filters in the consistency pass's dirty check
REPPL Oct 9, 2026
968e198
fix: blank content filters in the release receipts uncommitted check
REPPL Oct 9, 2026
da0af47
docs: state the unsigned loop commits and the filter-off dirty checks
REPPL Oct 9, 2026
5498f64
fix: make the sync merge use git's built-in merge on every path
REPPL Oct 9, 2026
5a1190f
test: run the config-pin fixtures' git through the hermetic helper
REPPL Oct 9, 2026
2abee88
fix: switch content filters off in the working-tree status reads
REPPL Oct 9, 2026
9ab704e
fix: keep the filter-free working-tree reads out of submodules
REPPL Oct 9, 2026
3e0a4cf
fix: show a submodule as a pointer change in every isolated diff
REPPL Oct 9, 2026
cbf0212
fix: start no automatic gc from the pick commit and the sync merge
REPPL Oct 9, 2026
b7570d7
fix: report an ignored filter-roots file on the ahoy board
REPPL Oct 9, 2026
142a730
docs: state that the pick commit and the sync merge run content filters
REPPL Oct 9, 2026
18bd26f
fix: show why ahoy ignored a filter-roots file on the text board
REPPL Oct 9, 2026
8b78af5
fix: refuse a toplevel that does not hold the discovered git directory
REPPL Oct 9, 2026
37623e7
fix: stop isolated git reads lazily fetching a missing object
REPPL Oct 9, 2026
83f0927
fix: hold every loop stage to the worktree path the loop derives
REPPL Oct 9, 2026
bfb8f7b
fix: refuse object reads in a partial clone on git older than 2.44
REPPL Oct 9, 2026
35c49aa
fix: refuse a held or landing lane whose branch the loop did not make
REPPL Oct 9, 2026
e8b3c1c
fix: accept a symlinked .git when checking the toplevel holds the git…
REPPL Oct 9, 2026
6561742
fix: count ignore and attribute lookups as object reads below git 2.44
REPPL Oct 9, 2026
82ceddf
fix: refuse sparse listings and fail closed on ignore scans below git…
REPPL Oct 9, 2026
5035f79
fix: refuse every index listing in a partial clone below git 2.44
REPPL Oct 9, 2026
97e95be
refactor: answer where a lane's worktree is in one place
REPPL Oct 9, 2026
d134c70
test: give the toplevel test's fixture commit an explicit identity
REPPL Oct 10, 2026
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
21 changes: 17 additions & 4 deletions .abcd/development/brief/04-surfaces/17-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ the same release, with no second edit. A hazard the guard reads in code rather
than from the registry, such as `git-stash-shared-stack` (a bare `git stash` in
a checkout with more than one worktree) or `interpreter-reads-stream` (a shell
handed its script through a pipe, as in `cat x | sh`), is enforced but not
taught. The registry taught is the one the guard
taught; the scripts the guard reads are taught by one rule the generator adds
after the entries'. The registry taught is the one the guard
enforces in the repository: an entry the repository adds in its
`.abcd/guard.json` is taught by the same generator as the bundled ones, its
rule marked `(repo)` after its entry id, and a guard file the guard refuses is
Expand Down Expand Up @@ -379,7 +380,17 @@ script, a `source` of one, and a line longer than the guard reads. An unquoted
brace group is expanded as bash expands it and every word it produces is
checked, so `mkdir -p foo/{a,b}` passes and `git push {--force,} origin main`
blocks; a group past the expansion cap is refused rather than read in part. A
command string handed to a shell is opened and read. A git alias declared on the same command line is resolved, and the
command string handed to a shell is opened and read, and so is a script file a
shell runs: its script operand, a `source`d file, the file a redirection makes
its standard input, a path run directly that its first bytes show is a shell
script, and the startup files the line selects (`BASH_ENV`, `ENV`,
`--rcfile`/`--init-file`, and the zsh and bash startup files under an assigned
`ZDOTDIR` or `HOME`). It is judged by the registry's command-position matches,
which are carried out naming the script, the line and the entry; a script
written earlier on the same line is refused, because the file read at check
time is not the one that runs, and a write the guard cannot place before it
warns
([adr-2610091150447054](../../decisions/adrs/2610091150447054-the-guard-reads-a-script-the-command-names-before-it-judges.md)). A git alias declared on the same command line is resolved, and the
command git would actually run is what gets checked. A commit or push that
moves `core.hooksPath` for itself is read as skipping its hooks, which is what
it does. A delete chained after `pushd` or `popd` is read as one chained after
Expand Down Expand Up @@ -538,8 +549,10 @@ string, as in `n="1 + --$x"` for an integer `n`),
since every line is read from the default IFS; a pid list a kill reads through a variable or a file, or from a `ps |
grep` chain;
a payload inside a non-shell interpreter such as `python -c`, which is
one opaque token and today a silent allow; and any dangerous form no entry
describes. Nor does an allow see what a variable carries in from an earlier
one opaque token and today a silent allow; another interpreter's file, a
program, the account's own startup files, file text substituted into a command
string, and a script changed between the check and the run; and any dangerous
form no entry describes. Nor does an allow see what a variable carries in from an earlier
command: a pid list (`p=$(pgrep make); kill $p`), a stream path handed to a
shell, shell text run through `eval "$X"` or placed in a string a shell runs,
or `pkill` or `killall` as a variable's value standing as the program with an
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
schema_version: 1
id: "iss-2610090821313095"
slug: "guard-function-keyword-body-only-warns"
severity: "minor"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/guard/match.go"
remedy: "Parse `function name { ...; }` (and `function name() {`) where `name()` is parsed and judge the body as command text, a body the tokenizer cannot read being a block; prove it with a guard verdict-table test (watched fail first) that `function f { git push --force origin main; }; f` and its newline form are block / git-push-force, `function f { git status; }; f` stays allow and the POSIX form stays a block; sweep siblings (other compound-command keywords the reserved-word walk does not step over)."
resolution: "The walk to command position steps over the function keyword and its name, as it steps over coproc NAME, so a function NAME { ... } body is judged like the other function forms and a blocker in it blocks; zsh's repeat COUNT, the same shape, is stepped too."
impact: fix
---

`abcd guard` only warns on a bash `function f { ...; }; f` whose body is a blocker, and the PreToolUse hook lets a warning run, so the function executes.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `function` is not in the reserved-word set the walk steps over before command position (internal/core/guard/match.go:158, `reserved`), so the keyword form falls to unrecognised-launcher while the POSIX form `f() { ...; }` has its body judged. The hook maps a warn to exit 1, which surfaces the message and lets the tool run; only a block (exit 2) stops it (internal/surface/cli/guard.go:455-466).
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
schema_version: 1
id: "iss-2610090821476887"
slug: "guard-allows-trap-command-string"
severity: "major"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/guard/payload.go"
remedy: "Read `trap`'s command operand the way `evalPayload` reads `eval` (drop a leading `--`, take the command word, send it back through payload expansion; an unreadable string is a block; `trap - EXIT` and `trap EXIT` stay allow); prove it with a verdict table beside the eval fixtures (watched fail first) in which every trap spelling in the reproduction is block / git-push-force, `trap - EXIT` and `trap 'echo hi' EXIT` stay allow and the DEBUG form with a following `true` blocks; sweep siblings (other builtins whose operand is a command string the shell runs later)."
resolution: "the guard reads trap's ACTION (and mapfile/readarray -C's callback) as eval's arguments are read: a readable string is judged, the reset and list forms carry no command, and text the guard cannot read keeps eval's verdict rather than the remedy's block, by the coordinator's ruling"
impact: fix
---

`abcd guard` allows a blocker written as the command string of `trap`, and bash runs it, an EXIT trap needing no further command.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `evalPayload` joins `eval`'s operands and the guard re-reads that string, which is why `eval -- 'git push --force origin main'` is a block (internal/core/guard/payload.go:1578). Nothing walks the command operand of `trap`. The hook maps an allow to exit 0 and only a block to exit 2 (internal/surface/cli/guard.go:455-470).
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
schema_version: 1
id: "iss-2610090821484829"
slug: "guard-allows-bash-env-sourced-file"
severity: "major"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/guard/payload.go"
remedy: "Needs a decision: for the file form the successor recommends (`bash /tmp/s.sh`, `source /tmp/s.sh`), either the guard refuses a shell whose script operand is a path it has not read, or the successor stops recommending it and the brief names it a residual; then block a `BASH_ENV=` prefix (and `ENV=` on `bash --posix`) on a shellFamily shell as interpreter-reads-stream even when the -c payload is harmless, proved by a guard test (watched fail first) that `BASH_ENV=/tmp/e bash -c true` and the `--noprofile --norc` form block, `bash -c true` and `bash -c 'git status'` stay allow and both file forms pin the chosen verdict; sweep siblings (every startup-file variable a shellFamily shell honours)."
resolution: "The guard reads a script a shell runs before it judges the command (adr-2610091150447054): a script operand, a source operand, BASH_ENV and ENV are read and judged with the registry's command-position matches, a script written earlier on the same line is refused, and the stream refusal's successor no longer recommends an unread file."
impact: fix
---

`abcd guard` allows `BASH_ENV=<file> bash -c true`, and non-interactive bash sources that file before `-c`, so a blocker written to the file in the same command runs.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `shellReadsStream` treats a `-c` string as the payload and does not look at an assignment prefix for `BASH_ENV` (internal/core/guard/payload.go:1860). The stream block's own successor tells the caller "to run a script, save it and run it as a file after reading it" (internal/core/guard/payload.go:1971), and the file form it recommends is also an allow that bash runs, so closing `BASH_ENV` alone leaves the file channel open.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
schema_version: 1
id: "iss-2610090821489740"
slug: "guard-steps-over-bash-init-file"
severity: "major"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/guard/payload.go"
remedy: "In `shellReadsStream`, treat an `--init-file` or `--rcfile` value as a script the shell executes: a process substitution there returns true, and a path is a file the guard has not read, failing closed on interpreter-reads-stream; prove it with a guard test (watched fail first) that the three reproduction lines are block / interpreter-reads-stream, `bash --version` and `bash -i -c true` stay allow and the process-substitution script stays a block; sweep siblings (other value options in shellStreamValueOptions and other shells' startup-file options)."
duplicates: [iss-2610090821484829]
resolution: "A startup file the command line selects is read before the guard judges the command (adr-2610091150447054 decision 1): a --rcfile or --init-file value is read and judged, and refused as a stream when it is a process substitution; the zsh startup files under an assigned ZDOTDIR or HOME, a login or interactive bash's under an assigned HOME, the logout files and the other shell-family members' profile and rc files are read the same way."
impact: fix
---

`abcd guard` steps over the value of `bash --init-file` and `--rcfile`, and interactive bash runs that file before `-c`, so a blocker in it runs while the checked command reads `bash -i -c true`.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `shellReadsStream` knows the two options only to skip their value (internal/core/guard/payload.go:1914, the list `shellStreamValueOptions` at :1943). The same process substitution in script position is a block through `readsScriptStream` (internal/core/guard/payload.go:1816).
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
schema_version: 1
id: "iss-2610090821491948"
slug: "scanner-misses-stacked-json-percent-encoding"
severity: "major"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/adapter/scanner/percent.go"
remedy: "After each JSON layer and after the percent view, run the other decoder (bounded by the existing layer caps) and map every hit back to the raw line, and correct the jsonescape.go comment; prove it with a scanner test (watched fail first) that the three reproduction payloads hard-fail and `Redact` leaves neither the token nor the encoded form, while the plaintext, single-percent and single-\\uXXXX controls still hard-fail; sweep siblings (every pair of decoded views lineViews builds)."
resolution: "lineViews now runs each decoder over the other's output once each way (JSON layers of the percent view, the percent view of each JSON layer), every composed view mapped back to the raw line, and the comment that said the two never compose is corrected."
impact: fix
---

The secret scanner misses a token or home path written as a JSON escape stacked on a percent encoding (or the reverse), because it never runs one decoder on the other's output, so `ScanText` reports nothing, `Redact` leaves it, and the launch gate ships it.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `lineViews` builds one percent view of the raw line and then JSON layers of that same raw line (internal/adapter/scanner/percent.go:61). The comment at internal/adapter/scanner/jsonescape.go:45 claims the two do not compose, yet `jsonUnescapeOnce` emits `%` as `%` (internal/adapter/scanner/jsonescape.go:100). `ScanBundle` counts the text file as fully scanned (internal/adapter/scanner/scanner.go:1225), and `scanRefusals` (internal/core/launch/dryrun.go:269) refuses only an unavailable scanner, a kept hard-fail or an Unscanned path. History and memory call the same `ScanText`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
schema_version: 1
id: "iss-2610090821499579"
slug: "scanner-png-chunk-tail-after-zlib-unscanned"
severity: "major"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/adapter/scanner/container.go"
remedy: "Return the unread suffix from `inflate` and cover it on its own in every chunk caller (zTXt, compressed iTXt, iCCP, IDAT), a suffix that cannot be covered being Unscanned; prove it with a scanner test (watched fail first) that the reproduction PNG hard-fails or is Unscanned, a PNG whose zTXt is only the zlib member still decodes, and a token inside inflated IDAT pixels stays the documented residual; sweep siblings (every caller of inflate)."
resolution: "inflate now returns the bytes a PNG chunk carries after its zlib stream, and every compressed chunk (zTXt, iTXt, iCCP, IDAT) covers that tail as a region, so a member hidden there is decoded and scanned."
impact: fix
---

The secret scanner reports a PNG as content-decoded while bytes inside a compressed chunk after the zlib checksum are never inflated or scanned, so a gzip member there ships through the launch gate.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `decodeBudget.inflate` uses `zlib.NewReader`, which stops at the Adler-32, and returns only the inflated bytes (internal/adapter/scanner/container.go:236). The `zTXt` arm covers only that output (internal/adapter/scanner/container.go:970) and `decodePNG` returns decoded after IEND (internal/adapter/scanner/container.go:951). `decodeStream`, the top-level zlib path, does cover the unread tail (internal/adapter/scanner/container.go:329). The same unread tail exists for compressed `iTXt`, `iCCP` and `IDAT`. Distinct from a private security report (the skip that never opened the PNG). A PNG-only bundle is refused by the zero-coverage sentinel; the bypass needs one other full-text file, which the include list already has.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
schema_version: 1
id: "iss-2610090821502084"
slug: "scanner-text-sniff-8192-bytes-counts-as-scanned"
severity: "major"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/adapter/scanner/scanner.go"
remedy: "On the text branch, require the whole file to be valid UTF-8 with no NUL or send it through `decodeContent`, anything not fully accounted being Unscanned; prove it with a scanner test (watched fail first) that the prose-plus-gzip file is refused, a normal markdown file still scans and the `.gz` control still hard-fails; sweep siblings (every other classification decided on a sniffed prefix)."
resolution: "The text branch now requires the whole file to be text (no NUL, valid UTF-8), not the first 8 KiB; a file that is not is Unscanned with its reason, so the launch gate refuses it."
impact: fix
---

The secret scanner counts a file as fully scanned when only its first 8192 bytes are valid UTF-8 text, so a gzip member after a page of prose in an included markdown file ships with no finding.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `isText` sniffs 8192 bytes (internal/adapter/scanner/scanner.go:1519). The text branch then runs `ScanText` on the raw bytes and increments FilesScanned without calling `decodeContent` (internal/adapter/scanner/scanner.go:1221-1225). `cover` already refuses an 8192-byte sniff as coverage of a decoded region. `docs/` is an include, and the bundler does not inspect content before inclusion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
schema_version: 1
id: "iss-2610090821506490"
slug: "scanner-dot-skip-fragment-passes-addresses"
severity: "minor"
category: "security"
source: "agent-finding"
found_during: "private security report, filed 2026-10-05"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/adapter/scanner/scanner.go"
remedy: "Reject a skip fragment that is only punctuation or matches every ordinary source path, and count a byte-only file toward the zero-coverage sentinel unless its extension is on the reviewed binary list; prove it with a scanner test (watched fail first) that the `.` fragment is refused or the address hard-fails, a token on a dotted path still hard-fails, a blank or slash-only fragment is still dropped and the default log-directory fragments still skip only those directories; sweep siblings (the other skip lists mergeConfig accepts)."
resolution: "A file a skip fragment alone sends to byte-only scanning is Unscanned with its reason, so the launch refuses and names it, unless its extension or name is on the reviewed skip lists; a new exclude_path_fragments config field declares an exclusion with a required reason, reported as excluded by choice and never counted as scanned, and the zero-coverage sentinel still refuses a bundle it leaves unscanned."
impact: fix
---

A committed `.abcd/config/pii.json` with `skip_path_fragments: ["."]` sends every dotted path to the byte-only branch, so a non-reserved IPv4, IPv6 or MAC address in an included file ships through the launch scan.

A private security report, fixed in this release; its advisory, with the full text and reproduction, is published with the release.

Evidence (lines at main 7549ca2d5): `mergeConfig` drops a fragment only when trimming slashes and whitespace leaves it empty, so `.` is stored (internal/adapter/scanner/scanner.go:338). `skipByFragment` is `strings.Contains` (internal/adapter/scanner/scanner.go:1495). `secretPatterns` drops the identity kinds, which include the network kinds (internal/adapter/scanner/scanner.go:1474). The zero-coverage sentinel trips only when FilesScanned is zero, and an undotted `LICENSE` on the include list keeps it above zero. `scanRefusals` does not refuse ContentUnverified files (internal/core/launch/dryrun.go:269). A token, the caller's home path, a real email and a long real name on that path still block.
Loading
Loading