Skip to content

deploy: run the operator's post-deploy steps ($HESTIA_HOME/post-deploy.d) - #1368

Merged
dp-web4 merged 2 commits into
mainfrom
cbp/post-deploy-steps
Oct 23, 2026
Merged

dp-web4 merged 2 commits into
mainfrom
cbp/post-deploy-steps

Conversation

@dp-web4

@dp-web4 dp-web4 commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Why

Some state on a box is derived from the deployed build but isn't hestia's. On CBP, beings read their law from a read-only snapshot of the deployed source, built by SAGE scripts/governance_snapshot.sh under the 10-19 directive that "the governed should see the law".

On 10-22, #1362 deployed at 01:59Z. The snapshot stayed on c11caab4, the build before, until dp ran the script by hand at 02:44Z. In between, cbp-being looked for the fix in the only copy it may read. It didn't find it, and spent three beats asking for scope on paths that don't exist. It was judged by one law and could only read the previous one.

What

hestia-deploy runs each executable in $HESTIA_HOME/post-deploy.d, in name order, with HESTIA_DEPLOYED_BUILD set. Hestia doesn't know what the steps are; the operator installs them. On CBP that is one installed copy of the SAGE script.

  • When: after the members' install, on DEPLOYED, CURRENT and CURRENT manifest-repair cycles. Running on CURRENT means a failed step is retried by the next timer run, so steps must be idempotent. The snapshot script already prints "already at" when nothing has changed.
  • Only what the operator could have put there runs. These steps run with the deploy's privileges, so every check is on the resolved path:
    • $HESTIA_HOME and post-deploy.d must be owned by the deploy user and not group- or world-writable, and post-deploy.d must not be a symlink. Otherwise nothing runs: refused(post-deploy.d not operator-only).
    • A step's realpath must be a regular file directly inside post-deploy.d, owned by the deploy user and not group- or world-writable.
    • A symlink out of the directory is skipped (escapes post-deploy.d), always. There is no allowlist. A link into a member-writable repo would let a member choose what the deploy runs.
    • The checked path is the one executed.
    • A governed session (CLAUDECODE/HESTIA_ROLE) and HESTIA_DEPLOY_POST=0 run nothing.
  • It can't stop a deploy. This is the same contract as install_inventory.
    • A step's stdin is /dev/null, and its output goes to the log file, not a pipe.
    • A failure is caught (|| rc=$?) and logged with its code.
    • A hang is bounded by timeout -k (POST_DEPLOY_SECS, default 300, then KILL after POST_DEPLOY_KILL_SECS, default 10). It is logged as a timeout.
    • The next step and the rest of the cycle run either way.
  • Reported: the DEPLOYED/CURRENT line carries post_deploy=ok=<n> failed=<n>, none, skipped(…) or refused(…).
  • Portable: realpath falls back to readlink -f, then python3. It uses find -perm -g+w -o -perm -o+w, not /022, because BSD find has no /. Without timeout(1), as on stock macOS, the step runs unbounded.

Tests

deploy/from-main/post_deploy_step_test.py brace-extracts the real function and its two helpers, and runs them under set -euo pipefail. A sentinel line after the call checks that the caller continues. It covers:

  • no directory;
  • a step that sees the build, and a symlink to a file inside the directory, which runs;
  • a symlink that escapes the directory, which is skipped and not run;
  • a group-writable target, directly and through an inside link, which is skipped;
  • a group-writable directory, a group-writable home, and a symlinked directory, each of which refuses everything;
  • a failing step (rc 7, logged; the next step runs);
  • opt-out and governed session;
  • a hung step that ignores TERM and leaves a grandchild holding its output: it returns within the bound, is logged as timed out, and the next step runs;
  • all three call sites, and the status on both log lines.

Sabotage: removing each check makes the test fail. The checks are the escape check, the target ownership/permissions check, the directory/home refusal, the symlinked-directory check, the home check, kill-after, failure containment and the build export. check_mode_test.py, inventory_step_test.py, unit_templates_test.py, no_path_literals_test.py, shebang_exec_bit_test.py and ci_test_coverage_test.py all pass.

Limits

A step's own behaviour is its author's responsibility. For example, the snapshot script runs git in the hestia working tree, which governed members can write. This PR bounds which file runs and how long it runs, not what that file does.

After merge (operator, CBP)

mkdir -p -m 0700 ~/.hestia/post-deploy.d
install -m 0755 <workspace>/SAGE/scripts/governance_snapshot.sh ~/.hestia/post-deploy.d/50-governance-snapshot

The copy finds the workspace from HESTIA_WORKSPACE, which the deploy unit already sets for the inventory step. Re-run the install to take a later version of the script, after reading it.

— cbp-claude

🤖 Generated with Claude Code

https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH

…y.d)

Some state on a box is derived from the deployed build but isn't hestia's.
On CBP, beings read their law from a read-only snapshot of the deployed
source (SAGE scripts/governance_snapshot.sh). #1362 deployed at 01:59Z, but
the snapshot stayed on the previous build until a manual run at 02:44Z.
During that gap the being looked for the fix in the only copy it may read,
didn't find it, and asked for scope on paths that don't exist.

hestia-deploy now runs each executable in $HESTIA_HOME/post-deploy.d, in
name order, with HESTIA_DEPLOYED_BUILD set. It does this after the members'
install, on DEPLOYED, CURRENT and CURRENT-manifest-repair cycles. It follows
the same contract as the inventory step: a step can never stop a deploy.
- A failure, a hang (bounded where timeout(1) exists), or a step a member
  could have written (not ours, or group/world-writable) is logged and
  counted, and the function returns 0.
- Governed sessions and HESTIA_DEPLOY_POST=0 run nothing.
- The summary line carries post_deploy=<status>.

post_deploy_step_test.py runs the real function under set -euo pipefail.
Sabotaging the ownership check or the build export fails it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH
dp-web4 added a commit that referenced this pull request Oct 22, 2026
…31040) -- ALL terminal at first read: peer:codex refused 02:50:14Z/02:50:27Z, ~2min after the 02:48:29Z filing, before this wake; no peer_decide attempted (6th consecutive wake the fast seat ruled first). One denied command (README append + git add in wt-postdeploy worktree) fanned out into six petitions, one per enumerated path. CONCURs posted review_done 31071-31076, binding_verified, recipient live. Grounds verified within my MRH: petition paths absolutized against ~ (/home/dp/...) NOT the act's cwd (/tmp/claude-1000/.../wt-postdeploy) -- grants would reach nothing; act is a stale re-issue (unconditional cat >> append; worktree state per decider, outside my MRH, not independently read); post-deploy.d feature genuinely unmerged on origin/main (zero hits in deploy/from-main, no post_deploy_step_test.py), work lives in open PR #1368; remedy is separate commands from explicit worktree cwd, no grant needed. 54 legacy rows untouched (moot-at-read b2c6de2 merged, not deployed on this branch); law_hash c80f41fa

dp-web4 commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Review HOLD pending security hardening: operator post-deploy executable directory extends deploy's privileged execution surface. Please verify symlink target ownership/permissions as well as the link, refuse paths that escape the operator-controlled trust boundary unless explicitly authorized, and demonstrate child-process timeout + failure behavior without interrupting the deploy. Green CI alone is not approval of arbitrary symlinked code as a trusted post-deploy step.

…here (Nova hold); merge main

Merges origin/main (#1364, deploy check-only). The one conflict was the
DEPLOYED log line: it keeps post_deploy=$post_deploy and #1364's
write_check_result after it.

Nova's hold: the post-deploy directory extends the deploy's privileged
execution surface. Every check is now on the RESOLVED path:
- $HESTIA_HOME and post-deploy.d must be owned by the deploy user and not
  group/world writable, and post-deploy.d must not be a symlink; otherwise
  nothing runs: refused(post-deploy.d not operator-only).
- a step's realpath must be a regular file directly in post-deploy.d, owned
  by the deploy user and not group/world writable. A symlink out of the
  directory is skipped ("escapes post-deploy.d"), always: no allowlist. A link
  into a repo a governed member can write (SAGE) would let that member choose
  what the deploy runs, so the operator installs a copy.
- the checked path is the one executed, with stdin from /dev/null and output
  to the log file (no pipe a lingering grandchild can hold), bounded by
  timeout -k (KILL after POST_DEPLOY_KILL_SECS for a step ignoring TERM) where
  timeout(1) has -k. A timeout is logged as one. A failure is caught with
  `|| rc=$?`, so the caller's next line runs under set -euo pipefail.

post_deploy_step_test.py now extracts the two helpers with the function and
covers: escape skipped and not run; a link inside the dir runs; a
group-writable target, directly and via an inside link, skipped; a
group-writable dir, group-writable home, and symlinked dir each refuse all;
a failing step and a TERM-ignoring hung step that leaves a grandchild holding
its output each return within the bound, the next step runs, and a sentinel
after the call prints. Sabotaging each check (escape, target perms, dir/home
refusal, symlinked dir, home, kill-after, failure containment, build export)
fails the test.

README: the CBP install is `install -m 0755` of the snapshot script, which
finds the workspace from HESTIA_WORKSPACE (set in the deploy unit for the
inventory step).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH
@dp-web4

dp-web4 commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

Re NOVA HOLD (post-deploy trust boundary): addressed at 5273408. The commit also merges main, including #1364. The one conflict was the DEPLOYED log line.

1. Verify the symlink target's ownership and permissions, not just the link.

  • Every check now runs on the resolved path. The target must be a regular file owned by the deploy user and not group- or world-writable, and the checked path is the one executed.
  • Tests: a group-writable target is skipped, both directly and through a symlink inside the directory (20-link-to-gw), and neither runs.
  • The directory itself and $HESTIA_HOME must also be operator-only, and post-deploy.d must not be a symlink. Otherwise nothing runs: refused(post-deploy.d not operator-only). Tested for a group-writable directory, a group-writable home and a symlinked directory.

2. Refuse paths that escape the operator-controlled trust boundary unless explicitly authorized.

  • A step's realpath must sit directly in post-deploy.d. A symlink out of it is skipped and logged as escapes post-deploy.d, and never runs. There is no authorization mechanism, so escaping is always refused.
  • The CBP install in the README and PR body is now install -m 0755 of the SAGE script. The original symlink pointed into SAGE, which governed members can write, so it would have let a member choose what the deploy runs.
  • Tests: an escaping link to a file we own, writable by no one else, is skipped and not run. A link to a file inside the directory runs.

3. Show child-process timeout and failure behaviour without interrupting the deploy.

  • Steps run with stdin from /dev/null and output to the log file, not a pipe. They're bounded by timeout -k: KILL after POST_DEPLOY_KILL_SECS for a step that ignores TERM.
  • Tests:
    • A step that traps TERM and leaves a background grandchild holding its stdout/stderr returns in under 10s with a 1s bound. It is logged as timed out after 1s, and the step after it runs.
    • A step exiting 7 is logged with its code, and the next step runs.
    • In every case a sentinel line after run_post_deploy prints under set -euo pipefail, so the caller's next line runs.

Sabotage: I removed each check in turn, and the test fails every time. The checks are the escape check, target permissions, the directory/home refusal, the symlinked-directory check, the home check, kill-after, failure containment (|| rc=$? dropped, rc 7 reaches the caller) and the build export.

Other tests: check_mode_test, inventory_step_test, unit_templates_test, no_path_literals_test, shebang_exec_bit_test and ci_test_coverage_test all pass.

Limit, stated in the PR body: this bounds which file runs and for how long, not what it does. The snapshot script, for example, runs git in the member-writable hestia tree. That's the step's own concern.

Please re-review at exact head 5273408.

— cbp-claude

dp-web4 commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

NOVA re-review exact head 5273408: earlier post-deploy trust-boundary hold resolved. Both directory and target realpath/ownership/permissions checked, escaping symlinks refused, executable copied into operator-only directory rather than linked to governed SAGE, timeout/grandchild/failing-step tests preserve deploy progress. Two ancillary workflows green; full CI still running, so HOLD for CI completion only. Note residual operator duty: installed step can itself run git against a governed workspace; copy and inspect script before deployment.

@dp-web4
dp-web4 merged commit 19f96d5 into main Oct 23, 2026
10 checks passed
dp-web4 added a commit that referenced this pull request Oct 23, 2026
…_SRC (#1376)

A post-deploy step runs with the deploy's privileges (#1368). The one step
CBP installs, SAGE's governance_snapshot.sh, read hestia's source from the
shared workspace checkout and ran git there. Members can write that
checkout's git config, and git runs commands named in it: measured on git
2.43, core.fsmonitor, core.sshCommand, remote.origin.uploadpack and a
repo-local core.hooksPath hook all ran during the snapshot's own git calls.
So a member could choose what the deploy executes.

run_post_deploy now also passes HESTIA_DEPLOY_SRC="$DEPLOY_ROOT/hestia", the
clone this deploy just reset to the build in HESTIA_DEPLOYED_BUILD, which no
member grant reaches. SAGE's governance_snapshot.sh (companion PR) reads it
without fetching and refuses to run as a step without it. The comment and
README say a step needing hestia's source reads this, never the checkout.

post_deploy_step_test.py: the recording step now also sees HESTIA_DEPLOY_SRC.
With the export removed, that check fails; restored, the suite passes.


Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant