fleetd #500: honest refusal when the policy parse fails, not just when it's empty #516

Merged
ltms merged 1 commits from worker/500-9e52c9-3 into main 2026-09-12 06:09:51 +02:00
Member

Fixes fleetd #500.

scripts/probe-member-credentials.sh used mapfile -t _FIELDS < <(producer) to parse the fetched policy. That construct hides a producer failure three separate ways: mapfile is bash 4+ and missing on macOS's /bin/bash 3.2; a process substitution's exit status is never propagated back to mapfile; and every downstream read of _FIELDS uses a :- default or a slice, neither of which fires set -u on a short or unset array. All three converge on the same 0 known names refusal at the bottom of the script, which blames the POLICY for a failure that is actually the INTERPRETER or the PARSER.

Three distinct, independently-measured guards, each closing one cause with its own message:

  1. A BASH_VERSINFO gate near the top refuses outright on bash < 4, naming the running version (exit 3).
  2. The parser's output is now captured with command substitution instead of handed straight to mapfile < <(...), so a non-zero jq/python3 exit is caught at the call, while the fact still exists, before mapfile ever sees it (exit 4).
  3. An arity check runs before the field slice and refuses a parse that exits 0 but returns fewer than 5 fields — a schema drift or a filter that stopped matching (exit 5).

The pre-existing "0 known names" guard is now honest: by the time it fires, the three causes above are already ruled out, so it really does mean the policy legitimately reports 0 known names. Its message was tightened to drop the "or the response could not be parsed" hedge, which is no longer a real possibility at that point.

set -o pipefail is kept, with a comment correcting an earlier guess on the ticket: it is not what catches today's single-stage jq/python3 pipe (jq is the last element, so the pipeline's status is already jq's status) — it is insurance for if a post-processing stage is ever appended after the parser.

Noted per the ticket's own open caveat: mapfile ... <<< materialises the captured string in memory instead of streaming it the way < <(...) did. Irrelevant for this policy response's size; flagged rather than assumed away.

Testing

bash -n passes under both /bin/bash (3.2.57) and env bash (5.3.9, homebrew) on this host.

Measured all three causes, plus controls, on both interpreters using throwaway fixture curl/jq/python3 stand-ins (no real policy URL or network contacted):

cause /bin/bash 3.2.57 env bash 5.3.9
(a) bash too old refuses: "...needs bash 4 or newer... This shell is bash 3.2.57...", exit 3 n/a (gate passes; not applicable on a bash that has mapfile)
(a) control (same as above — the gate fires before any fixture is even consulted) passes, gate never fires (confirmed via a direct version check)
(b) producer fails — jq exits 1 refuses with the bash-too-old message, exit 3 (the interpreter gate pre-empts this; see note) refuses: "...jq exited non-zero (status 1)...", exit 4
(b) producer fails — python3 exits 1 (jq hidden from PATH) not run (same pre-emption as above) refuses: "...python3 exited non-zero (status 1)...", exit 4
(c) parses but returns only 2 of 5 fields refuses with the bash-too-old message, exit 3 (pre-empted) refuses: "...jq returned 2 field(s); at least 5 are required...", exit 5
control — well-formed policy, 2 known names not run (pre-empted; see note) passes, exit 0, prints NAMES=2 -> A B as expected

Note on the pre-emption rows: on bash 3.2 the interpreter gate is checked first and unconditionally, before the script ever fetches or parses anything — so a cause-(b)/(c) fixture on that interpreter still produces the cause-(a) message. That is the intended behaviour of putting the gate "at the one place a guard cannot be defeated by its call site," not a gap in coverage; I ran it explicitly to confirm the gate really does supersede the later guards rather than assuming it.

Scope: only scripts/probe-member-credentials.sh changed. No real policy URL or external service was contacted; all fixtures were throwaway local scripts. No environment variable value was printed by any test.

Fixes fleetd #500. `scripts/probe-member-credentials.sh` used `mapfile -t _FIELDS < <(producer)` to parse the fetched policy. That construct hides a producer failure three separate ways: `mapfile` is bash 4+ and missing on macOS's `/bin/bash` 3.2; a process substitution's exit status is never propagated back to `mapfile`; and every downstream read of `_FIELDS` uses a `:-` default or a slice, neither of which fires `set -u` on a short or unset array. All three converge on the same `0 known names` refusal at the bottom of the script, which blames the POLICY for a failure that is actually the INTERPRETER or the PARSER. Three distinct, independently-measured guards, each closing one cause with its own message: 1. A `BASH_VERSINFO` gate near the top refuses outright on bash < 4, naming the running version (exit 3). 2. The parser's output is now captured with command substitution instead of handed straight to `mapfile < <(...)`, so a non-zero `jq`/`python3` exit is caught at the call, while the fact still exists, before `mapfile` ever sees it (exit 4). 3. An arity check runs before the field slice and refuses a parse that exits 0 but returns fewer than 5 fields — a schema drift or a filter that stopped matching (exit 5). The pre-existing "0 known names" guard is now honest: by the time it fires, the three causes above are already ruled out, so it really does mean the policy legitimately reports 0 known names. Its message was tightened to drop the "or the response could not be parsed" hedge, which is no longer a real possibility at that point. `set -o pipefail` is kept, with a comment correcting an earlier guess on the ticket: it is not what catches today's single-stage `jq`/`python3` pipe (jq is the last element, so the pipeline's status is already jq's status) — it is insurance for if a post-processing stage is ever appended after the parser. Noted per the ticket's own open caveat: `mapfile ... <<<` materialises the captured string in memory instead of streaming it the way `< <(...)` did. Irrelevant for this policy response's size; flagged rather than assumed away. ## Testing `bash -n` passes under both `/bin/bash` (3.2.57) and `env bash` (5.3.9, homebrew) on this host. Measured all three causes, plus controls, on both interpreters using throwaway fixture `curl`/`jq`/`python3` stand-ins (no real policy URL or network contacted): | cause | /bin/bash 3.2.57 | env bash 5.3.9 | |---|---|---| | (a) bash too old | refuses: "...needs bash 4 or newer... This shell is bash 3.2.57...", exit 3 | n/a (gate passes; not applicable on a bash that has mapfile) | | (a) control | (same as above — the gate fires before any fixture is even consulted) | passes, gate never fires (confirmed via a direct version check) | | (b) producer fails — jq exits 1 | refuses with the bash-too-old message, exit 3 (the interpreter gate pre-empts this; see note) | refuses: "...jq exited non-zero (status 1)...", exit 4 | | (b) producer fails — python3 exits 1 (jq hidden from PATH) | not run (same pre-emption as above) | refuses: "...python3 exited non-zero (status 1)...", exit 4 | | (c) parses but returns only 2 of 5 fields | refuses with the bash-too-old message, exit 3 (pre-empted) | refuses: "...jq returned 2 field(s); at least 5 are required...", exit 5 | | control — well-formed policy, 2 known names | not run (pre-empted; see note) | passes, exit 0, prints NAMES=2 -> A B as expected | Note on the pre-emption rows: on bash 3.2 the interpreter gate is checked first and unconditionally, before the script ever fetches or parses anything — so a cause-(b)/(c) fixture on that interpreter still produces the cause-(a) message. That is the intended behaviour of putting the gate "at the one place a guard cannot be defeated by its call site," not a gap in coverage; I ran it explicitly to confirm the gate really does supersede the later guards rather than assuming it. Scope: only `scripts/probe-member-credentials.sh` changed. No real policy URL or external service was contacted; all fixtures were throwaway local scripts. No environment variable value was printed by any test.
agent added 1 commit 2026-09-12 05:58:44 +02:00
fleetd #500: stop a wrong-interpreter or failed-parse reading a policy as empty
CI / contract (pull_request) Successful in 1m15s
CI / build (pull_request) Successful in 2m8s
d59ece6dec
probe-member-credentials.sh used mapfile < <(producer) to parse the fetched policy. That
hides a producer failure three ways: mapfile is bash 4+ and missing on macOS's /bin/bash
3.2, a process substitution's exit status is never propagated to mapfile, and the
downstream reads (":-" defaults and a slice) never fire set -u on a short or unset array.
All three converge on the same "0 known names" refusal, which blames the policy for a
failure that is actually the interpreter or the parser.

Three distinct guards, each closing one cause with its own message:
- a BASH_VERSINFO gate at the top refuses outright on bash < 4 (exit 3)
- the parser's output is captured via command substitution instead of mapfile < <(...),
  so a non-zero jq/python3 exit is caught at the call while the fact still exists (exit 4)
- an arity check before the field slice refuses a parse that exits 0 but returns fewer
  than 5 fields (exit 5)

The existing "0 known names" guard is now honest: by the time it fires, the three causes
above are already ruled out, so it really does mean the policy has 0 known names.
ltms merged commit b37def9238 into main 2026-09-12 06:09:51 +02:00
Sign in to join this conversation.