Delete a Conflicting Sudo Timestamp Drop-In on Confirm - #764
Conversation
install-tools.sh --sudo-timestamp already detected another sudoers file setting the same timestamp option, but only ever warned. A host that already carried a hand-authored drop-in, or one left by a prior buggy run, ended up with two files asserting the same thing, forever. configure_sudo_timestamp now classifies each conflicting entry: - Scoped to this user, and the file sets nothing else: delete it on confirm, after the managed drop-in is proved in place. - Scoped to a different user, or unscoped: report only, never touched. - Scoped to this user but mixed with unrelated settings, or living in /etc/sudoers itself: die and ask for a manual visudo fix rather than guess which lines are safe to drop. A decline leaves the host unchanged, matching the existing confirm-gate pattern. The cleanup also runs when the managed file already has the right content, closing the case where a host that already ran the old script is left with both files despite nothing else needing to change. Verified with an isolated fixture harness (fake SUDOERS_FILE and search paths, stubbed visudo/install, no real root writes) covering: pure duplicate deleted, mixed-content file refused, steady-state no-op, correct-file-plus-stray-duplicate cleaned up, and a different user's entry left alone. Also dry-run tested against this host's real /etc/sudoers.d/pieter-global-timestamp. shellcheck (koalaman/shellcheck via Docker, per GOVERNANCE.md) is clean.
There was a problem hiding this comment.
Pull request overview
This pull request tightens install-tools.sh --sudo-timestamp behavior so that, when a conflicting sudo timestamp drop-in already exists, the script can safely remove a redundant “pure” per-user file after confirmation (instead of only warning), while refusing to edit shared/mixed-content sources.
Changes:
- Add logic to detect whether the managed sudoers drop-in is already current and proceed with cleanup even on no-op installs.
- Classify conflicting timestamp settings and, for “pure” per-user duplicates, delete them only after the managed drop-in is validated/in place.
- Improve confirmation/diagnostic messaging around declines and post-change sudoers parse failures.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1166
- The
grep -E "Defaults:${user}..."filter treats$useras an ERE fragment. If the username contains regex metacharacters (notably '.'), the candidate set can include unintended files and then be deleted/blocked incorrectly. Escaping$userbefore embedding it in the pattern avoids this.
done < <(grep -E "Defaults:${user}[[:space:]]+timestamp_(type|timeout)=" <<< "$elsewhere" | awk -F: '{print $1}' | sort -u)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot review on #764: sudo_timestamp_file_is_pure and the elsewhere classifier both interpolated $user directly into an ERE. A username can legally hold a regex metacharacter (a dot is common in a domain-joined account), and left unescaped it can match a different user's line as if it were this one's, in the exact deletion path this PR added. sudo_timestamp_user_re escapes each ERE metacharacter in pure bash, so "john.doe" and "john_doe" are no longer conflated. Verified with a new fixture scenario (a target user whose name collides via regex with an unrelated user's own file) plus a shellcheck-clean pass on the pure-bash rewrite (the first, sed-based version tripped SC2001/SC2016/SC1003).
|
Answering the suppressed (low-confidence) finding from this review round, 1 of 1 in that block: host-setup/linux/install-tools.sh:1166 (as of the round's head, 6fe87b8): "The Fixed in f9195fd: same root cause as the finding answered on the resolved thread above ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (2)
host-setup/linux/install-tools.sh:1182
- The candidate-file extractor only matches when
timestamp_type/timeoutis the first Defaults entry after the username. If the timestamp option appears later in the list (for exampleDefaults:user !authenticate, timestamp_type=global), the file will not be classified as unsafe and the script can proceed while leaving a conflicting setting behind. Match any ordering by allowing.*between the user scope and the timestamp assignment.
done < <(grep -E "Defaults:${user_re}[[:space:]]+timestamp_(type|timeout)=" <<< "$elsewhere" | awk -F: '{print $1}' | sort -u)
host-setup/linux/install-tools.sh:1120
sudo_timestamp_file_is_purecan classify a file as "pure" even when it contains other Defaults on the same line (for exampleDefaults:user timestamp_type=global,requiretty) because the allowlist regex only checks the timestamp prefix. Also, a grep read error currently falls back to treating the file as pure, which could delete a file that couldn't be inspected. Tighten the allowlist to accept only timestamp_type/timeout assignments (optionally comma-separated) and treat grep failure as not pure/unsafe.
This issue also appears on line 1182 of the same file.
local user="$1" file="$2" other user_re
user_re=$(sudo_timestamp_user_re "$user")
other=$("${SUDO[@]}" grep -vE "^[[:space:]]*(#.*)?\$|^[[:space:]]*Defaults:${user_re}[[:space:]]+timestamp_(type|timeout)=" \
"$file" 2> /dev/null) || other=""
[[ -z $other ]]
Copilot review on #764, round 2: two real gaps in the classification path this PR added. sudo_timestamp_file_is_pure's allowlist was a prefix match, so a line like "Defaults:user timestamp_type=global,requiretty" passed as pure even though it also carries requiretty. The allowlist is now anchored at both ends and accepts only comma-separated timestamp_type/timeout assignments, nothing else. The same function silently treated a grep read error the same as "no disallowed line found" (pure), so an unreadable file could have been deleted sight unseen; it now reads grep's exit status directly and only exit 1 (every line matched, ergo pure) counts, never a fallback default. The elsewhere classifier required the timestamp option to be the first one after the username on its Defaults line, so "Defaults:user !authenticate, timestamp_type=global" evaded classification entirely and the file would not even reach the unsafe-file check. Widened to match the timestamp assignment anywhere after the required username-boundary whitespace, which is what keeps a user like "john" from matching inside "johnson". Verified with four new fixture scenarios: same-line unrelated option (now refused), reordered options (now recognized and refused), a username that is a literal prefix of another user's (still not conflated, boundary intact), and a simulated grep read error (now treated as not-pure rather than pure).
|
Answering both suppressed (low-confidence) findings from this review round, 2 of 2 in that block: host-setup/linux/install-tools.sh:1182 (as of the round's head, f9195fd): "The candidate-file extractor only matches when timestamp_type/timeout is the first Defaults entry after the username... Match any ordering by allowing Fixed in 238bd3d: widened the elsewhere classifier's grep to host-setup/linux/install-tools.sh:1120 (same round, same head): " Fixed in 238bd3d: the allowlist is now anchored at both ends ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1187
- The early-return condition ignores
unsafe_files. If a mixed-content drop-in sets this user's timestamp option (so it ends up inunsafe_files) and the managed file is already current, the function returns here and never hits the safetydie, leaving an unsafe/conflicting configuration unreported.
Include unsafe_files in the early-return guard so mixed-content files for this user still trigger the later error path.
if [[ $own_current == true && ${#delete_files[@]} -eq 0 ]]; then
Copilot review on #764, round 3: the early-return guard checked own_current and delete_files but not unsafe_files. A standing mixed-content conflict for this user, with the managed file already correct and no pure duplicate to clean up, made the function return success silently, the one outcome this PR exists to prevent. Requires unsafe_files to be empty too before returning early, so that case now falls through to the same warn-and-die path every other unsafe conflict takes. Verified with a new fixture scenario: managed file pre-seeded correct, a mixed-content file added for the same user, run again, must die rather than report "already carries exactly this."
|
Answering the suppressed (low-confidence) finding from this review round, 1 of 1 in that block: host-setup/linux/install-tools.sh:1187 (as of the round's head, 238bd3d): "The early-return condition ignores Fixed in 758a964: added |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1120
sudo_timestamp_file_is_pureclaims it allows "blank lines and comments", but theallowregex only treats full-line comments as allowable. A drop-in likeDefaults:<user> timestamp_type=global # managedwill be classified as impure and land inunsafe_files, causing the script todieeven though the file still "sets nothing else" beyond a comment. Consider allowing an optional trailing#...comment on otherwise-allowed Defaults lines so safe duplicates with inline comments can still be auto-removed.
user_re=$(sudo_timestamp_user_re "$user")
allow="^[[:space:]]*(#.*)?\$|^[[:space:]]*Defaults:${user_re}[[:space:]]+timestamp_(type|timeout)=[^,[:space:]]+(,[[:space:]]*timestamp_(type|timeout)=[^,[:space:]]+)*[[:space:]]*\$"
"${SUDO[@]}" grep -vE "$allow" "$file" > /dev/null 2>&1 && status=0 || status=$?
Copilot review on #764, round 4: sudo_timestamp_file_is_pure's leading comment said "aside from blank lines and comments," but the allowlist only accepts a full-line comment, not one trailing a Defaults line on the same line. Narrowed the wording to match what the regex actually does. The regex itself is unchanged: widening it to also accept a trailing "# ..." on an otherwise-safe Defaults line was suggested but declined in the PR thread, since every prior round's fix in this file moved the same direction (never guess, refuse rather than assume safe), and this would move the opposite way for an authoring style the one real drop-in this feature was built against does not use.
|
Answering the suppressed (low-confidence) finding from this review round, 1 of 1 in that block: host-setup/linux/install-tools.sh:1120 (as of the round's head, 758a964): " Two parts, two verdicts: Fixed in 0353478: the finding is right that the comment overclaimed. Narrowed "aside from blank lines and comments" to "aside from a blank line or a full-line comment," matching what the regex actually accepts. No change needed on widening the regex to also accept a trailing inline comment on an otherwise-safe Defaults line. Declining, not deferring: every fix across this PR's 4 review rounds moved the same direction, tighten the allowlist, refuse rather than guess, treat an unhandled shape as unsafe rather than assume it is fine (the SC1087 escaping, the anchored allowlist, the read-error fail-closed change, and the unsafe_files early-return gap were all exactly this). Widening the regex to accept an authoring style like |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1119
- In
sudo_timestamp_file_is_pure, the allowlist regex uses\$at line ends (e.g.(#.*)?\$). In POSIX ERE (grep -E),\$matches a literal$, not the end-of-line anchor. That will make the allowlist fail to match normal lines, so files that are actually "pure" will be treated as impure/unsafe and trigger the manual-fixdiepath.
allow="^[[:space:]]*(#.*)?\$|^[[:space:]]*Defaults:${user_re}[[:space:]]+timestamp_(type|timeout)=[^,[:space:]]+(,[[:space:]]*timestamp_(type|timeout)=[^,[:space:]]+)*[[:space:]]*\$"
Copilot review on #764, round 5: raised \$ in the allow regex as matching a literal dollar rather than ERE's end-of-line anchor. Verified against the running shell rather than taken on faith: the allow string is double-quoted, and bash's own quote processing strips a backslash before $ there regardless, so grep was always receiving a plain $ (confirmed by diffing the stored value of the escaped and unescaped forms, byte for byte identical, plus every fixture scenario in this PR already exercising this exact regex and passing). The finding was a false positive, working from the source text rather than what bash hands to grep. Dropped the now-provably-redundant backslash anyway, since it is exactly what produced this reading once and would read the same way to the next person, and added a comment stating why a bare $ is correct here so it does not get "fixed" back.
|
Answering the suppressed (low-confidence) finding from this review round, 1 of 1 in that block: host-setup/linux/install-tools.sh:1119 (as of the round's head, 0353478): "the allowlist regex uses Disproven, verified against the running shell rather than argued from the source text alone: That said, the finding correctly named a real source of confusion, a |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
host-setup/linux/install-tools.sh:1107
- In
sudo_timestamp_user_re, thecasepattern list includes'*'and'?'. In acasestatement these are glob wildcards, so this branch matches every character and escapes everything (e.g.1becomes\1), which can turn digits into regex backreferences and break matching/classification.
case "$c" in
'.' | '[' | $'\\' | '^' | '$' | '(' | ')' | '*' | '+' | '?' | '{' | '}' | '|') out+="\\$c" ;;
*) out+="$c" ;;
|
Answering the suppressed (low-confidence) finding from this review round, 1 of 1 in that block: host-setup/linux/install-tools.sh:1107 (as of the round's head, 38115d4): "the Disproven, verified against the running shell: quoting a glob metacharacter in a |
… Default (#768) Promote `develop` to `main`, carrying the remaining stages of the hub-hosted reusable-workflow rollout and the changes that landed beside them: - #759 Host Get-Version and Publish-Plan as Hub Reusable Tasks - #760 Host the Validate Task and Reshape the Test Pull Request Stub (settles #729 by design: the hub's validate task runs `uvx <tool>@latest`, since Dependabot tracks the action pins and not a uvx version) - #761 Host the Type-Specific Tasks and Retire the Date Badge - #762 Host the Release Chain and the Docker Core in the Hub - #748 and #752, the staged rollout tracker and the PhotoCleaner merge-bot pilot record - #758 Flip the Fleet Line-Ending Default from CRLF to LF - #753, #755, #756, #764, host-setup and test-collection changes The release that follows this promotion is the first tag carrying every hub task, so it is the pin the stage 2 to 5 adoptions and their catalog snippets use. It is also the first run of the hub's own `publish-release.yml` through `build-release-task.yml` with every target disabled, which is the live proof that `github-release` runs when its build needs are skipped. Closes #729. Refs #521 (hub half shipped, the merge-bot adoption sweep is what remains).
install-tools.sh --sudo-timestampalready detected another sudoers file setting the same timestamp option, but only ever warned about it. A host that already carried a hand-authored drop-in (or one left by a prior run of this script before this fix) ended up with two files asserting the same thing, forever.configure_sudo_timestampnow classifies each conflicting entry it finds:/etc/sudoersitself: die and ask for a manualvisudofix rather than guess which lines are safe to drop from a shared file.A decline leaves the host unchanged, matching the existing confirm-gate pattern. The cleanup also runs when the managed file already has the right content, so a host that already ran the old script and ended up with both files gets the stray one cleaned up on its next run, not just on a fresh install.
Testing
bash -n,scripts/prose_lint.py,scripts/repo_gate.py: clean.koalaman/shellcheck:stablevia Docker, perGOVERNANCE.md): clean.--dry-runagainst a live host carrying a genuine pre-existing duplicate (/etc/sudoers.d/pieter-global-timestamp): correctly classified it as a pure, this-user duplicate and staged its deletion.SUDOERS_FILEand search paths, stubbedvisudo/install, no real root writes), covering: