Skip to content

release: v1.13.0 — the account that serves a project can read its plugins, and .env can be audited - #160

Merged
hakeemRash merged 23 commits into
mainfrom
dev-mac
Sep 2, 2026
Merged

hakeemRash merged 23 commits into
mainfrom
dev-mac

Conversation

@hakeemRash

Copy link
Copy Markdown
Contributor

Promotes ## [Unreleased] to ## [1.13.0] - 2026-09-03. Merging this pushes
v1.13.0 and runs the build/publish chain; the Homebrew formula updates itself
afterwards.

Fixed

  • --owner= left every plugin file owned by the deploying user. A project's
    plugins are symlinks into the global store, and both halves of the hardening
    pass stop at that boundary on purpose — hardenTree skips symlinks because a
    chmod would follow one out of the project, and the chown only walked the
    project root. So the pool could write logs it was never going to reach the code
    to produce, under a report reading Project owned by deploy:www-data. --owner
    now covers the store versions the project links to, plus the directories
    between them and the store root. Only the versions this project links to —
    the store is shared.
  • --production called a project reachable while its plugins were not. The
    traversal check walked the project's parents only. The store defaults to
    $HOME/.cache, which a sudo deploy resolves to /root/.cache (0700 on every
    mainstream distro), so the chown succeeded on every entry and the site still
    read none of them. Checked now, with its own remedy: relocate the store rather
    than widen a home directory.
  • A plugin that gained an env var never got it, because enable returns
    early once the wiring is done. Enabling an already-enabled plugin tops up its
    block; the seeder still only ever adds keys the file does not mention, so no
    secret is rewritten.
  • Re-seeding opened a second block per plugin instead of merging into the one
    it already owns.

Added

  • hkm env (audit / dedupe / group). Duplicate keys are not an error
    anywhere: the loader resolves them silently and the last active assignment
    wins, so a key appended at the bottom overrides the one in its proper block.
    audit marks which line is live, dedupe asks per key rather than guessing
    (--keep=effective is the scriptable form that cannot change behaviour), and
    group reorders by declaring plugin then by feature. The group pass refuses to
    write unless every key and every informational comment survives; every write
    leaves a 0600 .env.bak.
  • A project is found from anywhere inside it. resolveRoot walks up for
    proj.json, so every command taking a [path|name] works from a subdirectory.
    An explicit path stays exact — the same resolver backs install --owner.

Verified

  • zig build test — 179 pass. vendor/bin/phpunit — 389 tests, 723 assertions, green.
  • --owner exercised against a fixture project and store via HKM_CHOWN_BIN
    pointed at a recording stub: version dirs chowned recursively, intermediate
    dirs once, dangling links skipped, nothing above the store root touched.
  • hkm env run against a real 222-line .env: key sets byte-identical before
    and after group, no informational comment lost, idempotent across three runs.
  • Project detection confirmed from the root and four depths of subdirectory, and
    confirmed to still fail cleanly outside any project.

No code change: `git diff 378cd6f 8496e49` is empty, so this is byte-identical
to what v1.9.0 already ships. What changes is whether the pointer SURVIVES.

