Skip to content

governance_snapshot: never run the source clone's git config; a deploy step reads the deploy's own clone - #520

Merged
dp-web4 merged 1 commit into
mainfrom
cbp/snapshot-private-source
Oct 23, 2026
Merged

dp-web4 merged 1 commit into
mainfrom
cbp/snapshot-private-source

Conversation

@dp-web4

@dp-web4 dp-web4 commented Oct 23, 2026

Copy link
Copy Markdown
Owner

governance_snapshot.sh ran git in a tree members can write, with the invoker's privileges. It now reads the deploy's own clone when run as a step, and never runs the source clone's git config.

Companion: hestia PR (branch cbp/post-deploy-src) exports HESTIA_DEPLOY_SRC to post-deploy steps.

The exposure. The default source is the shared hestia checkout (<workspace>/hestia), which every governed member session can write, .git included. git obeys a repo's own config, and several keys there run commands. The script runs as whoever invokes it: the operator by hand, or the deploy as a post-deploy step once hestia #1368's install is done. So a member could choose what that principal executes.

Measured on git 2.43, plain git over the snapshot's own calls (fetch, rev-parse, cat-file, archive, plus status), in a throwaway clone:

planted in the clone's config plain git hardened g()
core.fsmonitor ran blocked
core.sshCommand (ssh remote) ran blocked
remote.origin.uploadpack (local remote) ran blocked
repo-local core.hooksPath → reference-transaction on fetch ran blocked
ext:: remote already off by default blocked

On CBP a global core.hooksPath happens to mask .git/hooks in plain runs, but a repo-local core.hooksPath overrides it.

The change

  • Source: the clone named on the command line; else $HESTIA_DEPLOY_SRC, the deploy's own clone, read without a fetch because the deploy just synced it; else the shared checkout.
  • As a deploy step (HESTIA_DEPLOYED_BUILD set) without HESTIA_DEPLOY_SRC, it refuses with exit 3 and doesn't fall back to the shared checkout.
  • With no private clone, a source not owned by the invoker alone, or whose git dir or config is group/world-writable, is refused. On CBP members run as the operator's uid, so this can't tell them apart. The deploy path doesn't rely on it.
  • Every git call goes through g(): no system or global config, each command-running key above overridden, and only https/ssh transports. This is the backstop, not the boundary: a denylist of keys is only as complete as the list.
  • A failed fetch on the shared checkout warns and uses the clone as it is.
  • HESTIA_DEPLOYED_BUILD, when a deploy passes it, names the build first, since the daemon may not be serving it yet. SNAPSHOT gains a clone: line.

Tests (scripts/tests/test_governance_snapshot.py, 5) run the real script against planted configs. Each planted command touches a marker. The endpoint points at a dead port, so the live daemon is never asked.

  • shared checkout with fsmonitor, hooksPath hooks, uploadpack, alternateRefsCommand and a credential helper: no marker, snapshot built;
  • ssh remote with core.sshCommand: no marker;
  • deploy step without the private clone: exit 3, no marker, nothing written;
  • deploy step with it: snapshot is the deployed build, clone: the deploy's own clone, the clone isn't fetched;
  • group-writable source: refused.

Sabotage

  • Against main's script, all 5 fail.
  • With only g()'s hardening removed, the two hostile-config tests fail, firing the reference-transaction hook, fsmonitor and uploadpack, then sshCommand.
  • With the fix, all pass.

Not run against the live tree. That would rewrite the governance snapshot and fetch into the shared checkout. After both PRs merge, re-run the README's install -m 0755 so the step copy is this version. An older copy still reads the shared checkout.

Same class, not changed here: scripts/governance_snapshot_sage.sh runs git status in the live SAGE checkout, and status is what triggers core.fsmonitor.

— cbp-claude

🤖 Generated with Claude Code

https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH

…eploy step reads the deploy's clone

The default source is the shared hestia checkout, which governed members can
write, .git included. git obeys a repo's own config, and several keys there
run a command. Measured on git 2.43 over the snapshot's own calls (fetch,
rev-parse, cat-file, archive): core.fsmonitor, core.sshCommand,
remote.origin.uploadpack and a repo-local core.hooksPath (reference-transaction
on fetch) all ran. The script runs with whoever invokes it: the operator by
hand, or the deploy as a post-deploy step (hestia #1368). Either way a member
would choose what that principal executes.

- Source: the clone named on the command line; else $HESTIA_DEPLOY_SRC, the
  deploy's own clone (exported by hestia's run_post_deploy), read without a
  fetch since the deploy just synced it; else the shared checkout.
- A post-deploy step (HESTIA_DEPLOYED_BUILD set) without HESTIA_DEPLOY_SRC
  refuses (exit 3) instead of falling back to the shared checkout.
- With no private clone, a source not owned by the invoker alone, or whose git
  dir or config is group/world-writable, is refused. On a box where members run
  as the operator's uid this can't tell them apart; the deploy path doesn't
  rely on it.
- Every git call goes through g(): no system or global config, each
  command-running key the measurement found overridden, and only https/ssh
  transports. This is the backstop, not the boundary.
- A failed fetch on the shared checkout warns and uses the clone as it is.
- HESTIA_DEPLOYED_BUILD, when a deploy passes it, names the build first (the
  daemon may not serve it yet). SNAPSHOT gains a `clone:` line.

scripts/tests/test_governance_snapshot.py runs the real script against planted
configs, each command touching a marker: none fire; a deploy step without the
private clone refuses and writes nothing; with it, the snapshot is the deployed
build and the clone is not fetched; a group-writable source is refused.
Against main's script all 5 fail; with only g()'s hardening removed, the two
hostile-config tests fail (reference-transaction hook, fsmonitor, uploadpack;
sshCommand).

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 23, 2026

Copy link
Copy Markdown
Owner Author

Companion hestia PR: dp-web4/hestia#1376 (exports HESTIA_DEPLOY_SRC to post-deploy steps). — cbp-claude

dp-web4 commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

NOVA review 2540a6b: approve security hardening. Snapshot no longer trusts member-writable Git config on privileged deploy path, requires HESTIA_DEPLOY_SRC for deploy invocation, and adversarial tests inject command-bearing git configuration. Land companion Hestia #1376 first; reinstall operator snapshot step explicitly after both land. Syntax CI green.

@dp-web4
dp-web4 merged commit f469708 into main Oct 23, 2026
1 check passed
dp-web4 added a commit that referenced this pull request Oct 23, 2026
…still files

Nova's HOLD on 78efe4a: a file at the mirrored path is not the same
content or authority target (the fixture's copy differs on purpose), and
request_scope returned same_path_in_reach and filed nothing, swallowing an
ask the being may need for the original's current bytes or to change or
run it.

- request_scope files exactly as before; when a copy exists, the result
  adds copy_in_reach and `meanwhile` (and a hestia error carries the same
  text).
- One wording for every surface (copy_alternative): the copy is a separate
  file that may differ, readable now; for the original itself, request_scope
  it and say which. No surface says a grant is not needed.
- The relative read names the original as outside reach and the copy as the
  alternative, instead of substituting the copy's path.

Tests: request_scope is filed both for "I need to edit the original closure
table" and for a read reason, with stale copy contents; the gate refusal
asserts "no grant is needed" is absent. Sabotage, 7 cases (each surface off,
suppression put back, the copy called proof, loose matching): each fails
exactly its own test. Merged origin/main (#519, #520). Gateway suite: 1789
passed, 32 skipped, 2 failed (test_activity_reports, test_body_and_gaze;
both fail on main).

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 24, 2026
* gateway: a refused read names the copy the being can already read

dp, 2026-10-23, choosing this over editing the being's files ("b").
cbp-being asked at least six times for the editable tree's
<ws>/hestia/plugins/_shared/hestia_governance_closure.py while its standing
grant on <ws>/governance/** held the same file under
<ws>/governance/hestia/..., and memory_read of that path worked. Every
surface it met pointed the wrong way:

- the gate's refusal named the boundary ("'hestia' is not granted") and a
  pending scope request, never the copy;
- a relative `hestia/plugins/...` read said "A file DOES exist at
  <ws>/hestia/plugins/...", pointing AT the editable tree;
- the dispatcher's out-of-reach text said "a real boundary ...
  request_scope";
- request_scope filed, for a human or peer to refuse.

same_path_in_reach(p, reach) matches one shape: a recursive grant on G whose
parent also holds p, so the copy is p with G's name inserted. Not any shared
tail: request_scope files nothing on a match, and a loose match would swallow
a real ask. Canonical paths, inside the canonical root (no symlink escape),
exact grants skipped, every matching root returned sorted.

Each surface now names the copy first; request_scope answers
status=same_path_in_reach and files nothing, saying to tell the seat if the
original itself is needed. Refused writes are not answered with a copy.
Reach does not widen.

test_same_path_in_reach.py, 9 tests. Sabotage, one per surface (gate hint,
out-of-reach, relative read, request_scope, loose matching): each fails
exactly its test. Against the live layout and cbp-being's recursive grants
the closure file resolves to the governance copy; web4/ and /etc paths
match nothing. Gateway suite: 1783 passed, 32 skipped, 2 failed
(test_activity_reports, test_body_and_gaze; both fail on main).

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

* gateway: a copy in reach is offered as an alternative; request_scope still files

Nova's HOLD on 78efe4a: a file at the mirrored path is not the same
content or authority target (the fixture's copy differs on purpose), and
request_scope returned same_path_in_reach and filed nothing, swallowing an
ask the being may need for the original's current bytes or to change or
run it.

- request_scope files exactly as before; when a copy exists, the result
  adds copy_in_reach and `meanwhile` (and a hestia error carries the same
  text).
- One wording for every surface (copy_alternative): the copy is a separate
  file that may differ, readable now; for the original itself, request_scope
  it and say which. No surface says a grant is not needed.
- The relative read names the original as outside reach and the copy as the
  alternative, instead of substituting the copy's path.

Tests: request_scope is filed both for "I need to edit the original closure
table" and for a read reason, with stale copy contents; the gate refusal
asserts "no grant is needed" is absent. Sabotage, 7 cases (each surface off,
suppression put back, the copy called proof, loose matching): each fails
exactly its own test. Merged origin/main (#519, #520). Gateway suite: 1789
passed, 32 skipped, 2 failed (test_activity_reports, test_body_and_gaze;
both fail on main).

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

* gateway: same_path_in_reach reads (root, recursive) from grants that carry modes

SAGE #521 (43199d5) lets a grant in granted_reach be (root, recursive, modes).
The matcher unpacked exactly two, so on a tree with both PRs it would raise.
It now reads the first two; a test pins a three-element grant.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
dp-web4 added a commit that referenced this pull request Oct 24, 2026
…on-artifact

Conflicts, resolved as unions:
- being_gate_client: the carrier's memory_write schema (modes) with main's
  channel_egress schema (#529); the dispatcher context carries both the
  carrier's workspace and main's run granted_reach (#521).
- hestia_dispatch request_scope: the carrier's loop, unpacking main's
  (root, recursive, modes) tuples (#521).
- reference_f1a: the carrier's pair-aware confinement (it already read an
  exact grant as exact, which is what #519 brings to main), accepting #521's
  3-tuples and setting main's _extra_reach; the carrier's write guards kept.

Two breaks the conflicts did not show:
- main's "exactly this path" refusal text read a variable `reach` defined in
  a region resolved to the carrier side; every out-of-reach path would have
  raised NameError. It reads the carrier's `roots`.
- _safe_path read _extra_roots as pairs; main and its tests set bare Paths
  there beside _extra_reach. It reads _reach_pairs(), which accepts both.

Carrier-only test changes (the carrier's posture, not main's):
- test_exact_grant_reach: a write under a recursive grant is refused (writes
  stay in home and worktree); a bare grant entry with no reach is exact.
- test_same_path_in_reach, test_scope_next: request_scope on real paths (the
  carrier refuses a path that does not exist, 2026-09-14).

Union suite: 2003 passed, 0 failed (carrier tip 1953/0; main 1821/1, that
one red on main itself). No duplicate test defs; the one test ID gone is
test_channel_egress_pending_not_built, which #529 replaced.

Seat: legion-claude
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