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
25 changes: 25 additions & 0 deletions .claude/hooks/guard-taboos-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 36 additions & 2 deletions .claude/hooks/guard-taboos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`).
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.0
3.1.0
69 changes: 68 additions & 1 deletion rules/access-control.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
19 changes: 11 additions & 8 deletions rules/first-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,25 @@ 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

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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion rules/ssh-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading