Skip to content

fix(security): probe login shells by match, not by exclusion - #8

Merged
wintermeyer merged 3 commits into
wintermeyer:mainfrom
oliverandrich:fix/security-skill-guard-collision
Sep 17, 2026
Merged

wintermeyer merged 3 commits into
wintermeyer:mainfrom
oliverandrich:fix/security-skill-guard-collision

Conversation

@oliverandrich

Copy link
Copy Markdown
Contributor

What happened

During a real security audit the heinzel-security skill stopped
dead: its own documented system-account probe was denied by
heinzel's own guard-taboos.sh hook, and because the probe was in
a parallel batch, every sibling check in that batch was cancelled
with it.

Root cause

references/user-accounts.md listed system accounts with
interactive shells by excluding the inert ones by name:

awk -F: '($3 < 1000) && ($7 ~ /(nologin|false|sync|shutdown|halt)$/) {next} ...'

Two of those shell names are power-off commands. The guard scans
the whole command string and cannot tell a regex alternative from
an invocation, so it denies — correctly, by its own charter.

What changed

  • The probe matches instead of excludes. Practically every
    interactive shell ends in sh; every inert one does not. So
    $7 ~ /sh$/ selects exactly the accounts that can log in while
    naming no inert shell at all. A fallback listing every system
    account is documented for shells that break that rule (ksh93).
  • SKILL.md now states the habit rather than the one command,
    and fuses it with the pre-existing zsh !~ warning — both
    hazards push a probe the same way.
  • The guard header gains this case in its known-false-positive
    block, so the next reader sees a real example.
  • CLAUDE.md gains the general rule, because rules/ authors
    write probes too and never read the security skill.
  • guard-taboos-test.sh pins both directions: the retired
    shape must stay denied, the new one must stay allowed.

Why the probe and not the guard

Both guard-side alternatives are pre-rejected in its own header.
Argument-aware parsing "needs a per-command list of which
arguments are data, which is open-ended and would reopen issue
#4". A read-only carve-out for awk over /etc/passwd repeats the
category error of issues #5, #6 and #7 — naming the spelling of a
target instead of the effect on it — and #6 established that an
interpreter's read and write "look alike from outside, so the
read-only exemption cannot be proven". The guard is a coarse
backstop by design; the probe is the layer allowed to be precise.

The new spelling is also independently better: the file's existing
note about awk's !~ not surviving zsh quoting was already
pushing toward a positive match.

Verification

  • sh .claude/hooks/guard-taboos-test.sh — 221 passed, 0 failed.
  • Control check: inverting the new pass-case turns the suite red on
    that exact line, and only that line.
  • The new probe was run against a live Ubuntu 26.04 host. It
    returns root and postgres (the latter ships with /bin/bash
    from postgresql-common.postinst, which is why it is now named
    as an expected exception). A counter-check over all 33 system
    accounts confirms the remaining 31 are nologin/false/sync.

Not in this PR

VERSION and CHANGELOG.md are deliberately untouched — a
version bump drives the automatic tagging workflow, which is the
maintainer's call.

Follow-ups found in the same class (verified, not fixed here)

Three documented procedures are denied by the guard as written.
Each was confirmed against the current tree, not assumed:

  1. rules/deployment.md:79-84 — the "install the public key
    on the server" block is denied four ways, and its last line,
    chown -R deploy:deploy /home/deploy/.ssh, is literally the
    guard's own issue-Note that brew upgrade leaves the old service process running #7 example
    . Rephrasing cannot fix this one:
    the commands genuinely are the guarded effect applied to a key
    the model just created. It needs a decision (a documented
    override, or handing the block to the operator), which is why
    it is not in this PR.
  2. rules/secrets.md:38-51file …/ssh_host_ed25519_key
    and ssh-keygen -lf ….pub each pass alone; batched into one
    call the whole-string scan denies them. Confirmed by feeding
    the batch to the hook directly. SKILL.md actively tells you
    to batch.
  3. rules/cloud-image.md — declares editing sshd_config in
    an offline image a legitimate exception, in prose only. It
    mentions HEINZEL_GUARD_DISABLE zero times, while
    rules/os-replacement.md:34 and CLAUDE.md:129 document the
    override. A PreToolUse deny does not read prose.

Happy to send these as separate PRs if you want them.

The system-account check listed the inert shells to skip, and two of
those names read as power-off commands to the taboo guard, which then
denied the whole parallel batch mid-audit. Matching the shells that do
log in names none of them, and the habit generalizes to every probe.
@wintermeyer wintermeyer self-assigned this Sep 17, 2026
Printing only shells that end in sh got the probe past the taboo guard,
but a security audit then silently dropped accounts with any other
shell, such as ksh93, python3 or an empty field. The probe now skips a
shell only when it is nologin, false or named after its own account, so
nothing it does not recognize goes unreported. The test matrix runs
every bash block the skills ship through the guard and pins the probe's
output on a fixture.

An AI agent wrote this text in my name. I know that is problematic.
@wintermeyer

Copy link
Copy Markdown
Owner

Thanks, Oliver! Your diagnosis was spot on, and fixing the probe rather than the guard was the right call. I changed one thing before merging: printing only shells that end in sh fails open, so accounts with ksh93, python3 or an empty shell field would drop out of the audit. The probe now skips only nologin, false and shells named after their own account, and the test matrix runs every skill's bash blocks through the guard.

An AI agent wrote this text in my name. I know that is problematic.

@wintermeyer
wintermeyer merged commit 147d1bd into wintermeyer:main Sep 17, 2026
1 check passed
wintermeyer added a commit that referenced this pull request Sep 17, 2026
One fix since 2.20.0: the security audit's check for system accounts with a login shell no longer trips heinzel's own taboo guard, and it still reports shells it does not recognize, from Oliver Andrich's #8.

Release notes in CHANGELOG.md; the reasoning is in the preceding commit.

An AI agent wrote this text in my name. I know that is problematic.
wintermeyer added a commit that referenced this pull request Sep 17, 2026
Three changes since 2.20.1, all around the taboo guard: it now catches writes into SSH keys, sshd_config and disks by the common tools (#12), rule files mark the blocks it is meant to deny (#11), and the key fingerprint probe runs in a call of its own (#10). A minor release because the guard now denies commands it used to let through; #10 and #11 close gaps Oliver Andrich found in #8.

Release notes in CHANGELOG.md; the reasoning per change is in the preceding commits.

An AI agent wrote this text in my name. I know that is problematic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants