scripts/config-edit.sh — an auditable seam for editing the live fleetd.yaml, with the reload verdict read back #635
Open
opened 2026-10-01 17:13:02 +02:00 by ltms
·
10 comments
No Branch/Tag Specified
main
worker/612-01e9f7-13
worker/612-a-r4-quarantine-outage-7ab0e8-5
worker/612-a-r9-r11-capacity-coverage-peers-cfcc79-7
worker/612-a-r10-loophealth-ccc872-8
worker/612-a-r12-turnregistrar-9e3bb7-9
worker/612-a-r5-leadconfigdir-9e70cf-6
lead/config-edit-redact-anchor-wording
worker/config-edit-seam-ca8dc1-1
worker/612-r67-630-lifecycle-290b8d-3
worker/629-625-ports-seams-da7d5d-4
worker/612-r12-exhaustion-f37cd7-1
worker/612-r38-amqp-24b083-2
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#635
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?
Why
fleetd/fleetd.yamlis gitignored and holds the live fleet's settings. Today the lead cannot edit it: the command classifier refuses a directEditon it, and that refusal is correct, because a bad edit reaches a daemon that is already serving.The operator chose this option over simply allow-listing
Editon the file (decision taken 2026-10-01). The reason is the same reasonscripts/redeploy-fleetd.shexists: one auditable command the operator allow-lists once, which also carries the safety a raw file write does not have — a backup, a parse check before the file is installed, and the daemon's own reload verdict read back afterwards.So the point of this script is not convenience. It is that an edit to a live config is not finished when the bytes are written. It is finished when the daemon has said what it did with them.
What the daemon actually says — measured 2026-10-01 on
158a2a8ConfigRefre-reads the file, validates it, and then logs exactly one verdict. These are the five stringsConfigRef.Outcome.summary()can produce (ConfigRef.java:371-391):A parse or validation failure logs a different line first, before any summary (
ConfigRef.java:425):Note the em dash
—in the summary strings. It is a real multi-byte character in the source; match on the stable prefixconfig reload refusedinstead of trying to match the dash.In
fleetd/fleetd.outa verdict looks like this (a real line):Three facts that drive the design:
ConfigRef.java:429-434returns beforecurrent.set(fresh). So the running config keeps every old value, not just the cold one. The cold keys arebind,herdrSocket,memberHerdrSocket,broker,auth.current.set(fresh)runs — but the running objects that already read it keep the old value until a restart. So "needs a restart" is a success with a follow-up, not a failure.config watch: fleetd.yaml re-read when it changes (every 10s). Read the interval from that line rather than hardcoding 10; fall back to 10 if it is absent.Scope — two files, both under
scripts/Follow the conventions of the existing pair
scripts/redeploy-fleetd.shandscripts/test-redeploy-fleetd.sh. Read both before you start. Match their option parsing, theirdie/logging helpers and their overall shape — this script sits beside them and will be allow-listed the same way.1.
scripts/config-edit.shOverrides, needed so the test can drive it without a daemon:
--config <path>(defaultfleetd/fleetd.yaml),--log <path>(defaultfleetd/fleetd.out),--wait-seconds <n>(default: 4× the watch interval, so 40).yqv4 is installed (v4.52.2 measured) — use it for--set. Useyqto parse-check too.The sequence for an edit:
redeploy-fleetd.shalready does this; copy the approach. Without it an old refusal from hours ago reads as this edit's verdict.pgrep—pgrep/ps -fprint argv and argv holdsNAME=value, so they are a credential channel. Get the port from the config'sbindvalue; fall back to 8765.--set, copy thenyq -i; for--from, use the given file.mvit over the live path.--wait-secondselapses.Exit codes — four outcomes, and they must stay four
This is the part most likely to be got wrong, so it is the acceptance criterion below. Exit 5 is not a failure and it is not a success. It means the edit is on disk and nobody knows what the daemon did with it — the daemon is down, or the watcher is stalled. A script that reports "cannot tell" as either "refused" or "applied" is worse than one that does not check at all, because the caller then acts on a verdict that was never read.
In state 5 the script must not restore. It leaves the edit in place and prints the backup path and the literal
--restorecommand to undo it. The reasoning: an applied-but-unobserved edit is visible and recoverable; a silent revert of a good edit is invisible, and the caller would go on believing their change is live.In state 4 the script restores the backup and then waits for a second verdict to confirm the restore reloaded cleanly. If that confirmation does not arrive, say so plainly — do not report a restore you did not observe.
Never print a secret
fleetd.yamlkeeps credentials out by indirection (broker.uriEnv,gitTokenEnv), but the script must not rely on that staying true.--dry-runand the change report print a diff, and that diff must be redacted: pipe it throughsed -E 's#://[^@]*@#://<redacted>@#g'— thegis required — and mask the value on any line whose key matchesTOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|URI|_KEY.[ -n "$V" ] && echo "set (${#V} chars)". Never write${V:-x}— that form expands the secret.--checkis read-only. It reports: config path and whether it parses, the daemon's listening state, the watch interval it found, the last verdict line in the log, the newest backup, andyq's version. It changes nothing and exits 0 even when the daemon is down (it is a report, not a gate).2.
scripts/test-config-edit.shA self-contained test, in the style of
scripts/test-redeploy-fleetd.sh. It drivesconfig-edit.shagainst a fixture config and a fixture log inside a throwaway temp directory it creates and removes. It must not touchfleetd/fleetd.yaml, must not touchfleetd/fleetd.out, and must not start, stop or contact any daemon.There is no daemon in the test, so the test plays the daemon: it appends the verdict line it wants to the fixture log while the script is waiting. Run
config-edit.shin the background, append the line, wait for the exit code.Acceptance criteria
Each of these is a property that must hold under a change, and each must be proved by a run whose output you paste into your reply. "The function exists" and "the flag is parsed" are not acceptance; a construct cannot satisfy these.
--setto change a value. Have the fixture log receiveconfig reload refused — these keys cannot change under a running daemon: broker. Restart fleetd to apply them.Assert: exit code is 4, andcmpreports the config file identical to the backup taken at the start. Not "similar" —cmpsilent, exit 0.config reloaded. Assert exit 0 and thatyqreads the new value back from the live fixture path.config reloaded; these changes need a restart to take effect: profiles. Assert exit 3, not 0, and that the output namesprofilesand tells the reader a restart is needed.--restorecommand. Then run that printed command and assert the file matches the original backup.--fromwith a file that is not valid YAML. Assert a non-zero exit, and that the live fixture config is unchanged and that no new verdict was expected — the script must fail before installing. Prove the file is untouched withcmp.config reload refused — something ancientline in the fixture log before running a--setthat then gets aconfig reloaded. Assert exit 0. A script that scans the whole log instead of the part after its marker fails this and reports 4.uri: amqp://user:hunter2@host/vhostin the fixture config, change something else, and assert the script's full output contains neitherhunter2noruser:. This is the one test whose failure is a security defect rather than a bug.scripts/test-config-edit.shpasses end to end, andbash -n(orzsh -n, matching whichever shebang you use) is clean on both scripts. Ifshellcheckis installed, it is clean too; if it is not installed, say so rather than claiming it passed.Hard constraints
fleetd/fleetd.yaml. It is gitignored, and your worktree does not have it. Do not look for it, do not try to create one, and do not report on its contents. Build and test entirely against fixtures. The lead runs the live probe.scripts/redeploy-fleetd.sh. Reading it is expected; running it is not.git add -A. Do not commit.mcp.json,opencode.json,.autoenvor anything underwiki/..mcp.json,opencode.jsonand.autoenv— not the repo's real files. An edit to one cannot be committed and will not tell you so. Read the list withgit config --worktree --get-all fleet.neutralizedConfig.mvn clean installis not an invitation torm -rf target.Also report
Beyond the unit: this script's shape — back up, check, install, read the verdict back, restore on refusal — is not specific to
fleetd.yaml. If you spot another place inscripts/that writes a live file without reading back what consumed it, name it in one line. Do not fix it.Lead verification of PR #636 — and one defect to fix
PR #636,
854insertions,scripts/only, measured withgit diff --stat origin/main...pr636(three dots — against a movingmaina two-dot diff falsely reports deletions).The suite has teeth — six mutations, all killed
The implementer ran its own suite and nothing else, so the suite's own soundness was unproven. I mutated
config-edit.shsix times, each time a line the implementer did not write for that test, and ran the whole suite:return 5→return 0(cannot-tell reported as clean)expected 5, got 0restore_and_confirm "$backup"must restore the config byte for bytereturn 3→return 0(deferred reported as clean)expected 3, got 0expected 0, got 4redact()made a pass-throughmust NOT contain [hunter2], but it doesparse_check()always returns 0broken candidate must never reach the live fixtureEvery kill is behavioural — a real exit code or a real
cmpon file contents. None is asource.containsmatch, which is what this family of test is usually faked with.config-edit.shwas byte-identical to pristine after each cycle.One note on my own instrument. My first run of the marker mutation reported SURVIVOR. It was a false survivor: my
sedreplacement contained a|, which was also myseddelimiter, so the command failed and the file was never mutated — a pristine file passing its own suite reads exactly like a surviving mutant. I redid it withawkplus a control that asserts the file actually changed before the suite runs, and it killed. A mutation harness needs its own did-it-change control, or a broken mutation reads as a clean survival.The implementer's own caveat: measured, and it is fine
--setwrites every value as a YAML string viastrenv()(deliberately, to keep a raw value out of theyqexpression). The implementer flagged that it could not check whether the daemon's loader toleratesweight: "7"on a numeric field. I checked it, against the realObjectMapper(new YAMLFactory())from the built jar:Jackson coerces a quoted scalar. So
strenv()is safe for numbers and booleans, and a genuinely bad value is refused by the daemon — which the script then classifies asrefused, restores, and reports as exit 4. The chain holds. No change needed for this.The defect — a forgotten value reports success
--setaccepts an empty value, and the result is a silently nulled field reported as a clean success.Measured end to end against a fixture:
and the daemon's own loader on that value:
So:
--set .profiles.sonnet.maxLoad=— a plausible typo, the value simply forgotten — erases the field, the daemon accepts it, the reload is clean, and the script reports "applied cleanly", exit 0. The caller asked to change one number and deleted it instead, with a success report.apply_set_pairsonly checks that the argument contains=(*=*), which an empty value satisfies. The guard needs to be on the value, not the shape.This matters more than it looks, because a null config value widens rather than empties. A nulled
maxLoadfalls to its default, so capacity silently moves instead of failing. That is the exact class of silent degradation this script exists to catch, and right now the script would hand it back as a success.Fix wanted
In
apply_set_pairs, refuse an empty value and say what to pass instead. Two cases must stay distinguishable, because they need opposite handling:--set .a.b=— refuse. Exit non-zero, install nothing, and name the likely cause ("value is empty — did you mean--set .a.b=nullto clear it, or quote an intentional empty string?").null, not"".""andnullare not the same thing to the loader and must not collapse onto one form.Add one acceptance criterion in the same style as the existing eight: a forgotten value exits non-zero and
cmpproves the live fixture untouched; and the explicit clear produces a barenull, not"".Not blocking, noted only
apply_set_pairssplicespathinto theyqexpression raw.strenv()protects the value but not the path, so a crafted path could evaluate as an expression. The caller here is the lead, and a malformed path makesyqfail with nothing installed, so the blast radius is small. Worth a path shape check (^[A-Za-z0-9_.\[\]"-]+$) if this ever takes input from anywhere but a human.Also measured, no action
bash -nclean on both scripts; I ran the suite 1× myself (implementer ran it 3×, identical output).shellcheckis genuinely not installed — the implementer was right to say so rather than claim a pass.SETS=()is declared beforeset -euo pipefailmatters, so${#SETS[@]}does not tripset -uon macOS's/bin/bash3.2. I ran--checkand--restoreunder 3.2: exit 0 and a clean named failure.env bashresolves to Homebrew bash 5.3.9 here, so the normal path is 5.x, but 3.2 works too.Second defect, verified by the lead — backups are not gitignored
Found by the backup-lifecycle reviewer, then checked here. Fix this in the same PR (#636) as the empty-value defect.
What is true
backup_configwrites the backup beside the live file asfleetd/fleetd.yaml.bak.<ts>.<pid>, and that name is not gitignored. Measured:fleetd/.gitignoreignoresfleetd.yamlandbridged.yamlby exact name, pluslogs/,target/,*.iml,.idea/,.DS_Store. Nothing matchesfleetd.yaml.bak.*. The script's own header says backups are never pruned, so they accumulate in a tracked directory indefinitely.The whole reason
fleetd.yamlis gitignored is that it must never be committed. A backup of it inherits that requirement and does not inherit the rule.What is NOT true — the reviewer's severity is overstated
The finding was filed as high, on the grounds that the backups hold "plaintext credentials". They do not. I measured the live
fleetd/fleetd.yaml:://user:pass@hoststyle userinfo in a URL: 0 occurrences.*Envindirection key holding a variable name, never a value:tokenEnv×2,gitTokenEnv×7. Notoken:,password:,secret:orpasswd:key carries a literal.The
hunter2password in the reviewer's paste came from the reviewer's own fixture, which they had created for the redaction test. They reported a property of their fixture as a property of the live file. That is the easiest mistake to make in this kind of review and it is worth naming, because it changed the severity by a whole grade.So: medium, not high. A real defect that must be fixed, but not a live secret exposure today.
It still matters without secrets. An un-ignored backup is one
git add -Aorgit add .away from committing this host's live configuration — ports, filesystem paths,configDirlocations, model names, credential ids, host allow-lists. And the hazard is forward-looking: the day anyone puts a literal into that file, every historical backup beside it becomes a committable copy, and nothing would warn them.Fix wanted
Write backups into a gitignored directory, and add the glob as well. Both, not either:
fleetd/.config-backups/, created on demand, with an entry infleetd/.gitignore. Prefer this overfleetd/logs/— that one is the CB-505 audit trail and should not get a second purpose.fleetd.yaml.bak.*line tofleetd/.gitignore, so a stray backup written the old way, or by an older copy of this script, is still ignored.A directory beats a glob on its own, because a glob only protects the filename format that exists today. The day someone changes the backup naming, the glob stops matching and nothing fails — whereas a location keeps working.
--restoreandrestore_command_linemust keep working against the new location, and--check's "newest backup" line must still find it.Acceptance — one more criterion, number 11
--setrun against a fixture, assert thatgit check-ignorereports the backup path as ignored — or, equivalently, that the backup lands under a path thatgit status --porcelaindoes not list as untracked. Then prove--restorestill finds and uses it: run the printed restore command andcmpthe result against the pre-edit file.Write this criterion so it would fail today. A criterion that passes on the current code is not pinning the fix. Show it failing before your change and passing after.
Correctly dismissed — no action
The same reviewer noticed that
restore_and_confirmalways returns 0 and therefusedbranch always returns 4, even when the confirming verdict never arrives, and judged it documented behaviour rather than a bug. They were right. I tested it — a refusal with deliberate silence afterwards:and
cmpconfirms the file really is byte-identical to the pristine original. So exit 4 means "refused, and the restore is on disk" — which is true and verified — while the part the script could not observe is reported loudly instead of being folded into the code. That is the right shape. A separate exit code would be a nicety, not a correction, and the warning already names the one action the caller needs to take.They also checked whether
ls -tbackup ordering could pick the wrong file on a same-second tie and found no reproducible failure, and said so rather than reporting a hypothesis. That is the right call too.Third defect, verified — every edit silently narrows the config's file mode, one way
Found by the install-path reviewer, reproduced here. Fix in the same PR (#636).
install_candidatedoesmv -f "$cand" "$live", and$candcomes frommktemp, which creates at mode0600. The rename carries the temp file's mode onto the live path. Nothing ever puts the old mode back, andcponto an existing file (asbackup_configandrestore_and_confirmdo) keeps the destination's mode, so a restore does not undo it either.Measured on a fixture that starts at 644:
The live file is reachable.
staton the real one:So the first
--setagainst the live config permanently takes it from 644 to 600, and no later command here brings it back.Severity medium, and the direction matters: 644 → 600 is a narrowing, so this is not a security hole.
fleetdruns as the same user and keeps reading the file fine. It is a correctness defect: a tool asked to change one key must not silently change file metadata as a side effect, and must certainly not do it irreversibly and cumulatively.Fix wanted
Capture the live file's mode before the backup, and apply it to the candidate before the
mv(preferred — then the file never exists at the wrong mode), orchmodit back immediately after. Do the same on the--restorepath. If the config does not exist yet, fall back to the system default rather than inventing 644.Acceptance — criterion 12
644. Run--set, assertstatstill reports644. Run--restore, assert644again. Then repeat the whole thing from600and assert it stays600— the fix must preserve the mode, not hardcode 644. Show this failing on today's code first.The reviewer's secondary note: structurally true, but I could NOT reproduce it. No criterion.
The same reviewer noted there is no
trapanywhere in the script, so a signal delivered while the candidate exists would leave a.config-edit.XXXXXXfile beside the live config. They said honestly that they had not timed a kill well enough to reproduce it. I tried to, and I also failed.Confirmed structurally:
grep -c '^[[:space:]]*trap 'returns 0. Everyrm -f "$cand"sits on an explicit path. And the temp name is not gitignored either, same as the backup.Not confirmed behaviourally: 21 timed
SIGINTs across three rounds, zero leaks. Two of those rounds were my own broken instruments, which is the part worth recording:exit=0and the same 12 output lines as an uninterrupted run — the script finishes in ~0.14 s, so every kill landed after it was done. Nothing was ever interrupted.--setpairs passed them as a single argument, so the script died atunknown optionin 0.015 s, beforemktemp. Those runs could not have leaked whatever the code does.A planted
.config-edit.PLANTEDwas detected by the same check, so the detector itself works. The zeros are explained by the window being a few hundred milliseconds wide, not by the absence of a gap.So: add the trap, but it is not gated by an acceptance criterion. One line,
trap 'rm -f "$cand"' EXIT INT TERMscoped where$candis live, is cheap and obviously right. I am not asking for a test, because I could not make a leak happen on demand, and a criterion I cannot see fail today is not a test — it would be a line that passes for reasons nobody has established. If you can reproduce a leak, say so and add the criterion; if you cannot either, say that too and we ship the trap as hygiene with the gap named.Running total for PR #636 — four changes, three with criteria
--set .a.b=erases the key and reports "applied cleanly"trap, so a signal can leak the temp fileCriteria 9–12 must each be shown failing before the fix and passing after. Four criteria, four demonstrated failures, pasted into the reply.
Fourth defect — this one is in the TEST. Criterion 7 cannot fail.
Two reviewers were blocked by the command classifier on this dimension, both times stopping and reporting rather than routing around it, which is correct. The second refusal was
It executes a script from a temporary path whose effects are not shown.So I did this dimension myself. It was never denied to me, and I am saying so rather than quietly swapping the actor.A surviving mutant
I deleted the redacted diff print on the edit path — the one that runs against the live config:
So the redaction is never exercised on that path at all. Result:
Criterion 7 — the one whose failure is a security defect rather than a bug — passed with the thing it guards removed.
(Controls: pristine anchor count 1;
awkused so noseddelimiter could silently fail;cmpconfirmed the file actually changed before the run, and byte-identical again after.)Why it goes blind
Both content assertions are negative only. An absence assertion passes just as happily when the subject has left the output entirely as when it was correctly redacted. The exit-code assertion proves the run happened and succeeded; nothing proves the diff was ever printed. So the criterion cannot tell "redacted properly" from "printed nothing", and silently prefers to pass.
The dry-run test does still cover the other diff print, so the redaction is pinned on the dry-run path. The edit path — the one that matters — is not.
Fix — criterion 13, a positive control
Add a loud positive assertion to criterion 7. I verified it passes on today's unmutated code, against a fixture holding
uri: amqp://user:hunter2@host/vhost:and the diff the caller sees:
$RUN_OUTPUTcontains<redacted>, and that it contains the changed key name (so a diff was demonstrably printed). Then show it working as a test: remove thediff -u "$backup" "$cand" | redactline from the edit path, confirm criterion 7 now fails, and restore it.That last step is the whole point. Today that mutation is a survivor. After the fix it must be a kill, and the reply must show both states.
Apply the same reasoning to the other
assert_not_containsuses in the suite, if any share this shape: pair each with something that fails when the content goes missing. An absence assertion without a presence control is a test that gets quieter as the code gets more broken.PR #636 — five changes now
--set .a.b=erases the key, reports "applied cleanly"trap, temp file can leakNote what the shape of this list says. Defects 1–3 are in the script and were found by reviewing it. Defect 5 is in the test, and the only reason it surfaced is that one reviewer was pointed at the test itself rather than at the code. A suite that killed six of my mutations still contained an assertion that could not fail.
Lead review of
4eb7200— one new defect, acceptance criterion 14I checked
4eb7200myself in a clean copy of the branch. The four open items from the earlier brief are closed and I proved each one. Details at the end. First the one new defect.Defect 6 — the
--restorefailure message names a directory the code no longer searchesconfig-edit.sh:617:newest_backupwas moved to the new layout and now searches the backup directory (config-edit.sh:324):So the code looks in
<dir>/.config-backups/, but the message prints<dir>/fleetd.yaml.bak.*. The message was not moved with the code.Why this is worth a fix and not just a nit. The old naming still exists in the wild — that is exactly why
fleetd/.gitignorekeeps thefleetd.yaml.bak.*glob as a backstop. In that case the message points straight at a file that is really there while saying it found nothing. Measured in a fixture:Exit code is
1, measured with no pipe (a pipe toheadhid it as0).This is a reporting-only defect: refusing to restore from outside the managed directory is correct behaviour. Only the report lies. Keep the behaviour, fix the message.
What to change
cpto recover it by hand. Do not add a search fallback — reading backups from outside the managed directory is a behaviour change nobody asked for.Acceptance criterion 14 — and it is red today
Run
--restoreagainst a fixture that has no backup in.config-backups/. Assert the output names the directory that was searched.I confirmed this assertion fails on
4eb7200before you write the fix:Add the control the other way too: with a backup present in
.config-backups/,--restoremust still succeed. That stops the fix turning into a message that is always printed.The four earlier items — closed, and how I proved it
The whole suite is green in a clean copy of the branch: criteria 1–7, 9–12 plus the 3 extras,
exit=0. Criterion 13 is folded into 7.The mutation that mattered most is now a KILL. Removing
diff -u "$backup" "$cand" | redact || truefrom the edit path (config-edit.sh:579):It fails by name at criterion 7:
the redaction must be PROVEN to have run on real content, not merely absent: missing [<redacted>]. Before this commit the same mutation was a survivor. Criterion 7 is the one whose failure is a security defect, so this was the gate.Both controls ran on every mutation:
cmpproved the file really changed (otherwise the harness printsINSTRUMENT BROKEN, notSURVIVOR), andbash -nproved the mutant still parses, so a syntax error cannot masquerade as a kill.The two new criteria also kill their own defects:
chmodinapply_modechmod 644instead of preservingThe gitignore rules work, with a negative control:
The scope expansion was the right call
Implementing criteria 11, 12 and 13 from comments 17655, 17657 and 17659 was correct. A newer ticket comment beats the brief — that is the rule, and following it is what it is for. No concern here.
Defect 4 (the temp-file trap) — still no criterion, and that stays
I could not reproduce a leak either, and I now know why my first two attempts proved nothing:
kill -INT <pid>does not interrupt the script. Bash holds the trap until the foregroundsleepreturns, so the run finished normally and the probe read as a clean negative. My control and my interrupted run printed byte-identical output — the probe never acted.Signalling the process group, the way a terminal Ctrl-C does, works. With that method I can report something new: the trap behaves correctly.
So the trap cleans up and the script stops. It does not resume into the install with a candidate it just deleted, which was my worry when I read the diff. The trap fires twice under an interrupt (once for
INT, once forEXIT); that is harmless, becauserm -fon an already-removed file is a no-op.No criterion is added for this. The window where
CANDis set runs frommktempto the install and is a fraction of a second, so a test would have to slow the script down artificially to hit it. That tests a modified script, not this one. A criterion nobody has seen fail is not a test.The global
CANDinstead of a function-local was the right choice, for the reason given: anEXITtrap that re-fires after a local$candis out of scope tripsset -u.Addendum to comment 17664 — also refresh PR #636's description
Small, and part of the same round as criterion 14. PR #636's body still describes the first commit, so the merge record would carry two things that are no longer true.
1. The
--setquoting caveat is settled — remove it, do not re-open it. The body says:That was a fair thing to flag, and it has since been checked. Against the real
ObjectMapper(new YAMLFactory())from the built jar:So writing values as quoted strings via
strenv()is correct and stays. A bad value is refused by the daemon, classified asrefused, the backup is restored, and the script exits 4. The chain holds end to end. Replace the caveat with that result, so the next reader does not re-litigate a closed question.2. The counts are stale. The body says "all 8 acceptance criteria plus 3 extra checks". It is now criteria 1–7 and 9–12, with 13 folded into criterion 7, plus the 3 extras — and criterion 14 once you add it. Say what the suite actually runs.
Leave the rest of the body as it is. In particular keep the note that
shellcheckwas not available and was not run: that is the honest form and it is still true on this host.No code change in this addendum — description only.
Defect 7 —
redact()leaks a multi-line value, and the output looks redactedFound while reviewing
096f08cbefore merging. Criterion 14 is correct and I proved it (details at the end). This is a new defect, and it is in criterion 7's property — the one the ticket calls a security defect rather than a bug. It needs fixing before this merges.What happens
redact()masks a line only when that line itself starts with a key. A YAML block scalar puts the value on the following lines, so the key line is masked and the value flows straight through.Reproduced with the real script, a normal
--dry-run, no mutation:Look at the shape of that output.
token: <redacted>sits directly above the value it was supposed to hide. A reader skimming it sees the redaction marker and concludes the line was handled. An incomplete redactor that prints a reassuring marker is worse than one that prints nothing, because it stops the reader looking.Two separate causes in that one paste:
|,|-,>or>-value leaks in full.passphrasematches none ofTOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|URI|_KEY, so it is never even considered.PASSWORD|PASSWDdoes not cover it.How reachable this is — measured, not assumed
Not reachable with today's live config: every credential there is
*Envindirection holding a variable name, and there are no inline secrets. But the ticket is explicit that the redactor must not depend on that staying true, and--from <candidate.yaml>accepts any file an operator hands it.The path in is narrow and ordinary:
diff -uprints the changed hunk plus three lines of context, so the secret leaks when it falls inside that window. My first attempt did not leak, because the secret sat further than three lines from the edit. Editing a key next to it leaked immediately. This matters for writing the test — see below.Acceptance criterion 15
Put a block scalar under a secret-looking key in the fixture, change a key adjacent to it, and assert the full output contains neither the block-scalar value nor the key line's value.
The fixture design is the whole test here. If the edited key is more than three lines from the secret, the secret never enters the diff and the assertion passes while proving nothing — it would be green today, before any fix. So:
096f08cbefore you write the fix, and say so in your reply. I have shown it is.Then add a
passphrasecase. Keep it separate from the block-scalar case so one failing does not hide the other.The fix
+,-, space) does not count toward indentation — strip it before measuring, or a+line will be misread.passphraseto the pattern.On the second one, be honest in the comment: a name list can never be complete, so it is a backstop. The structural fix is the one that holds, because it does not need to know the key's name to protect the value. Do not rewrite the redactor into a YAML parser — the two changes above are bounded and enough.
Also report, do not fix
One line each: does any other place in the script print content derived from the config or the candidate without going through
redact? I am asking about the paths, not the key list.Criterion 14 — verified, and correct
Your fix is message-only and
newest_backup/backup_dir_forare untouched, as instructed. The suite is green at096f08cin a clean copy (14 criteria + 3 extras,exit=0).Mutation results, each with both controls (
cmpproved the file changed,bash -nproved the mutant still parses):${CONFIG}.bak.*path back in the messagerestore_modealways take the not-found branchThe first one fails with exactly the right message:
the not-found message must name the directory actually searched, not the old beside-the-config glob: missing [.config-backups/fleetd.yaml.bak.*].Worth recording, because it cost me two bad readings first: my own harness reported
INVALID BASHand thenSURVIVORbefore these results. Both were my instrument, not your code. I had replaced one line of a multi-linedie, leaving the continuation dangling; and I had mutated thenewest_backupcall at line 521, which is in--check, not the one at line 616 inrestore_mode. A mutation aimed at the wrong function reads as a clean survivor, and it is the reassuring answer. Your direction-2 proof in isolation and my M-2 in the full suite agree once aimed correctly — in the full suite criterion 4 catches it first.The stale-message survey is the right answer and the
-h|--helprange check was a good addition to it.Addendum to comment 17670 — one sentence in PR #636's body describes
redactand will go staleYour PR description patch is right: the
strenv()caveat now reads as settled with theObjectMapperresults, and the criterion list is accurate. Thank you.One line in it needs to move with the defect 7 fix. The body currently says:
After the fix that is no longer the whole rule, and more importantly it is the sentence a future reader will trust when deciding whether the diff output is safe to paste somewhere. Update it to say what the redactor actually does: the userinfo rewrite, the key-name backstop, and that a masked key's deeper-indented continuation lines are masked too.
Say in that same sentence that the key-name list is a backstop and cannot be complete. A reader who knows the list is partial will look; one who thinks it is exhaustive will not. That is the whole lesson of defect 7 — the old output printed
<redacted>directly above a leaked value, and the marker is what stopped it being noticed.No code change in this addendum beyond what 17670 already asks for — description only.
Defect 8 — a failing
--setechoes its own value, unredacted. Fix this in the same round as criterion 15.I ran the survey I asked you for, because its output becomes your finding and I should not hand you a conclusion I have not measured. I found two unredacted paths. One is defect 8 below and belongs in this round. The other I am taking off your plate — see the end.
Only two places print config-derived content, and both already go through
redact:config-edit.sh:579(the edit's change report) and:608(--dry-run). That part is correct. The leaks are elsewhere.The defect
apply_set_pairsechoes$kv— the wholepath=valuethe operator typed — in its failure messages at lines 391 and 395. Measured:Line 378 (
--set expects <yq-path>=<value>) is safe — it only fires when there is no=, so there is no value. Line 384 (the empty-value refusal) is safe by definition. Lines 391 and 395 are the two that matter, and both are failure paths.Why a failure path is the worst place for this
The operator typed the value, so this leaks nothing they do not already know. That is not the risk. The risk is where the text goes next: this fleet pastes command output into tickets, PRs and
fleet_replybodies constantly, and a failure is exactly when someone copies the output to ask for help. A secret that was safe in a terminal becomes a secret in a ticket.The fix
In those two messages, print the path and not the value. The path is what the operator needs to fix their command; the value they already have. Something like
--set '.broker.password=<value>'. Do not route$kvthroughredact— it is notkey: valueshaped, soredactwould pass it straight through and give you a false sense of coverage. That is the same mistake as defect 7.Acceptance criterion 16
Run a
--setwhoseyqexpression fails, with a recognisable value. Assert the full output contains the path and does not contain the value.Confirm it is red before the fix — I have shown it is. Add the positive control: assert the path is present, so the criterion cannot pass by the message disappearing entirely.
Not yours — I am filing the second path as its own ticket
$VERDICT_LINEis printed raw at lines 441, 442, 465, 469, 473 and 515. That is the daemon's own verdict text, and a refusal readsconfig reload refused — <error message>. If the loader's message quotes the offending value, a refused edit prints it.I am not asking you to fix that, and you should not. Two reasons. I cannot verify it without a live daemon, which is mine to run and explicitly not yours. And masking the daemon's own refusal text could hide the one thing that explains the refusal, so it is a design decision about how refusals are reported rather than a bug with an obvious fix. I will file it separately and decide it with the measurement in hand.
Stay inside criterion 15 and criterion 16. If you spot anything else, name it in one line and do not fix it.
Lead, after verifying
d7f94ca: one wording fix, then this merges. No new code.d7f94cais good. I re-ran your suite in a pristine copy of the branch (git archive+git init,no worktree, no daemon) and it passed all 16 criteria plus the 3 extras, exit 0. I read the diff
myself. Criteria 15a, 15b and 16 are accepted, and your criterion 15a comment — placing the edit
next to the block scalar on purpose, and running the positive control first — is exactly right.
Do not touch
redact()'s behaviour. What follows is two sentences of prose.While verifying, I measured a case your fix does not cover, and filed it as #639: the
continuation masking only works while the masked key line is itself inside the printed hunk.
diff -uprints three lines of context, so a block scalar's body routinely appears without itskey — and then nothing is masked. I reproduced it through the real script with
--dry-run, with apositive control first proving the body was inside the window. A blank line inside a block scalar
loses the anchor the same way.
This does not block the merge and it is not a regression — the pre-fix code leaked those cases
too, so your commit is a strict improvement. It is also latent, not live: today's
fleetd.yamlhas5 block scalars and all 5 sit under non-secret keys.
The one thing to change
Two sentences currently claim more than the code does.
scripts/config-edit.sh, in your comment aboveredact():Make it say that the masking holds while the masked key line is itself in the printed hunk,
and that
diff -u's three-line context window can deliver a block scalar's body without its key— in which case nothing is masked. Point at #639.
PR #636's body, the same claim:
Same correction, same pointer to #639.
Keep both short. The reason this is worth a commit rather than a follow-up: an incomplete redactor
that claims to be complete is the defect #635 is about, and a future session will read that
comment and trust it.
Acceptance
redact(), to any other function, or to the test suite.bash -n scripts/config-edit.shclean; the suite still exits 0.worker/config-edit-seam-ca8dc1-1.Then
fleet_replywith the commit sha and the two new sentences quoted, so I can check themwithout re-reading the file. Your last turn ended with no
fleet_reply— the ticket, the pushand the PR body all landed, so nothing was lost, but I had to reconstruct your result from the
commit instead of reading it. Please end this one with the reply.