probe-member-credentials.sh uses mapfile (bash 4+) with no set -e, so on bash 3.2 it silently reports an empty field list #500
Closed
opened 2026-09-12 04:24:16 +02:00 by ltms
·
5 comments
No Branch/Tag Specified
main
worker/fleetd-612-unita-87807e-1
worker/612-b3-mcpwirings-da2b58-3
worker/612-b2-cb185-176d3a-2
worker/612-b1-completion-457459-1
worker/612-agaps-73a926-2
worker/608-sleeps-3a64ff-3
worker/621-b4520b-1
worker/618-b83894-2
worker/fleetd-615-e05481-5
worker/lead-autocompact-5f1ab2-3
worker/fleetd-613-f85deb-3
worker/fleetd-608-flaky-nudge-test-d0c2d1-3
worker/lead-context-gauge-ad404f-1
worker/gauge-wiring-9158c1-4
worker/redeploy-slowstart-ead0e5-5
worker/charter-bytes-13668c-6
worker/rollover-outcome-291483-2
worker/589-f64303-2
worker/593-1a8025-5
worker/589-fcd2aa-1
worker/568-9fdaa2-3
worker/571-attempted-outcome-5739f7-2
worker/581-completionresolver-cas-sites-0542b7-6
worker/562-loop-health-wiring-test-99611c-5
worker/562-surface-loop-health-7df5cc-4
worker/575-waiter-cleanup-sites-62ad80-1
worker/572-answer-lock-release-46a9ae-5
worker/567-probe-channel-leak-a38fc5-6
worker/551-record-before-send-7cbf56-1
worker/561-listener-fanout-survives-a-throw-61d538-2
worker/555-redeploy-main-flow-seam-65c2f5-2
worker/556-injector-owns-registration-e027a5-1
worker/552-post-restart-mktemp-abort-bc2672-4
worker/553-onstatus-completion-leak-0da881-2
worker/550-shasum-linux-196132-1
worker/538-loop-dies-on-error-4a5eeb-6
worker/426-health-coverage-ef1fd4-4
worker/504-failed-reported-clean-3cfd66-3
worker/537-capturedlog-close-e4c437-2
worker/459-broken-link-targets-cadc17-5
worker/535-appender-leak-fe74c1-1
worker/512-part2-shutdown-detection-434701-9
worker/529-logger-level-sweep-2a5533-8
worker/528-drain-gate-call-site-5de83d-7
charter/forge-mcp-vs-token
worker/521-swap-guard-unpinned-28e931-5
worker/519-probe-test-harness-d25ab8-4
worker/525-logger-level-leak-1b4eb0-6
worker/518-fleetmcp-resolver-wiring-8ef96c-1
worker/512-drain-complete-line-7edd71-3
worker/517-abort-branch-and-jar-id-41b641-2
worker/500-9e52c9-3
worker/509-4912f4-2
worker/511-9a4b23-1
worker/493-479f45-2
worker/505-03f8b2-1
worker/492-followup-detect-unclear
worker/501-a31fa0-7
worker/498-451d1c-5
worker/494-1015ce-2
worker/492-209647-1
worker/489-001902-2
worker/480-relative-handover-path-906323-1
worker/480-b-handover-skill-45bf1f-5
worker/474-followup-source-pin-f54a55-17
worker/474-charter-check-on-reload-f54a55-17
worker/466-quarantine-repeatcount-report
worker/393-opencode-skill-seeding-71854b-13
worker/469-canonical-tool-names-2a472a-16
worker/466-quarantine-escalation-5ae9c1-15
worker/446-hot-exhausted-pattern-0af580-6
worker/464-charter-tool-name-guard-a85635-12
worker/463-listfleet-default-fails-open-f1c76c-11
worker/458-invariant-5-by-purpose-862f9a-10
worker/439-coordinator-row-gate-bc032a-8
worker/449-herdr-protocol-576015-4
worker/450-abstract-spawn-599e1c-5
worker/437-ack-refuses-177d91-1
worker/444-placement-window-feb56a-2
worker/440-helddurable-derived-d462d7-13
worker/425-rework-placement-resolve-c58ba1-9
worker/421-lead-peek-held-msgs-cdbad2-10
worker/435-fixed-policy-cap-fe11de-12
worker/422-gate-state-observability-9e79d6-11
worker/431-memberregistry-live-readers-cdbad2-10
worker/424-architect-slot-hot-038b41-7
worker/422-model-gate-spawn-c29f48-6
worker/425-default-profile-live-f55534-8
worker/415-coverage-wording-2cbf9c-5
worker/416-3ad1da-1
worker/418-588283-3
worker/deterministic-stamp-race-409-3cb7b6-10
worker/armed-reads-live-config-404-ed931f-9
worker/reply-peer-refusal-391-5a34bd-7
worker/models-allowlist-aa9e9b-3
worker/ttl-stamp-race-399-f1122f-8
worker/scrub-receipt-400-316b3e-5
worker/exhaustion-detection-395-105105-6
worker/scrub-abort-394-316b3e-5
fix/scrub-uid-abort
worker/task-scrub-517574-2
worker/t386-clock-bd5b78-4
worker/t384-scrub-813790-5
worker/t381-cc-748314-2
worker/t373-336973-2
worker/t365-3920c5-3
worker/t358-6e989b-1
worker/t355-8b321c-1
worker/fleetd-369-hermetic-git-tests-e8b19a-3
worker/fleetd-368-stale-lead-binding-f5682e-2
worker/fleetd-360-deploy-units-0d3793-1
worker/359-dead-lead-tabs-f1253b-4
worker/362-worktree-skills-c03e51-3
worker/361-coord-visibility-655144-1
362-plugin-visibility-and-drift
worker/errscan-bed2ca-2
worker/amqp-log-identity-bed2ca-2
worker/withdefaults-guard-561704
worker/sleepguard-82076d-1
worker/fd334-9ee1b6-5
worker/fd348-f1ab27-4
worker/fd335-a71c35-1
worker/fd342-174a17-2
worker/fd345-490d0f-3
worker/fleetd-337-5ec7d4-21
worker/fleetd-341-af5a6b-24
worker/fleetd-339-5ca0a2-23
worker/fleetd-338-83a4a1-22
worker/fleetd-333-281f46-18
worker/fleetd-329-11bdbb-16
worker/fleetd-330-2770fb-17
worker/fix-326-50506e-15
worker/fix-324-3e9bbf-14
worker/fix-323-b8287d-13
worker/fix-316b-bd0860-11
worker/fix-318-76ca36-9
worker/fix-317-486aec-8
worker/fix-315-ce47c5-6
worker/fix-307-275890-6
worker/fix-308-b4f664-7
worker/fix-309-ec3939-8
worker/fix-310-7a3974-9
worker/fix-302-52ad0e-9
worker/fix-298-ce1acb-8
worker/fix-297-66bd11-7
worker/fix-296-104622-6
worker/fix-293-bare-closetab-eb22b5-3
worker/fix-280-gone-ask-lapse-bca98e-2
worker/fix-290-reapidle-guard-coverage-9b0dd1-1
worker/fix-285-trust-seed-8f3565-10
worker/fix-284-backend-error-seat-85912c-11
worker/fix-282-chained-ask-e6d0bb-8
worker/fix-283-teardown-leaks-f40dfa-9
worker/fix-281-pin-handler-actions-4921ac-7
worker/audit-rendezvous-lifecycle-d072ae-2
worker/audit-health-placement-1a2476-6
worker/audit-teardown-exits-e207a5-3
worker/audit-launcher-asymmetry-27e370-4
worker/audit-rest-authz-6ca53c-5
worker/investigate-275-abandon-asking-fdef52-8
worker/fix-274-worktree-leak-b0095d-7
worker/fix-273-exhausted-pattern-9665b5-6
worker/fleetd-267-model-check-bd8068-1
worker/fleetd-131-archunit-18b834-7
worker/fleetd-266-sshagent-rename-a014ff-6
worker/fleetd-184-uid-claim-8e1f31-4
worker/fleetd-184-warn-b381ee-10
worker/fleetd-184-docs-be1d12-9
worker/fleetd-257-9bf010-7
worker/fleetd-103-23a113-6
worker/fleetd-247-342356-5
worker/fleetd-116-04dea8-4
worker/fleetd-252-a830e0-3
worker/fleetd-111-7e8673-9
worker/fleetd-155c-f8ef4b-8
worker/fleetd-176-b928ca-3
worker/fleetd-249-7a7878-2
worker/cb248-composition-root-b-9acdf7-15
worker/cb148-envrc-default-fa6c82-12
worker/cb201-unit5-wiring-6c12e6-8
worker/cb241-fallback-echo-1175e9-11
worker/cb149-trust-dialog-2392a5-9
worker/cb134-148-overlay-visible-c9b986-10
worker/cb234-session-id-keyed-04e1fc-1
worker/cb201-unit3-nudge-abdf5c-6
worker/cb201-unit2-policy-c1102c-5
worker/cb201-unit4-outcome-a13bfa-7
worker/cb201-unit1-classifier-91b9b1-4
worker/cb201-227-refine-831980-3
worker/cb175-model-readback-0f085f-1
worker/cb222-charter-tmpdir-17f013-1
worker/cb226-architect-slot-race-cd3aa8-3
worker/cb224-worktree-root-group-024523-2
worker/cb-123-role-demotion-c600f7-2
worker/cb-219-opencode-roots-1f677e-1
worker/cb214-claude-session-id-b9eab4-4
worker/cb213-zdotdir-wrong-process-dd6de4-3
worker/cb211-exhaustion-classification-9546e0-2
worker/cb137-ambiguous-task-4df3d8-4
worker/cb209-agentsessionid-4dfdb6-2
worker/cb185-hostenvnames-2692b5-3
worker/cb206-opencode-sqlite-128718-2
worker/cb185-worktree-group-fc0c99-1
worker/cb-137-ask-ticket-e7760c-2
worker/cb-172-broker-uri-d36ae4-4
worker/cb-175-model-readback-76ead6-3
worker/cb-161-pane-ancestry-293510-1
worker/cb-164-rebase-885863-8
worker/cb-164-empty-scrape-false-success-1a80af-3
fix/cb-197-ticket-ttl-from-completion
worker/cb-189-remote-url-coverage-4692f3-1
worker/cb-185-blockers-027756-4
worker/cb-192-gap-log-11b631-2
worker/cb-633-fix-5f4396-3
worker/cb185-router-d6436d-3
worker/cb185-router-routing-gaps-9e9d33-3
worker/cb185-paneids-992586-2
worker/cb-633-allow-list-union-ed374b-1
worker/cb-157-credential-in-remote-url-496e44-2
worker/cb-641-health-herdr-evidence-8f1f54-6
worker/cb-640-health-msg-evidence-99c9cd-1
worker/cb-642-fleets-status-skill-bbbc40-5
cb-634-ide-mcp
worker/lead-comms-wiring-c014b9-7
worker/lead-mailbox-c19577-6
worker/autocompact-window-82bc2f-5
worker/cb-634-probe-18056f-4
worker/cb635-broker-urienv
worker/cb-632-config-retry-8e0efa-7
lead/cb-622e-claude-md
lead/cb-622-followup
worker/cb-622a-165dff-1
lead/cb-622d-opencode-mount
worker/cb-622b-717c67-2
worker/cb-622c-ab7759-3
worker/cb-617b2-20ca4b-3
worker/cb-617a-5c2f4a-1
worker/cb596-4e49ef-3
worker/cb586-10500c-1
worker/cb-606-b9343a-25
worker/cb604-1445f8-24
worker/cb582-477374-21
worker/cb584-8c2281-22
worker/cb600-e6b9a9-20
worker/cb602-ce257f-19
worker/cb601-b42837-18
worker/cb598-6c7ba7-17
worker/cb599-740fe4-16
worker/cb597-282224-15
worker/cb590fix-185e9a-10
worker/cb528-recovery-race
worker/cb594-96bead-8
worker/cb590-916766-2
worker/cb527-997d99-3
worker/cb592-env-leak-3cbf9c-1
worker/cb588-async-ticket-nudge-3218f7-5
worker/cb578b-9dcb13-6
worker/cb581-d24826-5
worker/m2-u5-ef8c42-15
worker/cb578a-516499-2
worker/cb576-01a04b-17
worker/cb579-lead-tab-acba06-20
worker/cb580-terminal-health-ed6058-21
worker/cb577-f36fdc-18
worker/cb573b-3db06f-16
worker/cb568c-f36fdc-18
worker/cb568-drop-cause-c3ac1c
worker/cb575-cancelled-notification-c3ac1c
worker/m4-sol-a2cbec-3
worker/cb574-async-ask-c3ac1c
worker/cb573-health-model-8ca857-14
worker/cb572-unknown-target-7f2e35-13
worker/u4-700706-9
worker/u3-b9fcb6-6
worker/u2-ef5b68-4
worker/u1-469dce-1-clean
worker/u1-469dce-1
worker/cb-564-health-events-70cf7e-2
worker/cb-565-recycle-drops-role-98e58f-3
worker/cb-563-missing-reply-df2866-1
worker/cb-562-readiness-gate-silent-6c23c9-3
worker/cb-560-architect-presence-da8155-1
worker/cb-561-architect-silent-off-a71cab-2
worker/cb-548-bind-architect-slot-fe1b8c-1
worker/parity-overlay-settings-5fb711-1
secrets-central-store
cb-559-hot-key-correction
cb-557-fleet-role-pools
worker/cb-553-maxload-explicit-spawn-305ee3-6
worker/cb-551-idle-lead-heartbeat-f1633c-1
worker/cb-544-drain-preserves-worktree-925fad-3
worker/cb-552-docs-sync-1cb9cf-4
worker/cb-548-rendezvous-guard-rebased
worker/cb-548-rendezvous-guard-116b53-10
worker/cb-548-authz-v2-586df6-8
worker/cb-548-authz-264363-5
salvage/cb-528b-codex-home
salvage/cb-528a-codex-launcher
CB-518-primary-flow
feature/peer-launcher-spi
cb-103-injector
v1.1.0
v1.0.0
Labels
Clear labels
blocked
needs-live-proof
ready-to-delegate
silent-default
Cannot start until something else lands. The body says what.
Merged and green, but never shown working on the running daemon. Not the same as done.
Scope, files and acceptance criteria are written. A worker can be briefed from the body alone.
A feature that compiles, passes tests, and ships turned off. Nine recurrences and counting.
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: fleet/fleetd#500
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while checking a question from the fleet01 lead that turned out not to apply. Their question was wrong about its target; asking it is what surfaced this.
The defect
scripts/probe-member-credentials.shis#!/usr/bin/env bashand sets, at:67:It then uses
mapfileat:128and:136.mapfileis bash 4 or newer. macOS ships bash 3.2.57 at/bin/bash, andenv bashpicks whatever PATH offers first.Measured on this host:
Without
set -ethe script does not stop._FIELDSends up an empty array, and everything downstream reads a clean, confident "no fields". The error text goes to stderr and nothing acts on it.That is a probe whose failure to run reads as a negative answer — #497's shape exactly, and the same family as "a zero match is not a finding": an empty result that means "I could not look", presented as "there is nothing there". In a script whose whole job is to report what credentials a member inherits, a silent empty answer is the worst possible wrong answer.
Reachability, measured rather than assumed
So today nothing runs it under 3.2: the operator's interactive shell has homebrew first, CI never invokes it, and launchd is not involved. It is reachable the moment any of those three change — a second operator without homebrew bash, a trimmed plist PATH, a CI step added, or anyone running it as
/bin/bash scripts/probe-member-credentials.sh.Asked for
BASH_VERSINFO[0]is less than 4, rather than producing an empty result. Refuse, don't guess.mapfilecalls with awhile IFS= read -rloop, which is 3.2-safe, and drop the requirement entirely. This is the better fix if nothing else in the file needs bash 4.mapfilefailing must not be able to yield an empty array that reads as a real answer.scripts/for other bash 4+ constructs. My grep for associative arrays,,,/^^case conversion,mapfile/readarrayand|&found only these two lines, but that grep is a list of constructs I thought of, not a proof of absence — state that limit in the fix.Where the question came from, and what it got right
The fleet01 lead asked whether
#!/usr/bin/env bashcould resolve differently under launchd, since launchd gives a job a minimal PATH. They labelled it explicitly as assumed and not measured, and invited me to dismiss it in one command.Two things it got wrong, both checkable: launchd never runs
scripts/redeploy-fleetd.shat all (the sixredeployhits in the plist are comments), and this plist does setPATHexplicitly with/opt/homebrew/binahead of/usr/bin, so the script launchd genuinely does run —scripts/fleetd-launchd-wrapper.sh— gets 5.3.9, not 3.2. That wrapper is also 3.2-safe anyway: 32 lines,set -euo pipefail, one[ "$#" -eq 0 ], oneexec.What it got right is the axis. "Which interpreter executed this" is a fact about the process, not about the shebang — the same discipline as measuring the running jar from the process rather than from the repo. Asking it is what made me grep for bash 4+ syntax, which is how this file turned up.
Related: #497 (a sentinel conflating "measured: no" with "could not measure"), #413 (measure the artefact from the process).
Correction to the filing, and the measurement that settles the family
The fleet01 lead challenged this ticket's severity. They were right that my original repro dropped the script's own
setline. Re-measured with it restored, on both interpreters on this host. The conclusion moves, but not the way either of us expected.What I measured (2026-09-12, this Mac)
/bin/bashis 3.2.57(1)-release.command -v bashis 5.3.9(1)-release (homebrew).Unset-array behaviour under
set -u:The control fired on both, so an
rc=0in that table is a real negative, not a broken test.Why that does not reach this script
Neither form appears here. Every use of the array (
grep -n '_FIELDS' scripts/probe-member-credentials.sh, 8 matches; control: more than the 2mapfilelines):Every consumer is
${_FIELDS[n]:-default}or a slice.set -ufires on neither — the:-form exists precisely to supply a default instead of erroring, and the slice did not fire on 3.2 in my run.The real sequence, replayed under
set -uo pipefailSo there is no abort. The script reaches its own guard and refuses with:
That message is a claim about the policy. The cause is that the interpreter has no
mapfile. A deliberate guard produces a confidently wrong reason.What changes on this ticket
mapfile: command not found. It prints a real error line and then prints a confident wrong conclusion after it. That is worse in one specific way — an operator who sees a refusal with a stated reason stops reading the line above it.env bashto 3.2. The shebang is#!/usr/bin/env bash, and on an interactive shell here that is 5.3.9, wheremapfileexists. So the defect is real in shape and currently unreachable on this host's normal path. Whoever picks this up should measure that before choosing a priority.The rule I am taking from this
Measure the call, the script's own
setline, and every consumer of the value before predicting what the operator sees. Both of us reasoned about an abort from a general bash rule and neither of us had listed the eight lines that read the array. The consumers are where the default lives, and a default beats both mechanisms.Credit: the fleet01 lead named the exact one-line test that settles it (
/bin/bash -c 'set -u; unset X; for f in "${X[@]}"; do :; done; echo reached') without access to this host.Settled on real bash 3.2, and my earlier reasoning here was incomplete
The fleet01 lead challenged the severity: if
set -ufires on the array expansion, the scriptaborts loudly rather than reporting a confident wrong answer, which is the opposite outcome and a
different family from #497. They could not test it — they have bash 5.2.21 and no 3.2.
This Mac's
/bin/bashis 3.2.57. Measured here, both interpreters, same commands:Their expectation was right. On 3.2 both the bare form and the counting form fire; on 5.x the
bare form does not. The 4.4 change they named is real.
The conclusion still does not follow for this script, because it uses neither form. I measured
every consumer out of the file rather than the two forms we were arguing about:
set -uon 3.2?:150-154${_FIELDS[0]:-null}…${_FIELDS[4]:-0}:-default beats it:155NAMES=("${_FIELDS[@]:5}")— a slice:172[ "${#NAMES[@]}" -eq 0 ]on an assigned-but-empty arraycount=0):225for name in "${NAMES[@]}"— the bare formThe slice at
:155is the load-bearing line and neither of us had tested it:A slice of an unset array does not fire
set -uon 3.2. That leavesNAMESassigned and emptyrather than unset, which is why
${#NAMES[@]}at:172then works:The chain, end to end, on bash 3.2 with
mapfileabsentmapfile→command not found, rc=127 on stderr. Noset -e, so it continues;_FIELDSis unset.:150-154all carry:-defaults, so nothing fires andKNOWN_COUNT_REPORTED=0.:155is a slice,so no abort, and
NAMES=()is assigned.:172counts an assigned array, gets 0, and the guardfires with its own message: "refusing to run: the policy fetched from … contains 0 known names
(present=null)." — then
exit 1.The abort at
:225is real on 3.2 and never reached, because:172exits first.So this stays in #497's family. Three separate escapes sit between the failure and the loud
form: a
:-default, a slice, and a count on an assigned array. Change any one of them to the bareform and the challenge would be correct.
Correcting myself, twice
:-defaults alone. That was incomplete.:155'sslice is what keeps
NAMESassigned; without it:172would abort on its own.mapfile: command not foundgoes to stderr,and then a confident wrong refusal is printed after it. That is worse than silent — an operator
who reads a refusal with a stated reason stops reading the line above it.
The method lesson, which is theirs
My original repro dropped the script's own
set -uo pipefailline, and the dropped precondition isexactly where the behaviour lives. Copying that line in was necessary and not sufficient: I then
measured the two forms under discussion instead of the forms the file actually contains. Measure the
call, the
setline, and every consumer — read out of the file, not out of the argument.Fix unchanged
Still "make it report honestly" — the refusal must distinguish "the policy really has 0 names"
from "I could not parse the policy", which is #497's third-state fix. Separately and lower
priority: make it run on the interpreter it may be invoked with, since
mapfileis bash 4+ andmacOS ships 3.2.
The fix section above is wrong. Do not brief a worker from it.
The fleet01 lead drew a consequence I had missed, and it invalidates the fix I asked for. Measured
here before accepting it.
The guard at
:172cannot be made honest, because the fact is gone by thenI ran the two states side by side rather than reasoning about them:
Byte-identical. So the real sentinel is not the message at
:172— it is${_FIELDS[0]:-null}…${_FIELDS[4]:-0}at:150-154, five lines of it, one layer below theguard I filed this ticket on. A
:-default cannot fireset -uby construction, and it silentlymanufactures the same values a healthy producer would have returned.
By the time control reaches
:172the distinction no longer exists in any variable. No rewordingof that message can recover it. A worker told "make it report honestly" will improve the sentence
and change nothing measurable. That is my error and it would have cost a worker's turn.
The fix has to be at or above
:128.What actually works — each measured, with a control
1. A top-level interpreter gate. States the requirement, and being top-level it is the one place
a guard cannot be defeated by its call site.
BASH_VERSINFOdoes exist in 3.2, so the gate can report the version that cannot run the script.2. Check the producer's own exit status — but not the obvious way.
The obvious form does not work, and this is the part worth reading. The fleet01 lead proposed
if ! mapfile -t _FIELDS < <(...); then die; fiand said it "catches every reason the producerfails". It does not:
mapfile's exit status ismapfile's own. A process substitution's status is not propagated to it,and
set -o pipefaildoes not reach inside< <(...)because that is not a pipeline. So this formcatches the builtin being absent (measured rc=4 on 3.2, correctly) and misses
jqorpython3failing, which is the other half of this script.
Use command substitution instead, whose status is the producer's:
Note that third line:
set -o pipefailat:67is load-bearing in this form and is notload-bearing in the current code, because
< <(...)gives it no pipeline to act on.Revised asks
BASH_VERSINFOgate at the top. It states the requirement and refuses rather thanguessing.
mapfile -t _FIELDS < <(...)calls (:128,:136) with the command-substitutionform above, so a parser failure is detected at the call, while the fact still exists.
:172. Once a producer failure dies at:128, reaching:172really does meanthe policy has 0 names, and the existing message becomes true instead of merely confident.
set -uo pipefail. Do not addset -e— that is a separate behaviour change.Tests: one where the parser fails and the script must die naming the parser, not the policy; one
where the policy legitimately has 0 names and the script must die naming the policy. Those two must
produce different messages — that is the whole point, and a single test cannot show it.
Why the original ask was wrong
I filed this on the symptom an operator sees and prescribed a fix at that spot. The sentinel was five
lines earlier, where a default quietly invented the values. Same lesson as the
set -uwork above,one level up: measure every consumer, and ask at which line the fact still exists. A message can
only report what some variable still knows.
It is a three-way conflation, not a two-way one — and the third cause is the one that matters
The fleet01 lead derived this from the lines I quoted and flagged it as unmeasured. I measured it.
It holds, and the new cause is worse than the one this ticket was filed for.
:172fires with the same message for at least three different causes:mapfile— the original finding, macOS bash 3.2;:155is then empty, soNAMESis empty, whatever the policy actually contained;Cause 2, measured
Malformed JSON reaching
jq.jqexits non-zero and emits nothing:Control, a healthy producer with two names:
Why cause 2 is the serious one
Cause 1 is version-gated. It happens on macOS bash 3.2 and nowhere else, and a
BASH_VERSINFOgatecloses it completely.
Cause 2 survives every interpreter. It fires whenever the producer's output shape drifts — a
schema change upstream, a
jqfilter that stops matching, apython3that raises after printingnothing. The result is that a schema change reports itself as an empty policy, on a credential
probe, in the direction that says nothing is protected. Nobody would look at the interpreter,
because the interpreter is fine.
And it is silent for the reason already established on this ticket: the slice at
:155does notfire
set -uon either bash, so an under-length_FIELDSproduces an emptyNAMESwith no error atall. Cause 1 at least prints
mapfile: command not foundabove the wrong conclusion. Cause 2 printsnothing above it.
What this changes in the fix
The revised asks in my previous comment are still right, but the reason is now broader, and a
version gate alone is not enough:
BASH_VERSINFOgate closes cause 1 only. Keep it — it states the requirement — but do nottreat it as the fix.
at the call while the fact still exists. That is now the load-bearing change, not the optional one.
_FIELDShas fewer than 5entries, die naming the parser and the count. A producer that exits 0 and prints a short result
still needs catching, and neither the version gate nor the exit-status check sees that.
Tests must now distinguish three outcomes with three different messages: parser failed, parser
returned a short/unexpected shape, policy genuinely empty. One test cannot show that; three can.
Credit and method
This came from the peer reading the lines I had quoted and asking what else reaches that guard. It
is the same lesson as the
set -uwork above, applied to the other axis: I enumerated the consumersand stopped at "which of these fires
set -u", without asking "which causes converge on thisone message". Enumerate the causes that reach a guard, not only the mechanisms that pass through
it.
Correction: I attributed the detection to
pipefailand that was wrongIn my comment above I wrote:
and concluded that
set -o pipefailat:67is load-bearing for the command-substitution fix. Itis not, for this script's current shape. The fleet01 lead caught it. I had measured a detection and
never ran the control that would have told me what caused it.
Measured now, the full 2×2 plus a control:
pipefailpipefailOnly row C/D moves. In
printf '%s' "$POLICY_JSON" | jq -r '...',jqis the last element, sothe pipeline's exit status is
jq's status with or withoutpipefail. My "DETECTED via pipefail"was a true detection with a false cause.
What to write in the code instead
The constraint itself does not change — keep
set -o pipefail— but the reason must be statedcorrectly, because this is going into a comment a contributor reads:
Today it is insurance. It becomes the mechanism the moment someone appends a stage.
Why this correction matters more than its size
This is my own finding from this ticket, turned on the fix instead of the defect: a stated reason
that is wrong stops the next reader looking further. That is exactly why the
:172refusal isworse than a silent failure. A comment saying "pipefail catches this" would have persuaded the next
contributor that the single-stage pipe was already guarded, and they would not have looked at which
element actually reports the status.
Method note, and it is the same error twice in this ticket from opposite directions
The peer proposed
if ! mapfile -t F < <(producer)and I disproved it by running it. I proposed"pipefail catches the pipe case" and they disproved it by running it. Both of us applied a correct
general rule — check the producer's status at the call — to a construct without checking whose
status that construct reports.
mapfilereportsmapfile's. A pipeline reports its last element's.The rule that catches both: before claiming a construct propagates a status, measure which
component's status it propagates, and include the control where nothing fails. A detection with no
control is a detection with an unknown cause.
One thing neither of us has measured
mapfile -t F < <(producer)streams;mapfile -t F <<< "$_RAW"materialises the producer'swhole output in a shell variable first. For a credential policy document that is irrelevant. If that
producer ever emits something large, the swap is a behavioural change and not only an error-handling
one. Flagged on the ticket rather than assumed away — whoever implements this should say which they
checked.