Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/render-invariants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ on:
branches: [master]
paths:
- 'src/quote_renderer.py'
# quote_corpus owns the corpus walk (litclock-dev#590) and, since litclock-dev#870,
# decides WHICH corpus the renderer reads from languages.json
- 'src/quote_corpus.py'
- 'languages.json'
- 'src/gd_measure.py'
# render_invariants derives notch geometry from literary_clock's QR
# constants — a geometry-only change must re-run this gate
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to LitClock are documented here. Format loosely follows [Kee

## [Unreleased]

### Fixed

- Preparing a clock to hand on — gift mode, or a factory reset from the app — now clears the shell history as well, so a previous owner's typed commands do not travel with the device. Preparing an SD card for cloning already did this.

## [v0.228.0] - 2026-09-18

### Changed
Expand Down
82 changes: 73 additions & 9 deletions CLAUDE.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ Translations are welcome — the corpus is built to be localized, and
you start, because both are cheap to check once and effectively unauditable
across thousands of rows later.

A translated corpus is a CSV of its own: point the language's `corpus.path` in
`languages.json` at it and the clock's text renderer reads that file for the
device's active language (litclock-dev#870) — no per-language image set is
needed. The pre-rendered PNGs under `images/` stay English (the registry's
`fleet_default`), which is why a second language needs on-device text
rendering (litclock-dev#871) before it can be activated.

**Name the edition you took a quote from, in the pull request.** Which edition
a row came from is the one fact nobody can recover from a CSV diff.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ The clock keeps working on whatever SHA it's pinned to; manual updates via the a
Most resets don't need a shell — use the control app's **System** tab:

- **Reset WiFi** — forget saved networks and return to the LitClock-Setup network (your settings — location, weather, gift mode — are kept)
- **Factory reset** — wipe all settings, your WiFi, and the setup network's password, then power off. The next power-on raises `LitClock-Setup` with a **new** password shown on the clock's screen, so a phone that saved the old one must forget the network first
- **Factory reset** — wipe all settings, your WiFi, the setup network's password, and the default shell history files, then power off. The next power-on raises `LitClock-Setup` with a **new** password shown on the clock's screen, so a phone that saved the old one must forget the network first
- **Prepare for Gifting** — wipe WiFi, write a welcome message for the recipient, and power off ready to box up

From a shell, the equivalent is:
Expand All @@ -315,7 +315,7 @@ Flags:
- `--reboot` — reboot automatically after reset
- `--keep-wifi` — keep your WiFi **and** the setup network's password. For a technical user resetting their own clock: the device stays on its network, never starts a setup network, and an SSH session survives the reset
- `--wipe-wifi` — no-op; erasing both passwords is the default (litclock-dev#666)
- `--gift-mode` — prepare for shipping: wipes WiFi, paints a welcome splash on the e-ink, and powers off. Implies `--wipe-wifi --yes`.
- `--gift-mode` — prepare for shipping: wipes WiFi and the default shell history files, paints a welcome splash on the e-ink, and powers off. Implies `--wipe-wifi --yes`.

### Troubleshooting

Expand Down
4 changes: 2 additions & 2 deletions docs/recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ clock over SSH: it preserves both, so the device returns to its own network and
never raises a hotspot — which also means your SSH session survives. Without it,
a reset over SSH drops the connection when the WiFi goes.

Use `--gift-mode` to prepare a device for shipping to someone else (wipes WiFi +
config, writes a welcome splash, powers off). See
Use `--gift-mode` to prepare a device for shipping to someone else (wipes WiFi, config
and the default shell history files, writes a welcome splash, powers off). See
[SD Card Cloning](sd-card-cloning.md) for duplicating a configured card.

---
Expand Down
2 changes: 1 addition & 1 deletion docs/script-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Scripts are organized into `scripts/` (shell) and `src/` (Python):
| `src/clear.py` | `python3 src/clear.py` | Clear the e-ink display to white |
| `src/wifi_provision.py` | `python3 src/wifi_provision.py [flags]` | WiFi provisioning via captive portal hotspot |
| `scripts/update.sh` | `./scripts/update.sh` | Pull latest code and apply updates in-place |
| `scripts/reset-setup.sh` | `sudo ./scripts/reset-setup.sh [--yes] [--reboot] [--poweroff] [--keep-wifi] [--gift-mode]` | Reset configuration (see [Resetting](../README.md#resetting)); `--gift-mode` preps the device for shipping with a welcome splash. **`--gift-mode` and `--poweroff` both disable SSH before powering down** (litclock-dev#528) — a handed-on device gets a fresh-flash posture; re-enable per [recovery](recovery.md) |
| `scripts/reset-setup.sh` | `sudo ./scripts/reset-setup.sh [--yes] [--reboot] [--poweroff] [--keep-wifi] [--gift-mode]` | Reset configuration (see [Resetting](../README.md#resetting)); `--gift-mode` preps the device for shipping with a welcome splash. **`--gift-mode` and `--poweroff` both wipe and lock the default shell history files (litclock-dev#868) and disable SSH before powering down** (litclock-dev#528) — a handed-on device gets a fresh-flash posture; re-enable per [recovery](recovery.md) |
| `scripts/prepare-for-cloning.sh` | `sudo ./scripts/prepare-for-cloning.sh` | Wipe config and credentials for SD card cloning (see [Creating SD Cards](sd-card-cloning.md)) |
| `scripts/cut-release.sh` | `./scripts/cut-release.sh vX.Y.Z [--expect-sha SHA] [-m MSG] [--yes]` | Promote the CHANGELOG heading, commit and tag a release — does not push (see [Building the Image](building-image.md)) |
| `scripts/install.sh` | _(retired)_ | Superseded by the flashed image; see the "Running your own code on the clock" section in [README.md](../README.md) |
Expand Down
13 changes: 8 additions & 5 deletions scripts/first-boot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,10 @@ disable_first_boot() {
# explicit `history -w` and the HISTFILESIZE truncate all fail with EISDIR,
# root included). That directory rides every clone; this puts the paths back
# to "absent" so bash creates a normal file on the recipient's first login.
# Since litclock-dev#868 the same lock (clear_and_lock_bash_history in
# lib/state.sh) is left by reset-setup.sh's gift-mode and --poweroff arms too,
# and a reset lands on this same not-yet-set-up path, so one restore covers
# every handoff. The name keeps its clone-prep origin.
#
# `rmdir`, unconditionally, through sudo: it removes an EMPTY DIRECTORY and
# nothing else, so a real history file (ENOTDIR) or an absent path (ENOENT) is
Expand All @@ -533,10 +537,10 @@ restore_bash_history_after_clone_prep() {
case "$_verdict" in
CLEAR) ;;
LOCKED)
log "WARN clone-prep history lock at $_p could not be removed; shell history will not be saved there (litclock-dev#834)"
log "WARN handoff history lock at $_p could not be removed; shell history will not be saved there (litclock-dev#834, litclock-dev#868)"
;;
*)
log "WARN could not check the clone-prep history lock at $_p (the privileged probe did not run); if shell history is not saved there, remove it with: sudo rmdir $_p (litclock-dev#834)"
log "WARN could not check the handoff history lock at $_p (the privileged probe did not run); if shell history is not saved there, remove it with: sudo rmdir $_p (litclock-dev#834, litclock-dev#868)"
;;
esac
done
Expand All @@ -557,9 +561,8 @@ main() {
exit 0
fi

# litclock-dev#834 — undo prepare-for-cloning.sh's history lock before
# anything else on the not-yet-set-up path, which is the only path a
# cloned card takes, so the recipient's first login gets a normal history.
# litclock-dev#834 / litclock-dev#868 — undo the handoff history lock before anything
# else on the not-yet-set-up path (see the function header).
restore_bash_history_after_clone_prep /home/pi/.bash_history /root/.bash_history

# Stop the clock timer — if re-running first-boot (e.g. after removing
Expand Down
99 changes: 99 additions & 0 deletions scripts/lib/state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,105 @@ atomic_remove_file() {
rm -f "$target" 2>/dev/null || sudo rm -f "$target" 2>/dev/null || true
}

# ─── shell-history wipe + write-back lock (litclock-dev#834, litclock-dev#868) ──
#
# clear_and_lock_bash_history <path>... — empty each history path, then replace
# it with an empty DIRECTORY so that shells still open when the device powers
# off cannot write their history back. Shared by prepare-for-cloning.sh (Step
# 6) and reset-setup.sh's two handoff arms (gift mode and the --poweroff
# factory reset), because all three exist to pass the device to someone else
# and the previous owner's typed commands — an `nmcli … password …` line
# included — must not go with it. Before litclock-dev#868 only clone prep did this.
#
# `history -c` reaches only the calling script's own non-interactive shell.
# The console or SSH shell the operator ran the script FROM writes its history
# back on exit, during the power-off — bash's save_history() APPENDS the
# session's lines, then truncates to HISTFILESIZE — so on the bench the file
# `rm` had removed was back eight seconds later holding the operator's last
# command. A directory at the path fails open(O_WRONLY|O_APPEND), rename() over
# it (`history -w` writes a sibling temp file and renames) and the truncate's
# read with EISDIR, which no capability bypasses (root's DAC override beats a
# mode-0 file; a /dev/null symlink loses to the rename), and one `rmdir`
# removes it. scripts/first-boot.sh removes both directories on the
# not-yet-set-up path (restore_bash_history_after_clone_prep), which every
# clone AND every reset lands on, so the next owner's first login gets an
# ordinary history file.
#
# Reports, does not decide: the caller owns fatality. On return,
# _HIST_DIRTY paths whose CONTENTS survived (could not be removed — an
# immutable file, a read-only parent, a NON-EMPTY directory
# left by an earlier aborted run — or were refused because
# removing the NAME would not remove the contents: a symlink,
# or a file with a second hard link);
# _HIST_UNLOCKED paths that were emptied but could not be locked;
# _HIST_LOCKED paths now holding the lock — a caller that aborts can tell
# the operator which locks stay until the next boot.
# Returns 0 when DIRTY and UNLOCKED are both empty, 1 otherwise. The `rmdir`
# before `rm -f` takes the lock a previous run left (and refuses a non-empty
# one); an absent path satisfies both harmlessly.
#
# What the lock is, and is not (litclock-dev#873 review): a barrier against bash's own
# exit-time write-back and `history -w`, which is the measured leak. It is a
# root-owned directory in a pi-writable home, so the pi user can `rmdir` it —
# that user is the device's current owner, and a hostile owner racing their
# own reset is outside the threat model (the previous owner's history reaching
# the NEXT owner). Only the two default paths are covered; a shell with its own
# HISTFILE saves wherever that points.
clear_and_lock_bash_history() {
history -c 2>/dev/null || true
_HIST_DIRTY=()
_HIST_UNLOCKED=()
_HIST_LOCKED=()
local _h _links
for _h in "$@"; do
# Refuse to follow (litclock-dev#873 review, Codex): `rm -f` on a SYMLINK unlinks
# the link and leaves its target — contents included — on the device;
# a file with a second hard link survives the same way. Both are the
# owner's own arrangement, and the honest verdict is "could not clear",
# never a green line over a file that is still there. Checked BEFORE
# anything is removed, so the evidence of what survived is intact.
# The one symlink that is fine is the standard "disable history" idiom,
# `ln -sf /dev/null ~/.bash_history` (litclock-dev#873 red team): a character
# device holds nothing, so the link is simply removed and locked over.
# `-c` follows the link; `-L` does not.
if [[ -L "$_h" && ! -c "$_h" ]]; then
_HIST_DIRTY+=("$_h")
continue
fi
if [[ -f "$_h" ]]; then
_links=$(stat -c %h "$_h" 2>/dev/null || echo 0)
if [[ "$_links" != 1 ]]; then
_HIST_DIRTY+=("$_h")
continue
fi
fi
# `chattr +a` / `+i` on .bash_history is a common hardening idiom, and
# on the --poweroff arm a DIRTY verdict lands after env.sh is wiped and
# the key rotated, with no PWA left to retry from (litclock-dev#873 review, Claude
# adversarial). Every caller runs as root, so clear the attributes
# first; on a non-ext4 path or a missing chattr this is a no-op and the
# verdict below still tells the truth. Only reached for a regular file
# or a directory — the symlink case was refused above.
chattr -ia "$_h" 2>/dev/null || true
rmdir "$_h" 2>/dev/null || rm -f "$_h" 2>/dev/null || true
if [[ -e "$_h" || -L "$_h" ]]; then
_HIST_DIRTY+=("$_h")
continue
fi
# mkdir IS the lock, and its status is the verdict: it either created
# OUR empty directory or something landed at the path between the rm
# and here. A failure is reported as UNLOCKED, not swallowed and then
# re-tested with `-d`, which follows a symlink and would have called a
# pi-planted link "locked" (litclock-dev#873 review, Codex + security specialist).
if mkdir "$_h" 2>/dev/null && [[ -d "$_h" && ! -L "$_h" ]]; then
_HIST_LOCKED+=("$_h")
else
_HIST_UNLOCKED+=("$_h")
fi
done
(( ${#_HIST_DIRTY[@]} == 0 && ${#_HIST_UNLOCKED[@]} == 0 ))
}

# ─── env.sh writer-lock helpers (issue litclock-dev#274) ─────────────────────────
#
# Three shell writers mutate env.sh (update.sh Phase 3, reset-setup.sh,
Expand Down
10 changes: 7 additions & 3 deletions scripts/litclock-lkg-record.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@
# ▼
# write lkg-sha (atomic: .tmp + mv)
#
# Bootcheck/revert is a separate follow-up (issue litclock-dev#241 → bootcheck-revert)
# and is intentionally NOT shipped here. This script is observability +
# substrate; consumption lands in its own PR after we have field data.
# This script is the substrate: it records the LKG sha and nothing else.
# Consumption SHIPPED — `litclock-bootcheck.sh` reads this sha, counts failed
# boots, pins `rollback-target`/`blocked-sha` and triggers the updater in
# rollback mode. The sentence that stood here said bootcheck/revert was "a
# separate follow-up ... intentionally NOT shipped", which stopped being true
# when it landed; corrected 2026-09-19 alongside litclock-dev#847's own
# pending-work wording.

set -uo pipefail

Expand Down
Loading
Loading