v1.9.0 pinned `378cd6f`, a commit on `fix/portable-alter-table`. That branch has
since merged (Let-Migrate PR #8), and a merged branch is a branch someone
deletes — at which point `git submodule update` fails for anyone checking out
the v1.9.0 tag, with nothing in the kernel to say why. `8496e49` is the tip of
`main`, which is not going anywhere.

This is the same failure `modules/http` was one branch-deletion away from
earlier today, fixed the same way.

No CHANGELOG entry and no version bump: a gitlink that resolves to identical
content is not something a user of the kernel can observe, and auto-release
reads the top heading, which stays `1.9.0`.
Restores the four driver fixes and the Laravel-parity alias that 9ac3a8e undid
-- that commit put src/, tests/ and README back byte-for-byte to the state
before a day of merged PR work, under a message describing a cleanup -- then
carries them forward with what running them against real engines turned up.

Verified by execution, not by compiling and reading: SQLite, MariaDB 12.3 and
PostgreSQL 18 all run the full DDL lifecycle green. SQL Server is the one gap;
no server was reachable and the sqlsrv extension is absent here, so its two
fixes are argued from the T-SQL specification. tests/Live runs them the moment
LETMIGRATE_DB_SQLSRV points at a server, and skips with the reason until then.

docs/guides/18_MIGRATIONS.md: useCurrent() and useCurrentOnUpdate() now exist,
so the anti-pattern entry saying they do not is corrected. The ->index()
half of it still stands -- an index is declared on the Blueprint, not the
column.
A commit titled "refactor: remove deprecated methods" had put let-migrate's
src/, tests/ and README back byte-for-byte to their state before a day of
merged PR work, undoing four driver fixes and deleting the eight tests that
covered them. Nothing was failing that the deletion fixed. This restores them
and carries them forward with what running the compiler against real engines
turned up -- including a PostgreSQL bug where every schema lookup silently
matched nothing, because libpq's $1 placeholder is one PDO neither understands
nor rejects.

Verified by EXECUTION on SQLite, MariaDB 12.3 and PostgreSQL 18. SQL Server is
the gap: no server was reachable and the sqlsrv extension is absent, so its two
fixes are argued from the T-SQL specification rather than demonstrated. The new
tests/Live suite runs them the moment LETMIGRATE_DB_SQLSRV points at a server,
and skips with the reason until then -- never counted as a pass.
Picks up the commit-msg hook and the composer wiring that points a clone at it.
Without this bump the submodule checkout stays on f13c977, which has neither --
and that is the checkout anyone working in this workspace actually uses, so the
hook would protect the standalone clone and nobody else.

No functional change to the migration engine.
… serves it

`hkm install --production --owner=` only ever touched var/ and userdata/, so
every directory a request actually reads — app/public_html, src/, vendor/,
plugins/ — kept the deploying user's ownership and whatever mode the clone
arrived with. The pool could write logs it was never going to reach the code
to produce.

Three separate reasons a PHP-FPM boot failed on a tree that runs fine from
the shell:

  - the pass covered two directories out of the project. It now covers the
    whole tree, and runs LAST — composer install and the plugin fetch both
    create files (vendor/, plugins/) as whoever ran the command, so running
    at step 3 meant the two largest directories in the project were created
    after the permissions were "fixed";
  - .env was chmod'd 0600, which no pool running as another account can read
    APP_KEY through. It is 0640 now — group-readable, never group-writable,
    never world-anything;
  - nothing reported that the pool could not TRAVERSE to the project, which
    is unfixable from inside it: a home directory is 0700 on a stock Debian
    install. The offending parents are named, and only named.

The model is split ownership: code owned by the deploy user and reachable by
the web server through the GROUP (2750/0640), var/ and userdata/
group-writable (2770/0660). Code is never group-writable in either profile —
an FPM pool that can rewrite the PHP it executes turns any file-write bug
into code execution. Every directory carries setgid, code included, so a
file a later deploy lands does not take the deploying account's primary
group and drop out of the share. An already-executable file keeps its exec
bit, re-granted only where the profile grants read, so bin/psp and
vendor/bin/* survive at 0750 rather than 0751.

Separately, the installed kernel was left at whatever the installing
account's umask produced. /opt/hkm-kernel is shared infrastructure — every
pool on the box loads its PHP from that one tree and none of them runs as
the installing account — so umask 027 or 077 left it unreadable to all of
them, while the install reported success because the installer could
obviously read what it had just written. install.sh now normalises the tree
it lays down.
`FileManager` was the one hyphenated plugin missing from the slug override
table, so it resolved to hkm-plugin-filemanager — a repository that does not
exist. GitHub answers 404 for "does not exist" and "not yours" alike; it will
not confirm a private repository to an anonymous request. Git cannot tell the
two apart, assumed the second, and stopped to ask for a username and password
that no account could have satisfied, on a plugin anyone can clone.

Added the override, plus tests pinning every multi-word folder to its real
hyphenated slug and round-tripping it back to the PSR-4 folder name, so the
next repo added with a hyphen cannot drift the same way.

Separately, the fetch inherited the terminal, so an unreachable remote hung
the whole install on a password box until somebody killed it — on a deploy
box or in CI, indefinitely. Every git invocation now runs with
GIT_TERMINAL_PROMPT=0 and SSH BatchMode=yes: the remote fails immediately and
the call site names the plugin and the URL, which is the information actually
needed. HKM_GIT_INTERACTIVE=1 restores the prompt for a genuinely private
remote to authenticate against by hand.
…gins, and .env can be audited

--owner stopped at the project boundary. A project's plugins are symlinks into
the global store, and both halves of the hardening pass skip symlinks on
purpose, so every plugin file kept the deploying user's ownership under a report
that said the project was owned by the pool. It now chowns the store versions
the project links to, and the traversal check covers the store's own parents —
the store defaults under $HOME, which a sudo deploy resolves to /root/.cache.

hkm env is new: a .env accumulates duplicate keys that no parser reports,
because the loader resolves them silently and the last active assignment wins.
It reports them with the live one marked, resolves them one prompt at a time,
and groups the file by declaring plugin then by feature.

Enabling an already-enabled plugin now tops up its env block, so a plugin that
declares a new config[] entry in a later version stops failing the boot on a key
nothing wrote; new keys merge into the block the plugin already owns instead of
opening a second one.

resolveRoot walks up to find proj.json, so every command that takes a project
works from anywhere inside it. An explicit path stays exact.
The example read TENANCY_CONTROL_PLANE=admin.example.com, which parses as "the
control plane is served from this host". The Tenancy plugin declares the key as
type: bool, and any non-empty string is truthy — so uncommenting the documented
value switched tenant routing off entirely, which is the opposite of what a
multi-tenant deployment is configuring.

Adds TENANCY_CENTRAL_DOMAINS, a declared key the example never mentioned, and
names the modes. Verified against the plugin's module.json config[], which
remains the authority.
@hakeemRash
hakeemRash merged commit 30bc339 into main Sep 2, 2026
11 checks passed
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