fleetd #635: config-edit.sh — the one auditable way to edit fleetd.yaml #636

Merged
ltms merged 4 commits from worker/config-edit-seam-ca8dc1-1 into main 2026-10-01 18:58:28 +02:00
Member

Closes fleetd #635.

Adds scripts/config-edit.sh: backs up the live fleetd.yaml, builds a candidate off it (--set via yq, or --from a given file), parse-checks the candidate with yq before it ever reaches the live path, installs atomically (mv within the same directory), then reads the daemon's own ConfigRef reload verdict back out of fleetd.out — marked from before the edit (the log's line count), so a stale verdict line can never be mistaken for this edit's result.

Four exit codes, kept to four on purpose:

  • 0 applied, verdict read, clean
  • 3 applied, verdict read, needs a restart (deferred/split keys named)
  • 4 REFUSED by the daemon — backup restored, and the restore's own verdict reported if a confirming line is seen
  • 5 CANNOT TELL — no verdict line inside the wait window; nothing is restored, the edit stays on disk, and the literal --restore command is printed

Every diff this script prints (the install's own change report, and --dry-run) is piped through redact, which blanks any scheme://user:pass@host userinfo, masks the whole value on any line whose key looks like TOKEN/SECRET/PASSWORD/PASSWD/PASSPHRASE/CREDENTIAL/URI/_KEY, and — since a masked key's value is not always on the key's own line — also masks every line that follows a masked key and is indented deeper than it, until the indentation returns to the key's own level (a YAML block scalar's value sits on exactly those lines). The key-name list is a backstop and can never be complete; the indentation-based continuation masking is what holds regardless of the key's name.

Fixed during review (defects 7 and 8, both security — ticket comments 17670/17671/17673): redact used to mask only the key line itself, so a YAML block scalar's value leaked on the lines that followed it while the key line right above printed a reassuring <redacted> — an incomplete redaction that looks complete is worse than none, because the marker stops a reader from looking further. Fixed as described above; passphrase was also missing from the key-name list entirely. Separately, apply_set_pairs's two yq-failure messages used to echo the operator's full path=value input verbatim, so a failing --set with a secret-looking value echoed that value right back — now they print only the path, never the value (and deliberately do not route it through redact, which would pass a non-key: value-shaped string straight through and give a false sense of coverage).

--check is read-only (reports config parse state, daemon-listening probe, watch interval, last verdict in the log, newest backup, yq version) and always exits 0.

Backups now live in a dedicated <dir>/.config-backups/ directory (gitignored at the repo root and in fleetd/.gitignore, plus a fleetd.yaml.bak.* glob backstop) rather than beside fleetd.yaml itself — a backup of a file that must never be committed inherits that requirement. The live file's permission mode survives both an edit and a restore (a mktemp candidate used to carry mktemp's 0600 onto the live path forever). An empty --set .a.b= value is refused outright rather than silently nulling the field; a deliberate clear is spelled --set .a.b=null, which writes a real YAML null.

Also adds scripts/test-config-edit.sh, self-contained, driving the real script against fixtures in a throwaway temp dir with no daemon involved — it plays the daemon by running config-edit.sh in the background and appending the verdict line it wants to a fixture log.

Settled during review: --set always writes the value as a YAML string scalar (via yq's strenv()), so e.g. --set .profiles.sonnet.weight=7 ends up as weight: "7" rather than an unquoted 7. This was a deliberate simplification to avoid any shell-injection risk from embedding the raw value in a yq expression. Checked against the real daemon loader (ObjectMapper(new YAMLFactory()) from the built jar): a quoted scalar coerces fine onto Integer/Boolean fields ("300000" -> Integer, "false" -> Boolean), and a genuinely bad value is refused ("abc" -> InvalidFormatException) — which this script already classifies as refused, restores the backup, and exits 4. The chain holds end to end; no change needed.

Noted but out of scope (per the ticket, not touched): scripts/redeploy-fleetd.sh is the other script that writes a live file (the jar) without the exact same "read back what the consumer did with it" seam this ticket's shape is about — it reads back healthz/process-liveness, which is adjacent but not identical. Not fixing, just naming it as asked. Also out of scope: $VERDICT_LINE (the daemon's own refusal/reload text) is printed raw in several places — the lead is filing this as its own ticket rather than asking for a fix here, since verifying it needs a live daemon and masking the daemon's own refusal text could hide the one thing that explains the refusal.

Test plan

  • scripts/test-config-edit.sh run locally: criteria 1–7 and 9–12, 14, 15a, 15b and 16 (13 folded into criterion 7) plus the 3 extra checks (dry-run never installs, --check is read-only, the parse-failure refusal wording is also recognised) pass, exit 0.
  • Criteria 15a/15b (defect 7) and 16 (defect 8) each confirmed RED against the pre-fix code and GREEN after, with a positive control proving the relevant line was actually present in the output before asserting the secret/value absent.
  • bash -n clean on both scripts.
  • shellcheck was not available in this worktree — not run; said so rather than claiming a pass.
  • No mvn build run — shell-only ticket, no Java touched.
Closes fleetd #635. Adds `scripts/config-edit.sh`: backs up the live `fleetd.yaml`, builds a candidate off it (`--set` via `yq`, or `--from` a given file), parse-checks the candidate with `yq` before it ever reaches the live path, installs atomically (`mv` within the same directory), then reads the daemon's own `ConfigRef` reload verdict back out of `fleetd.out` — marked from before the edit (the log's line count), so a stale verdict line can never be mistaken for this edit's result. Four exit codes, kept to four on purpose: - 0 applied, verdict read, clean - 3 applied, verdict read, needs a restart (deferred/split keys named) - 4 REFUSED by the daemon — backup restored, and the restore's own verdict reported if a confirming line is seen - 5 CANNOT TELL — no verdict line inside the wait window; nothing is restored, the edit stays on disk, and the literal `--restore` command is printed Every diff this script prints (the install's own change report, and `--dry-run`) is piped through `redact`, which blanks any `scheme://user:pass@host` userinfo, masks the whole value on any line whose key looks like TOKEN/SECRET/PASSWORD/PASSWD/PASSPHRASE/CREDENTIAL/URI/_KEY, and — since a masked key's value is not always on the key's own line — also masks every line that follows a masked key and is indented deeper than it, until the indentation returns to the key's own level (a YAML block scalar's value sits on exactly those lines). The key-name list is a backstop and can never be complete; the indentation-based continuation masking is what holds regardless of the key's name. **Fixed during review (defects 7 and 8, both security — ticket comments 17670/17671/17673):** `redact` used to mask only the key line itself, so a YAML block scalar's value leaked on the lines that followed it while the key line right above printed a reassuring `<redacted>` — an incomplete redaction that looks complete is worse than none, because the marker stops a reader from looking further. Fixed as described above; `passphrase` was also missing from the key-name list entirely. Separately, `apply_set_pairs`'s two yq-failure messages used to echo the operator's full `path=value` input verbatim, so a failing `--set` with a secret-looking value echoed that value right back — now they print only the path, never the value (and deliberately do not route it through `redact`, which would pass a non-`key: value`-shaped string straight through and give a false sense of coverage). `--check` is read-only (reports config parse state, daemon-listening probe, watch interval, last verdict in the log, newest backup, yq version) and always exits 0. Backups now live in a dedicated `<dir>/.config-backups/` directory (gitignored at the repo root and in `fleetd/.gitignore`, plus a `fleetd.yaml.bak.*` glob backstop) rather than beside `fleetd.yaml` itself — a backup of a file that must never be committed inherits that requirement. The live file's permission mode survives both an edit and a restore (a `mktemp` candidate used to carry `mktemp`'s `0600` onto the live path forever). An empty `--set .a.b=` value is refused outright rather than silently nulling the field; a deliberate clear is spelled `--set .a.b=null`, which writes a real YAML `null`. Also adds `scripts/test-config-edit.sh`, self-contained, driving the real script against fixtures in a throwaway temp dir with no daemon involved — it plays the daemon by running config-edit.sh in the background and appending the verdict line it wants to a fixture log. **Settled during review:** `--set` always writes the value as a YAML string scalar (via yq's `strenv()`), so e.g. `--set .profiles.sonnet.weight=7` ends up as `weight: "7"` rather than an unquoted `7`. This was a deliberate simplification to avoid any shell-injection risk from embedding the raw value in a yq expression. Checked against the real daemon loader (`ObjectMapper(new YAMLFactory())` from the built jar): a quoted scalar coerces fine onto Integer/Boolean fields (`"300000" -> Integer`, `"false" -> Boolean`), and a genuinely bad value is refused (`"abc" -> InvalidFormatException`) — which this script already classifies as `refused`, restores the backup, and exits 4. The chain holds end to end; no change needed. **Noted but out of scope (per the ticket, not touched):** `scripts/redeploy-fleetd.sh` is the other script that writes a live file (the jar) without the exact same "read back what the consumer did with it" seam this ticket's shape is about — it reads back healthz/process-liveness, which is adjacent but not identical. Not fixing, just naming it as asked. Also out of scope: `$VERDICT_LINE` (the daemon's own refusal/reload text) is printed raw in several places — the lead is filing this as its own ticket rather than asking for a fix here, since verifying it needs a live daemon and masking the daemon's own refusal text could hide the one thing that explains the refusal. ### Test plan - `scripts/test-config-edit.sh` run locally: criteria 1–7 and 9–12, 14, 15a, 15b and 16 (13 folded into criterion 7) plus the 3 extra checks (dry-run never installs, `--check` is read-only, the parse-failure refusal wording is also recognised) pass, exit 0. - Criteria 15a/15b (defect 7) and 16 (defect 8) each confirmed RED against the pre-fix code and GREEN after, with a positive control proving the relevant line was actually present in the output before asserting the secret/value absent. - `bash -n` clean on both scripts. - `shellcheck` was not available in this worktree — not run; said so rather than claiming a pass. - No `mvn` build run — shell-only ticket, no Java touched.
agent added 1 commit 2026-10-01 17:29:49 +02:00
fleetd #635: add scripts/config-edit.sh, the one auditable way to edit fleetd.yaml
CI / shell-tests (pull_request) Failing after 10s
CI / contract (pull_request) Successful in 2m12s
CI / build (pull_request) Failing after 2m48s
0db6d31dc2
Backs up, builds a candidate off the live file, parse-checks it with yq before
install, installs atomically, then reads the daemon's own ConfigRef reload
verdict back out of fleetd.out (marked from before the edit, so a stale line
can never be mistaken for this edit's result). Four exit codes: 0 clean, 3
needs a restart, 4 refused (backup restored), 5 cannot tell (nothing
restored, printed --restore command). Every diff is redacted.

scripts/test-config-edit.sh drives it end to end against fixtures in a
throwaway temp dir, with no daemon involved.
agent added 1 commit 2026-10-01 18:07:18 +02:00
fleetd #635 follow-up: refuse empty --set values, gitignore backups, preserve file mode
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 1m43s
CI / build (pull_request) Failing after 1m52s
4eb720029c
Five fixes against PR #636, all verified by the lead's own review and reproduced here:

1. --set .a.b= (a forgotten value) is now refused outright instead of silently nulling the
   field — a null numeric config value falls back to its default rather than erroring, which
   widens capacity silently instead of failing loudly. A deliberate clear gets its own spelling,
   --set .a.b=null, which writes a literal YAML null via yq, never through strenv(). (criteria
   9, 10)

2. Backups move from beside fleetd.yaml to a dedicated fleetd/.config-backups/ directory,
   gitignored at the repo root (so it also covers scripts/test-config-edit.sh's own throwaway
   fixtures) and in fleetd/.gitignore, plus a fleetd.yaml.bak.* glob backstop for any stray
   backup written the old way. A backup of a file that must never be committed inherits that
   requirement. (criterion 11)

3. The live config's file mode now survives both an edit and a restore. mv from a mktemp
   candidate used to carry mktemp's 0600 onto the live path forever, and cp onto an existing
   file keeps the destination's mode, so a restore did not undo it either. (criterion 12)

4. A global CAND + single EXIT/INT/TERM trap prevents an uninstalled .config-edit.XXXXXX
   candidate from leaking if the script is interrupted mid-run. No acceptance criterion is
   gated on this — a reproducible leak could not be made to happen on demand — but it is cheap
   and obviously right.

5. Acceptance criterion 7's redaction check gained a positive control: it now asserts the
   output actually CONTAINS the redaction marker and the changed key, not only that it lacks
   the secret. The prior two assertions were negative-only and passed just as happily when the
   diff was never printed at all — confirmed by reproducing the lead's own mutation (deleting
   the redacted diff print on the edit path) and watching it survive the old test and get
   caught by the new one. (criterion 13)

All 13 acceptance criteria plus 3 extras pass in scripts/test-config-edit.sh. Criteria 9, 10,
11, 12 and 13 were each proven non-vacuous: criteria 9/10 by mutating the test's own expected
value and watching it fail by name, then reverting; criteria 11/12/13 by reverting or mutating
the corresponding fix in config-edit.sh and watching the matching criterion fail by name, then
restoring the fix and re-confirming a clean pass.
agent added 1 commit 2026-10-01 18:25:09 +02:00
fleetd #635 follow-up: fix the stale --restore not-found message (defect 6, criterion 14)
CI / shell-tests (pull_request) Failing after 9s
CI / contract (pull_request) Successful in 55s
CI / build (pull_request) Failing after 2m48s
096f08c866
The --restore "no backup found" message still printed the old beside-the-config glob
(${CONFIG}.bak.*) even though newest_backup had already moved to searching the managed
.config-backups/ directory. The message was left behind when the search moved — the search
itself was already correct (ticket comment 17664). Fix is reporting-only: the message now
names the directory actually searched (via backup_dir_for), and separately says that a
backup written the old way, directly beside the config, is not searched any more, with the
one-line cp to recover one by hand. No search fallback was added — reading backups from
outside the managed directory stays unsupported, as instructed.

Acceptance criterion 14 proves both directions: the not-found message names the real
directory (confirmed red on the pre-fix code, green after), and a restore with a real backup
present in .config-backups/ still succeeds (confirmed this catches an "always not-found"
regression that direction 1 alone would miss).

All 14 criteria plus 3 extras pass in scripts/test-config-edit.sh.
agent added 1 commit 2026-10-01 18:47:07 +02:00
fleetd #635 follow-up: redact() masks block-scalar continuations + passphrase; --set failures stop echoing the value (defects 7 and 8, criteria 15a/15b/16)
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 1m42s
CI / build (pull_request) Failing after 1m50s
d7f94cafa2
Defect 7 (comment 17670): redact() only masked a line that itself started with a
secret-looking key, so a YAML block scalar's value leaked on the lines that
followed the key while the key line right above it printed a reassuring
"<redacted>". Fixed by tracking the masked key's own indentation and masking
every following line indented deeper than it, stopping once indentation returns
to the key's level or shallower; the diff's leading +/-/space marker is stripped
before indentation is measured, per the comment's own pitfall. "passphrase" is
now also in the key-name backstop.

Defect 8 (comment 17673): apply_set_pairs echoed the operator's full
"path=value" input, unredacted, in both of its yq-failure die messages — a
failing --set with a secret-looking value printed that value right back. Fixed
to print only the path; deliberately not routed through redact, which would
pass a non-"key: value"-shaped string straight through.

Adds acceptance criteria 15a (block-scalar continuation), 15b (passphrase key),
and 16 (failing --set never echoes its value) to scripts/test-config-edit.sh,
each with a positive control proving the relevant line really was in the
printed output before asserting the secret is absent. All three confirmed RED
against the pre-fix code and GREEN after, in isolation, before being folded
into the full suite (16 criteria + 3 extras, exit 0).

Also updates PR #636's description per comment 17671: the redact() sentence now
names the continuation-masking rule and says plainly that the key-name list is
a backstop, never a complete list.
ltms merged commit ea6896f2ef into main 2026-10-01 18:58:28 +02:00
Owner

Correction to this PR's description, from the lead who merged it.

The description above says:

The key-name list is a backstop and can never be complete; the indentation-based continuation
masking is what holds regardless of the key's name.

The second half of that sentence is too strong. Read it as: the continuation masking holds
while the masked key's own line is inside the diff hunk being printed, and not otherwise.

redact() is fed diff -u output, which prints three lines of context. A block scalar's body
often reaches the function with its key line left out, and then masked is never set and the body
prints in full — with no <redacted> anywhere to hint that redaction was attempted. A blank line
inside a block scalar drops the anchor the same way, and that one prints a <redacted> marker
directly above the leaked text.

Both measured through the real script with --dry-run, each with a positive control run first to
prove the secret's lines actually reached the output.

  • Evidence, reachability and two candidate fix directions: #639.
  • The same overstated claim in redact()'s own source comment is corrected on main by #640
    (merged).
  • Not a regression, and it did not block this merge. The pre-fix code leaked these cases too,
    so d7f94ca is a strict improvement. It is latent rather than live: today's fleetd.yaml holds
    5 block scalars and all 5 sit under non-secret keys, and secrets here are referenced by env-var
    name rather than written inline.

The rest of the description stands, and the work in this PR is accepted — I re-ran the suite in a
pristine copy of d7f94ca (16 criteria + 3 extras, exit 0) and read the diff before merging.

**Correction to this PR's description, from the lead who merged it.** The description above says: > The key-name list is a backstop and can never be complete; the indentation-based continuation > masking is what holds regardless of the key's name. **The second half of that sentence is too strong.** Read it as: the continuation masking holds while the masked key's own line is inside the diff hunk being printed, and not otherwise. `redact()` is fed `diff -u` output, which prints three lines of context. A block scalar's body often reaches the function with its key line left out, and then `masked` is never set and the body prints in full — with no `<redacted>` anywhere to hint that redaction was attempted. A blank line inside a block scalar drops the anchor the same way, and that one prints a `<redacted>` marker directly above the leaked text. Both measured through the real script with `--dry-run`, each with a positive control run first to prove the secret's lines actually reached the output. - Evidence, reachability and two candidate fix directions: **#639**. - The same overstated claim in `redact()`'s own source comment is corrected on `main` by **#640** (merged). - **Not a regression, and it did not block this merge.** The pre-fix code leaked these cases too, so `d7f94ca` is a strict improvement. It is latent rather than live: today's `fleetd.yaml` holds 5 block scalars and all 5 sit under non-secret keys, and secrets here are referenced by env-var name rather than written inline. The rest of the description stands, and the work in this PR is accepted — I re-ran the suite in a pristine copy of `d7f94ca` (16 criteria + 3 extras, exit 0) and read the diff before merging.
Sign in to join this conversation.