diff --git a/.claude/hooks/guard-taboos-test.sh b/.claude/hooks/guard-taboos-test.sh index 03319be..9c940e6 100755 --- a/.claude/hooks/guard-taboos-test.sh +++ b/.claude/hooks/guard-taboos-test.sh @@ -439,6 +439,29 @@ check pass 'sysadminctl -secureTokenStatus alice' check pass 'groups alice' check pass 'ssh root@h "cat /etc/sudoers.d/deploy"' +# The server lock (/etc/heinzel.conf, rules/access-control.md). +# The machine's owner sets it; heinzel reads it and never lifts +# it, by any of the ways a file gets written, moved or deleted. +check deny 'echo "allow_changes = true" > /etc/heinzel.conf' +check deny 'echo x >> /etc/heinzel.conf' +check deny "sed -i 's/false/true/' /etc/heinzel.conf" +check deny 'rm /etc/heinzel.conf' +check deny 'rm -f /private/etc/heinzel.conf' +check deny 'mv /etc/heinzel.conf /etc/heinzel.conf.off' +check deny 'chmod 000 /etc/heinzel.conf' +check deny 'truncate -s 0 /etc/heinzel.conf' +check deny 'cp heinzel.conf /etc/heinzel.conf' +check deny 'tee /etc/heinzel.conf < x' +check deny 'vi /etc/heinzel.conf' +check deny 'ssh root@h "rm /etc/heinzel.conf"' +check deny 'ssh root@h "sed -i s/false/true/ /etc/heinzel.conf"' +check deny 'python3 -c "import os; os.remove(\"/etc/heinzel.conf\")"' +check pass 'cat /etc/heinzel.conf' +check pass 'ssh root@h "cat /etc/heinzel.conf"' +check pass 'ssh root@h "if [ -e /etc/heinzel.conf ]; then cat /etc/heinzel.conf || echo HEINZEL_CONF_UNREADABLE; else echo HEINZEL_CONF_ABSENT; fi"' +check pass 'grep -i allow_changes /etc/heinzel.conf' +check pass 'ls -l /etc/heinzel.conf' + # --- every code block the skills and rules ship passes --------- # A taboo word used as data in a documented probe is denied like # the command itself and cancels the whole parallel batch. The @@ -935,6 +958,8 @@ if command -v jq >/dev/null 2>&1; then check_file deny Write file_path /etc/group x check_file deny Write file_path /etc/master.passwd x check_file deny Write file_path /sys/firmware/efi/efivars/Boot0001-x x + check_file deny Write file_path /etc/heinzel.conf x + check_file deny Edit file_path /private/etc/heinzel.conf x check_file pass Write file_path /etc/sudo.conf x check_file pass Write file_path /etc/default/grub x check_file pass Write file_path /boot/loader.conf x diff --git a/.claude/hooks/guard-taboos.sh b/.claude/hooks/guard-taboos.sh index 94b24fa..086d1c7 100755 --- a/.claude/hooks/guard-taboos.sh +++ b/.claude/hooks/guard-taboos.sh @@ -32,6 +32,9 @@ # deleting an account or a group, visudo, vipw/vigr, any # write to sudoers(.d/) or to the account files # (/etc/passwd, shadow, group, master.passwd) +# - writing, moving or deleting the server lock +# /etc/heinzel.conf, which only the machine's owner sets +# (rules/access-control.md) # - any of those paths reached through a language # runtime (python/perl/ruby/node/awk ...), whose file # I/O looks nothing like a shell write; awk is exempt @@ -41,8 +44,8 @@ # expansion, whose target nobody sees before it runs # (issue #51) # - a file-edit tool pointed at sshd_config, a key file, -# sudoers, an account file, an efivar or a raw disk -# device (see the file-mode section) +# sudoers, an account file, an efivar, the server lock or +# a raw disk device (see the file-mode section) # # What it deliberately does NOT scan: the body of a heredoc that # is written to an ordinary file by cat or tee (issue #8). That @@ -357,6 +360,12 @@ SUDOERS='/etc/sudoers(\.d(/[[:alnum:]_.-]*)?)?' ACCOUNTS='/etc/(passwd|shadow|gshadow|group|master\.passwd|s?pwd\.db)' EFIVARS='/sys/firmware/efi/efivars' +# The server lock. The machine's owner writes it to keep heinzel +# hands-off; a heinzel that could flip it to true or delete it +# would make it worthless. Unanchored on the left, so macOS's +# /private/etc matches too. Reading it stays allowed. +LOCKFILE='/etc/heinzel\.conf' + # A general-purpose language runtime. See the interpreter section # at the bottom for why this list, and not a list of the ways # those runtimes spell a write. @@ -539,6 +548,10 @@ useradd and passwd make; account work is a Heinzel Pro feature" if target_hit "$EFIVARS"; then deny "writing a firmware variable can brick the board; boot \ work is a Heinzel Pro feature" + fi + if target_hit "$LOCKFILE"; then + deny "/etc/heinzel.conf is the server lock; only the \ +machine's owner changes it, never heinzel" fi exit 0 fi @@ -956,6 +969,23 @@ account work is a Heinzel Pro feature" fi fi +# --- The server lock ------------------------------------------ +# Same write set as sudoers above: a redirect, sed -i, an editor, +# a delete, move or re-permission, a copy or tee onto it. Moving +# or deleting it lifts the lock as surely as writing true. +if hit "$LOCKFILE"; then + if hit '>>?[[:space:]]*["'\'']?[^[:space:];|&]*'"$LOCKFILE" \ + || { hit '(^|[^[:alnum:]_-])(sed|perl)([^[:alnum:]_-]|$)' \ + && hit '(^|[[:space:]])-i'; } \ + || hit "(^|[^[:alnum:]_-])$EDITOR([^[:alnum:]_-]|\$)" \ + || hit "(^|[^[:alnum:]_-])($CLOBBER|cp)([^[:alnum:]_-]|\$)" \ + || writes_to "$LOCKFILE" + then + deny "/etc/heinzel.conf is the server lock; only the \ +machine's owner changes it, never heinzel (cat and grep read it)" + fi +fi + # --- Effects reached through an interpreter ------------------- # Every rule above recognizes a write by the way it is spelled: a # redirect, tee, sed -i, an editor, rm/mv/chmod/truncate. A @@ -1018,6 +1048,10 @@ rewrite it, and a pattern matcher cannot tell that from a read \ line can write a firmware variable - read them with cat or ls \ instead" fi + if hit "$LOCKFILE"; then + deny "an interpreter with /etc/heinzel.conf on its command \ +line can lift the server lock - read it with cat or grep instead" + fi fi if hit "$INTERP_NOAWK" && hit "$ACCOUNTS"; then deny "an interpreter with an account file on its command line \ diff --git a/CHANGELOG.md b/CHANGELOG.md index de91f76..eeb4e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 3.1.0 — 2026-10-15 + +- **A server can lock itself.** `allow_changes = false` + in `/etc/heinzel.conf` puts every heinzel that + connects into read-only mode: it inspects and + reports, but changes nothing. An optional `reason` + line is shown to the user. The check fails closed + (an unreadable file or any value other than `true` + locks), runs on every connection and in local mode, + and is step 6 of `rules/first-connection.md`. The + guard hook blocks every write, move or deletion of + the file, so only the machine's owner lifts the lock. + ## 3.0.0 — 2026-10-15 - **heinzel reads disks, boot, accounts and secrets; diff --git a/CLAUDE.md b/CLAUDE.md index 51fdbd4..a98da8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,11 @@ When SSH stops answering, read Read `rules/access-control.md` for full details. Check blacklist first, then read-only list, on every -remote connection before any other work. +remote connection before any other work. A server +can lock itself with `allow_changes = false` in +`/etc/heinzel.conf`: read it on every connection, +local mode too, and treat a lock like the read-only +list. Never write, move or delete that file. ## Critical Safety Rules diff --git a/README.md b/README.md index d8373f2..197ca7e 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,10 @@ The safety rules are part of every session. `--dangerously-skip-permissions`: halt and poweroff, `mkfs`, partition-table writers, boot-loader and EFI writers, deleting or overwriting SSH keys, writes to - `sshd_config`, deleting accounts, writes to `sudoers` and - the account files. It also catches them inside - `ssh host "…"`, behind `python3 -c` and when the file-edit - tools target one of those files. Read-only forms + `sshd_config`, deleting accounts, writes to `sudoers`, + the account files and the server lock. It also catches + them inside `ssh host "…"`, behind `python3 -c` and when + the file-edit tools target one of those files. Read-only forms (`fdisk -l`, `efibootmgr -v`, `visudo -c`) stay allowed. The hook has no off switch: what it blocks is Heinzel Pro's work. OpenCode does not run this hook; there the written @@ -191,6 +191,22 @@ The safety rules are part of every session. - **Blacklist and read-only servers.** Hosts in `memory/blacklist.md` are never contacted. Hosts in `memory/readonly.md` are inspected but never changed. +- **Server lock.** A machine's owner can lock it against + every Heinzel with a file on the machine itself: + + ``` + # /etc/heinzel.conf + allow_changes = false + reason = Database migration until 20 Oct, hands off + ``` + + Heinzel still connects and inspects, but changes nothing + and reports what it would have done. An unreadable file + or any value other than `true` locks too. Heinzel never + writes, moves or deletes the file; you remove the lock by + hand. It is a promise kept by Heinzel, not a security + boundary: it stops no tool that ignores it + (`rules/access-control.md`). - **Protected paths.** `memory/protected-paths.md` marks paths per server as `readonly`, `hidden` (content never shown) or `confirm` (needs a typed `CONFIRM`). diff --git a/VERSION b/VERSION index 4a36342..fd2a018 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 +3.1.0 diff --git a/rules/access-control.md b/rules/access-control.md index 9f919bc..304cd43 100644 --- a/rules/access-control.md +++ b/rules/access-control.md @@ -1,7 +1,8 @@ # Access Control Rules for the server blacklist and read-only server -list. Both use the same file format and lookup logic. +list, which share one file format and lookup logic, +and for the server lock a host sets for itself. ## Shared File Format @@ -103,3 +104,69 @@ unprivileged mode sysadmin report. The user must remove the entry from `memory/readonly.md` before heinzel will modify the server. + +## Server Lock: `/etc/heinzel.conf` + +The machine's owner can lock it against changes from +the machine itself, for every heinzel that connects, +whoever runs it: + +``` +# /etc/heinzel.conf +allow_changes = false +reason = Database migration until 20 Oct, hands off +``` + +`key = value` per line, `#` comments, blank lines +ignored. The same path on Linux, FreeBSD and macOS +(`/etc` is `/private/etc` there). Root-owned, mode +`0644`. + +**When to check:** on every connection, as the +first SSH call once the SSH user and port are known +(`rules/first-connection.md`, step 6). Local mode +runs the same probe in the local shell. Read it +fresh every time, never from server memory. Run the +probe in a call of its own: + +```bash +if [ -e /etc/heinzel.conf ]; then + cat /etc/heinzel.conf || echo HEINZEL_CONF_UNREADABLE +else + echo HEINZEL_CONF_ABSENT +fi +echo HEINZEL_CONF_DONE +``` + +**How to read it (fails closed):** + +- `HEINZEL_CONF_ABSENT`: no lock, work normally. +- `allow_changes = true`: no lock. Case and the + spaces around `=` do not matter. +- Anything else is a lock: `false`, a missing key, + any other value, an empty file, + `HEINZEL_CONF_UNREADABLE`. +- No `HEINZEL_CONF_DONE` means the check did not + run. Change nothing until it has. + +**On a lock:** switch to read-only mode exactly as +for `memory/readonly.md` above (same allowed and +blocked lists, same deferred modification report). +Tell the user: "This server is locked in +`/etc/heinzel.conf`. I will inspect, but I will not +change anything." Quote `reason` when it is set. + +Either source is enough: a host listed in +`memory/readonly.md` stays read-only whatever the +file says. + +**Never lift it.** heinzel never creates, edits, +moves, deletes or re-permissions `/etc/heinzel.conf`, +not even on request. The owner changes it by hand. +When the user asks heinzel to lock or unlock a +server, give them the command to run themselves. The +guard hook blocks every write to the file. + +The lock is cooperative, not a security boundary: it +stops every tool that honours it. Only withholding +root and sudo keeps a tool out that does not. diff --git a/rules/first-connection.md b/rules/first-connection.md index db1262b..797fc28 100644 --- a/rules/first-connection.md +++ b/rules/first-connection.md @@ -37,13 +37,16 @@ port (`rules/ssh-port.md` → Known port). the one the user named, else `- SSH port:` from the memory read in step 4; a new host without one goes through `rules/ssh-port.md` → first contact. -6. **OS detection.** See `rules/os-detection.md`. -7. **Server memory file.** Create on first +6. **Server lock.** Read `/etc/heinzel.conf` and + switch to read-only mode if it locks the server. + See `rules/access-control.md` → Server Lock. +7. **OS detection.** See `rules/os-detection.md`. +8. **Server memory file.** Create on first connection, read on every subsequent connection. See `rules/server-memory.md`. -8. **Activity check.** Every connection, not just +9. **Activity check.** Every connection, not just the first. See `rules/activity-check.md`. -9. **Then** execute the user's request. +10. **Then** execute the user's request. ## Local mode @@ -51,8 +54,8 @@ In local mode (`localhost`, the user's own hostname), skip steps 1, 2, 4 and 5 — they are remote-only (see `CLAUDE.md` → How It Works → Local mode). -Still run OS detection, server memory, and activity -check. +Still run the server lock, OS detection, server +memory, and activity check. ## Path Access Control @@ -97,8 +100,8 @@ three lists, one glob per line; an optional leading shown. See `rules/secrets.md` → "Paths Marked Hidden". -Precedence, strongest first: server blacklist and -read-only list > `hidden` > `confirm` > `readonly` +Precedence, strongest first: server blacklist, +read-only list and server lock > `hidden` > `confirm` > `readonly` > no entry. A glob is a hint, not a sandbox: also treat commands that reach a protected path indirectly (a recursive copy of its parent, a diff --git a/rules/ssh-port.md b/rules/ssh-port.md index 0f88781..32e898e 100644 --- a/rules/ssh-port.md +++ b/rules/ssh-port.md @@ -30,7 +30,7 @@ No line means port 22. Never write `- SSH port: 22`. does not copy it into memory — two sources drift. On a first connection the memory file does not -exist until step 7 of `rules/first-connection.md`. +exist until step 8 of `rules/first-connection.md`. Use the port for every call of the onboarding and write the line when the file is created.