Compare commits

...
Author SHA1 Message Date
jcwalker3 dc1d0e045f Merge branch 'master' into fix/issue-790-slice-a-heartbeat-policy 2026-07-23 01:13:11 -05:00
sysadmin 4f3a464a90 Merge pull request 'fix: authoritative cross-role generic queue allocation (Closes #840)' (#841) from fix/issue-840-cross-role-queue-allocation into master 2026-07-23 00:37:55 -05:00
jcwalker3 badc4e636b Merge branch 'master' into fix/issue-790-slice-a-heartbeat-policy 2026-07-23 00:06:18 -05:00
sysadmin 9468dd624d merge master into fix/issue-840-cross-role-queue-allocation 2026-07-23 00:06:09 -04:00
sysadminandClaude Opus 4.8 648d9464ba fix: authoritative cross-role generic queue allocation (Closes #840)
Add controller-owned cross_role allocation mode that inspects the full
queue and returns one selection with required role/profile/action and
lease evidence. Document process_work_queue routing, normalize
controller role metadata, and keep the dashboard explanatory only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-23 00:05:35 -04:00
jcwalker3andsysadmin caaae9b6ee feat(arch01): atomic platform install + authority kernel (Closes #822) (#839)
Co-authored-by: jcwalker3 <[email protected]>
2026-07-22 23:00:24 -05:00
sysadmin 689c60fc7c Merge pull request 'feat(webui): console authorization, RBAC, redaction, and audit model (Closes #633)' (#811) from feat/issue-633-console-authz-audit-model into master 2026-07-22 22:16:38 -05:00
jcwalker3 66a89a46bb Merge branch 'master' into feat/issue-633-console-authz-audit-model 2026-07-22 21:57:53 -05:00
sysadmin 53c2c92782 Merge pull request 'feat(webui): versioned project registry API (Closes #635)' (#819) from feat/issue-635-project-registry-api into master 2026-07-22 20:04:26 -05:00
sysadmin 14c9c4d702 Merge pull request 'fix(mcp): forward worktree_path into publication preflight (Closes #815)' (#817) from fix/issue-815-preflight-worktree-forwarding into master 2026-07-22 18:34:31 -05:00
jcwalker3andClaude Opus 4.8 95a5eb254f fix(mcp): forward worktree_path into publication preflight (Closes #815)
gitea_publish_unpublished_issue_branch accepted a required worktree_path
but resolved it only after verify_preflight_purity had already run, so the
#618 branches-only guard and every other workspace-resolution layer behind
preflight received None and fell back to the MCP process root. A daemon
rooted at the stable control checkout therefore refused a valid registered
issue worktree the caller had explicitly supplied, before the publication
assessor could use it — the sole verify_preflight_purity call site that
accepted a worktree argument and dropped it.

Resolve the workspace once, before preflight, and forward it. A blank or
absent path forwards None and keeps the ordinary #618 fail-closed fallback,
so guard strictness is unchanged for missing, empty, unregistered, foreign,
or control-checkout worktrees. Public tool contract, ownership, cleanliness,
hash, ancestry, and read-after-write protections from PR #814 are untouched.

Adds tests/test_issue_815_preflight_worktree_forwarding.py: a #735-style
capture proving the argument reaches verify_preflight_purity, a faithful
production reproduction (control-rooted daemon, no session lock, explicit
worktree) that clears #618 on the fixed source and is trapped at #618 on the
unpatched source, an end-to-end control-rooted publication in the real
topology (PROJECT_ROOT is the stable control checkout, the issue worktree is
a distinct registered path, production guards forced on), and negative
coverage keeping every #618 and assessor refusal intact. The prior #812
suite masked the defect by patching PROJECT_ROOT to equal the issue worktree.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 17:27:49 -05:00
sysadmin 910b6edbdc Merge pull request 'feat(mcp): publish an unpublished local commit on a registered issue worktree (Closes #812)' (#814) from feat/issue-812-publish-unpublished-commit into master 2026-07-22 16:40:54 -05:00
jcwalker3andClaude Opus 4.8 (1M context) &lt;[email protected]&gt; 99fda93bcc feat(mcp): publish an unpublished local commit on a registered issue worktree (Closes #812)
Entry point B of #812 is the state where an author's work has already advanced to a local commit: the worktree is registered, clean, on the issue branch, and carries the only copy of the implementation, but the branch has never been published. Two individually correct predicates close a cycle around it: exact-owner lease renewal refuses without an observable remote head, and every publication path is lock-derived under #618, so nothing can create that remote head without first holding the lock renewal would grant.

This adds the missing operation. gitea_publish_unpublished_issue_branch publishes an already-committed local head to its remote branch, so exact-owner renewal has the evidence its model requires. Publication is the whole of its authority: it renews, reclaims, rebinds, and clears nothing.

Why this is not a lock bypass: the operation can only publish a branch whose durable issue-lock record already names the caller as claimant. Ownership is read from the lock file, never asserted by the caller. Guard strictness is unchanged (AC15): a dirty tree, an untracked file the commit does not carry, an unregistered worktree, a non-issue or stable branch, a changed local HEAD, a remote head that is not an ancestor of the commit, a competing open PR for the same issue on another branch, and any declared-hash mismatch each fail closed. The refspec names the commit SHA explicitly and never forces.

Record separation (AC23): the durable issue-lock file and the control-plane workflow lease are distinct records. This reads the former as ownership evidence and writes neither.

Truthful process evidence (AC24): the recorded owner pid's liveness is never consulted or asserted. A regression pins the recorded pid to a live process, proves publication still succeeds, and proves expired-lock reclaim still refuses for that same pid.

Scope is AC20 only. AC21 cannot unblock on its own, so the two are separable and only the smaller one is implemented here.

Tests: 36 new cases against synthetic fixtures only, using a real git repository with a real local bare remote so publication and read-after-write verification are genuinely executed rather than mocked. AC17 is honoured and a regression asserts the protected worktree is never referenced.

Full suite: 11 failed, 4354 passed, 6 skipped, 533 subtests passed. The 11 failures are the documented pre-existing drift baseline at 9eb0f29, unchanged in count and identity.

Co-Authored-By: Claude Opus 4.8 (1M context) &lt;[email protected]&gt;
2026-07-22 16:18:00 -05:00
jcwalker3andClaude Opus 4.8 479e434f92 feat(webui): console authorization, RBAC, redaction, and audit model (Closes #633)
Phase 1 of the MCP Control Plane Web Console (#631) defines the model that
future gated writes must pass through, and enables none of them.

The read-only MVP (#426-#436) ships with no authentication; protection comes
from network placement alone (#435). That is adequate while every route is a
GET and inadequate the moment Phase 2 wires a write. This lands the authority
first, so no write can later be added without something to check it against.

webui/console_authz.py
  Identity sources (none / local-dev / access-proxy), a four-role matrix
  (viewer, operator, controller, admin), the privileged-action list, and a
  fail-closed authorize(). Every action maps to a task_key in
  task_capability_map, so the console cannot invent an authority the MCP layer
  does not already define. Roles are always server-side configuration, never a
  client assertion. Deny reasons are closed and enumerated; there is no
  implicit allow branch, and even an allow reports execution_enabled=false
  while ACTIVE_PHASE is 1.

webui/console_redaction.py
  One redaction pass for API payloads, rendered HTML, logs, and audit records.
  Reuses gitea_audit.redact as the shared authority rather than forking it,
  then adds console patterns for keychain references, credential assignments,
  PEM private-key blocks, and JWTs. Never raises: an unredactable value
  degrades to the placeholder rather than being emitted raw.

webui/console_audit.py
  Console-side audit records, which gitea_audit cannot supply: it records MCP
  mutations and carries no console actor, identity source, correlation id, or
  retention class, and an authorization denial is not a mutation at all. The
  two are additive and join on correlation.request_id. Records are redacted at
  build time, re-scanned at write time, and dropped rather than persisted if
  they still trip a detector. Retention is per-record; an unknown action is
  retained as privileged rather than standard.

webui/app.py
  Attaches an authorization block to the existing preview and attempt routes
  and records the decision. The terminal outcome is unchanged - gated_actions
  still fails closed for every action - so this cannot loosen anything. Adds
  GET /api/console/security-model publishing the three policies as JSON.

Probe authentication is deliberately declarative in this slice:
probe_auth_required() reports operator intent and no route consults it. The
documentation says so plainly and a regression test pins the not-enforced
status, so wiring it in Phase 2 is a deliberate change rather than a silent
one. An operator who sets the variable believing it protects a probe would be
worse off than one who knows it does not.

Tests: tests/test_webui_console_authz_audit.py - 75 passed, 93 subtests,
covering each acceptance criterion and each test the issue requires
(redaction units, default-deny for unauthenticated write stubs, audit record
creation for a simulated privileged preview).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-22 14:37:35 -05:00
sysadminandClaude Opus 4.8 243f52dc79 feat(lease): make the author task heartbeat load-bearing (#790 Slice A)
Slice A of Issue #790, per the controller reassessment in comment 13958. Does
not close the issue: terminal retirement (Slice B) and the read-side generation
check plus the #760 renewal re-scope (Slice C) are deliberately not implemented.

The defect. `issue_lock_store.assess_lock_freshness` parsed `last_heartbeat_at`
and then never consulted it. Liveness was decided by an absolute four-hour
`expires_at` and by PID liveness, and the recorded PID is the long-lived MCP
daemon rather than the authoring task, so an abandoned claim stayed live for the
full four hours. A tree-wide search found the field written in exactly one place
and advanced by nothing. Issue #787 / PR #789 hit this; Issue #760 / PR #791 hit
it again, blocking reconciliation for over five hours after its work had landed.

A1 — central policy. New `lease_policy` declares every duration for every task
class in one place: author initial/sliding TTL 10 minutes, heartbeat cadence 2,
stale warning 5, missed-heartbeat grace 10, absolute cap 8 hours, recovery grace
10, terminal race-drain 2. It ships first so the first heartbeat and TTL
behavior to run reads from it (AC-N7). The duplicated four-hour literal is gone
from both `issue_lock_store` and `gitea_mcp_server`. Reviewer, merger, and
conflict-fix classes are declared but not rewired — Slice C moves those call
sites — and a test asserts the declaration still equals the constants #747 and
`pr_work_lease` own, so the two cannot drift apart unnoticed.

A2 — load-bearing freshness, with two deliberate asymmetries. An alive PID never
establishes freshness anywhere (AC-N2); it is recorded as evidence and no branch
returns live because of it. A dead PID still marks a lease stale, and that band
still precedes every heartbeat evaluation, so #753 dead-session recovery keys on
exactly the classification it always did. New bands `stale_missed_heartbeat` and
`stale_absolute_cap` are classified in `branch_cleanup_guard` rather than
falling through to unknown-status, and still block unless the ownership record
proves `reclaim_allowed is True`. A heartbeat lease carrying no heartbeat is
contradictory and fails closed. `assess_expired_lock_reclaim` accepts a lapsed
heartbeat as reclaim grounds for heartbeat-lifecycle leases only: under this
lifecycle the heartbeat is the liveness proof, and also requiring a dead PID
would reinstate the original defect.

A3/A4 — task-session identity and the writer. `mint_task_session_id` produces an
ownership key containing no process identifier, since the daemon PID is reused
by every task it serves and identifies none of them. `heartbeat_session_lock`
writes inside the existing per-issue flock under the #772 generation
compare-and-swap, verifying exact issue, branch, realpath-normalized worktree,
claimant username, claimant profile, and recorded session identifier. It cannot
acquire, take over, or revive: a lease past its grace is refused and must use
the reclaim path, so a session that stopped proving liveness cannot restore
ownership retroactively. New `gitea_heartbeat_issue_lock` gates on the same
authority as `lock_issue`, being strictly narrower.

A5 — legacy compatibility (AC-N8). The explicit `lifecycle_version` marker, never
a timestamp comparison, discriminates legacy from heartbeat leases: a legacy lock
has `last_heartbeat_at == created_at` forever precisely because nothing advanced
it, and a freshly minted heartbeat lease has them equal too, so the equality
carries no information in either direction. Legacy locks keep their recorded
absolute expiry and are never evaluated against the short grace, so deployment
cannot make an existing claim instantly reclaimable. They leave that state only
by terminal retirement (Slice B) or by `rebind_legacy_lock`, which re-verifies
the exact owner and mints a genuine identifier and first heartbeat while
preserving the original claim under `legacy_origin`. Rebinding a lapsed legacy
lease is refused; that belongs to #760 renewal or #601 reclaim.

A6 — native coverage. Review #499 proved assessor-level tests miss discard
points, so `tests/test_issue_790_heartbeat_mcp_path.py` drives the real tools
against a real git repository and a real durable lock: lock creation and
read-back, policy window, freshness, survival of `verify_lock_for_mutation`,
invariance of the duplicate-work and linked-open-PR gates, CAS rejection,
foreign-session and foreign-claimant refusal, alive-PID-only refusal, missed
heartbeat, legacy protection on deployment, and legacy rebinding.

Tests. New suites 55 passed. Lock and lease regression set (issue_lock_store,
lease_lifecycle, #753, #755, #760 x2, #768, #772, lock registration, worktree,
adoption, duplicate gate, branch cleanup guard, capability invariants, claim
heartbeat, worktrees) 383 passed with 98 subtests. Full suite 4295 passed, 11
failed, 6 skipped, 499 subtests passed, against a clean master baseline worktree
at 620ed6e9 that reports 11 failed and 4240 passed — the same eleven node IDs.
The 55-test delta is exactly the new suites; no new failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_011u6GKSJwwrrYjguPjs1aK5
2026-07-22 04:19:48 -04:00
27 changed files with 8624 additions and 89 deletions
+294 -40
View File
@@ -78,6 +78,33 @@ VALID_ROLES = frozenset(
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER} {ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
) )
# Allocation modes (#840).
# role_scoped: only candidates whose expected role matches the caller role.
# cross_role: controller-owned generic queue selection — inspect full queue,
# rank/eligibility canonically, return one selection naming the required
# downstream role/profile. Controller routes; it does not perform mutations.
ALLOCATION_MODE_ROLE_SCOPED = "role_scoped"
ALLOCATION_MODE_CROSS_ROLE = "cross_role"
VALID_ALLOCATION_MODES = frozenset(
{ALLOCATION_MODE_ROLE_SCOPED, ALLOCATION_MODE_CROSS_ROLE}
)
# Default execution-profile / MCP-namespace names for each role.
DEFAULT_ROLE_PROFILES: dict[str, str] = {
ROLE_AUTHOR: "prgs-author",
ROLE_REVIEWER: "prgs-reviewer",
ROLE_MERGER: "prgs-merger",
ROLE_RECONCILER: "prgs-reconciler",
ROLE_CONTROLLER: "prgs-controller",
}
DEFAULT_ROLE_NAMESPACES: dict[str, str] = {
ROLE_AUTHOR: "gitea-author",
ROLE_REVIEWER: "gitea-reviewer",
ROLE_MERGER: "gitea-merger",
ROLE_RECONCILER: "gitea-reconciler",
ROLE_CONTROLLER: "gitea-controller",
}
# Default action matrices by role (mutation gate will re-check). # Default action matrices by role (mutation gate will re-check).
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
ROLE_AUTHOR: ( ROLE_AUTHOR: (
@@ -259,6 +286,126 @@ def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
) )
def resolve_allocation_mode(
role: str,
allocation_mode: str | None = None,
) -> str:
"""Resolve allocation mode; controller defaults to cross_role (#840)."""
raw = (allocation_mode or "").strip().lower()
if raw:
if raw not in VALID_ALLOCATION_MODES:
raise ControlPlaneError(
f"unknown allocation_mode {allocation_mode!r}; expected one of "
f"{sorted(VALID_ALLOCATION_MODES)}"
)
return raw
if role == ROLE_CONTROLLER:
return ALLOCATION_MODE_CROSS_ROLE
return ALLOCATION_MODE_ROLE_SCOPED
def required_profile_for_role(
role: str,
*,
profile_name: str | None = None,
) -> str:
"""Map a required role to the canonical execution profile name."""
role_norm = (role or "").strip().lower()
# Preserve remote/env prefix from the active profile when present
# (e.g. dadeschools-author → dadeschools-reviewer).
active = (profile_name or "").strip()
if active:
lower = active.lower()
for token in ("author", "reviewer", "merger", "reconciler", "controller"):
if lower.endswith(f"-{token}") or lower == token:
prefix = active[: -len(token)].rstrip("-")
if prefix:
return f"{prefix}-{role_norm}"
return role_norm
return DEFAULT_ROLE_PROFILES.get(role_norm, f"prgs-{role_norm}")
def required_namespace_for_role(
role: str,
*,
profile_name: str | None = None,
) -> str:
"""Map a required role to the canonical MCP namespace name."""
role_norm = (role or "").strip().lower()
profile = required_profile_for_role(role_norm, profile_name=profile_name)
# Namespace is typically gitea-<role>; keep stable mapping when profile is
# non-prgs (still gitea-<role> for isolation).
return DEFAULT_ROLE_NAMESPACES.get(role_norm, f"gitea-{role_norm}")
def selected_action_for_candidate(c: WorkCandidate, required_role: str) -> str:
"""Canonical next action for the selected work under *required_role*."""
role = (required_role or "").strip().lower()
if role == ROLE_AUTHOR:
if c.kind == "pr" and c.request_changes_current_head:
return "address_pr_change_requests"
if c.kind == "pr":
return "update_pr"
return "implement"
if role == ROLE_REVIEWER:
if c.approval_stale:
return "re_review"
return "review"
if role == ROLE_MERGER:
return "merge"
if role == ROLE_RECONCILER:
if c.approval_contaminated:
return "reconcile_contaminated_approval"
return "reconcile"
if role == ROLE_CONTROLLER:
return "diagnose"
return "process"
def build_selection_dict(
selected: WorkCandidate,
*,
active_role: str,
required_role: str,
profile_name: str | None = None,
allocation_mode: str,
) -> dict[str, Any]:
"""Authoritative single selection payload for allocator results (#840)."""
action = selected_action_for_candidate(selected, required_role)
req_profile = required_profile_for_role(
required_role, profile_name=profile_name
)
req_ns = required_namespace_for_role(
required_role, profile_name=profile_name
)
return {
"kind": selected.kind,
"number": selected.number,
"title": selected.title,
"labels": list(selected.labels),
"head_sha": selected.head_sha,
"priority": selected.priority,
"expected_role_next": required_role,
"required_role": required_role,
"selected_action": action,
"action": action,
"required_profile": req_profile,
"required_namespace": req_ns,
"pinned": {
"kind": selected.kind,
"number": selected.number,
"head_sha": selected.head_sha,
"issue_number": selected.number if selected.kind == "issue" else None,
"pr_number": selected.number if selected.kind == "pr" else None,
},
"reason_selected": (
f"highest-priority eligible candidate under allocation_mode="
f"'{allocation_mode}' (active_role={active_role}, "
f"required_role={required_role}, action={action})"
),
}
def expected_role_for_candidate(c: WorkCandidate) -> str: def expected_role_for_candidate(c: WorkCandidate) -> str:
"""ADR §5.3 routing: which role should take this work next.""" """ADR §5.3 routing: which role should take this work next."""
if c.kind == "pr": if c.kind == "pr":
@@ -289,6 +436,7 @@ def classify_skip(
role: str, role: str,
terminal_pr: int | None, terminal_pr: int | None,
claim_ownership: str | None = None, claim_ownership: str | None = None,
allocation_mode: str | None = None,
) -> str | None: ) -> str | None:
"""Return skip reason, or None if candidate is selectable for *role*. """Return skip reason, or None if candidate is selectable for *role*.
@@ -297,7 +445,12 @@ def classify_skip(
and unknown claims are excluded so one session's in-progress task can never and unknown claims are excluded so one session's in-progress task can never
blockade the queue for a different controller; ``own`` stays selectable so blockade the queue for a different controller; ``own`` stays selectable so
a controller can resume its own work. a controller can resume its own work.
*allocation_mode* (#840): ``cross_role`` (controller default) ranks the full
queue and selects the highest-priority eligible item for any downstream
role. ``role_scoped`` retains prior role-match filtering.
""" """
mode = resolve_allocation_mode(role, allocation_mode)
if c.state in ("merged", "closed"): if c.state in ("merged", "closed"):
return f"{c.kind}#{c.number} is {c.state}; never assign" return f"{c.kind}#{c.number} is {c.state}; never assign"
if c.blocked or "status:blocked" in c.labels: if c.blocked or "status:blocked" in c.labels:
@@ -322,34 +475,58 @@ def classify_skip(
if c.kind == "pr" and not (c.head_sha or "").strip(): if c.kind == "pr" and not (c.head_sha or "").strip():
return f"pr#{c.number} missing head_sha pin" return f"pr#{c.number} missing head_sha pin"
expected = expected_role_for_candidate(c)
# Terminal path first: when an active terminal PR exists, only that PR # Terminal path first: when an active terminal PR exists, only that PR
# (or controller diagnosis) is assignable for review-path roles. # is assignable for review-path roles (or for work whose expected role is
# review/merge under cross_role selection).
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr: if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
if role in (ROLE_REVIEWER, ROLE_MERGER): terminal_roles = (ROLE_REVIEWER, ROLE_MERGER)
if mode == ALLOCATION_MODE_CROSS_ROLE:
if expected in terminal_roles:
return (
f"pr#{c.number} skipped: active terminal-review lock on "
f"PR #{terminal_pr} must be resolved first"
)
elif role in terminal_roles:
return ( return (
f"pr#{c.number} skipped: active terminal-review lock on " f"pr#{c.number} skipped: active terminal-review lock on "
f"PR #{terminal_pr} must be resolved first" f"PR #{terminal_pr} must be resolved first"
) )
expected = expected_role_for_candidate(c) if mode == ALLOCATION_MODE_CROSS_ROLE:
if role == ROLE_CONTROLLER: # Cross-role controller selection: eligibility only — no active-role
# Controller may inspect anything but only assigns diagnosis targets # match filter. The selection payload names required_role.
# when contaminated / blocked. pass
elif role == ROLE_CONTROLLER:
# Legacy diagnosis-only controller path (role_scoped): only reconciler-
# needed targets. Prefer cross_role for generic queue allocation.
if expected == ROLE_RECONCILER or c.blocked: if expected == ROLE_RECONCILER or c.blocked:
return None return None
return f"{c.kind}#{c.number} does not require controller (expected {expected})" return (
f"{c.kind}#{c.number} does not require controller "
if role != expected: f"(expected {expected})"
)
elif role != expected:
return ( return (
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'" f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
) )
# Ready-gate for issues: prefer status:ready when labels present. # Ready-gate for issues: prefer status:ready when labels present.
# Applies for author-bound work in both modes (cross_role only gates
# author-expected issues so reconciler/reviewer PRs stay selectable).
if c.kind == "issue" and c.labels: if c.kind == "issue" and c.labels:
if "status:ready" not in c.labels and "status:in-progress" not in c.labels: gate_role = expected if mode == ALLOCATION_MODE_CROSS_ROLE else role
# Allow unlabeled open issues; only skip explicit non-ready states. if gate_role in (ROLE_AUTHOR, ROLE_CONTROLLER):
if any(l.startswith("status:") for l in c.labels): if (
return f"issue#{c.number} not status:ready ({','.join(c.labels)})" "status:ready" not in c.labels
and "status:in-progress" not in c.labels
):
if any(l.startswith("status:") for l in c.labels):
return (
f"issue#{c.number} not status:ready "
f"({','.join(c.labels)})"
)
return None return None
@@ -501,12 +678,19 @@ def allocate_next_work(
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None, claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
exclude_issue_numbers: Sequence[int] | None = None, exclude_issue_numbers: Sequence[int] | None = None,
expected_candidate_set_fingerprint: str | None = None, expected_candidate_set_fingerprint: str | None = None,
allocation_mode: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Select and optionally reserve the next work unit via control-plane DB. """Select and optionally reserve the next work unit via control-plane DB.
*apply=False* (default): dry-run selection only — no lease/assignment. *apply=False* (default): dry-run selection only — no lease/assignment.
*apply=True*: atomic ``assign_and_lease`` for the selected candidate. *apply=True*: atomic ``assign_and_lease`` for the selected candidate.
*allocation_mode* (#840): ``cross_role`` (default for controller) inspects
the complete queue and returns one authoritative selection naming the
required downstream role/profile/action. ``role_scoped`` keeps prior
per-role filtering. Controller routes only — never grants author/reviewer/
merger/reconciler mutation rights to the controller session.
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted / *exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
empty preserves prior behavior. empty preserves prior behavior.
@@ -541,6 +725,19 @@ def allocate_next_work(
"substrate": "control_plane_db", "substrate": "control_plane_db",
} }
try:
mode = resolve_allocation_mode(role_norm, allocation_mode)
except ControlPlaneError as exc:
return {
"success": False,
"outcome": OUTCOME_ROLE_INELIGIBLE,
"reasons": [str(exc)],
"skipped": [],
"assignment": None,
"substrate": "control_plane_db",
"allocation_mode": (allocation_mode or "").strip() or None,
}
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}" session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
try: try:
db.upsert_session( db.upsert_session(
@@ -748,6 +945,7 @@ def allocate_next_work(
role=role_norm, role=role_norm,
terminal_pr=terminal_pr, terminal_pr=terminal_pr,
claim_ownership=ownership, claim_ownership=ownership,
allocation_mode=mode,
) )
if reason: if reason:
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
@@ -828,6 +1026,10 @@ def allocate_next_work(
"outcome": outcome, "outcome": outcome,
"apply": bool(apply), "apply": bool(apply),
"role": role_norm, "role": role_norm,
"allocation_mode": mode,
"routing_role": role_norm,
"required_role": None,
"selected_action": None,
"profile_name": profile_name, "profile_name": profile_name,
"username": username, "username": username,
"session_id": session_id, "session_id": session_id,
@@ -840,6 +1042,12 @@ def allocate_next_work(
"skipped": [s.as_dict() for s in skipped], "skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr, "terminal_pr": terminal_pr,
"assignment": None, "assignment": None,
"allocation_evidence": {
"mode": "empty",
"allocation_mode": mode,
"lease_created": False,
"selection_policy": SELECTION_POLICY,
},
"substrate": "control_plane_db", "substrate": "control_plane_db",
"file_lock_only": False, "file_lock_only": False,
"comment_lease_only": False, "comment_lease_only": False,
@@ -852,25 +1060,37 @@ def allocate_next_work(
"owner_session_id": owner_session_id, "owner_session_id": owner_session_id,
"downstream_note": ( "downstream_note": (
"#612 incident bridge remains downstream of #600; " "#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents" "allocator never assigns raw monitoring incidents; "
"controller routes only under cross_role (#840)"
), ),
} }
expected_role = expected_role_for_candidate(selected) expected_role = expected_role_for_candidate(selected)
allowed, forbidden = role_actions(role_norm) # Cross-role: lease/action matrix follows the required downstream role so
selection = { # evidence names the worker that must act. Controller session still owns
"kind": selected.kind, # the routing decision; mutation isolation is enforced by role gates on
"number": selected.number, # mutation tools (controller profile lacks author/review/merge ops).
"title": selected.title, lease_role = (
"labels": list(selected.labels), expected_role if mode == ALLOCATION_MODE_CROSS_ROLE else role_norm
"head_sha": selected.head_sha, )
"priority": selected.priority, allowed, forbidden = role_actions(lease_role)
"expected_role_next": expected_role, # Controller must never receive mutation-class rights via cross-role apply.
"reason_selected": ( if role_norm == ROLE_CONTROLLER:
f"highest-priority candidate for role '{role_norm}' " ctrl_allowed, ctrl_forbidden = role_actions(ROLE_CONTROLLER)
f"(expected_role={expected_role})" # Keep controller session capability evidence separate from lease_role.
), controller_allowed_actions = ctrl_allowed
} controller_forbidden_actions = ctrl_forbidden
else:
controller_allowed_actions = allowed
controller_forbidden_actions = forbidden
selection = build_selection_dict(
selected,
active_role=role_norm,
required_role=expected_role,
profile_name=profile_name,
allocation_mode=mode,
)
if not apply: if not apply:
return { return {
@@ -878,6 +1098,12 @@ def allocate_next_work(
"outcome": OUTCOME_PREVIEW, "outcome": OUTCOME_PREVIEW,
"apply": False, "apply": False,
"role": role_norm, "role": role_norm,
"allocation_mode": mode,
"routing_role": role_norm,
"required_role": expected_role,
"selected_action": selection["selected_action"],
"required_profile": selection["required_profile"],
"required_namespace": selection["required_namespace"],
"profile_name": profile_name, "profile_name": profile_name,
"username": username, "username": username,
"session_id": session_id, "session_id": session_id,
@@ -893,6 +1119,12 @@ def allocate_next_work(
"skipped": [s.as_dict() for s in skipped], "skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr, "terminal_pr": terminal_pr,
"assignment": None, "assignment": None,
"allocation_evidence": {
"mode": "preview",
"allocation_mode": mode,
"lease_created": False,
"selection_policy": SELECTION_POLICY,
},
"substrate": "control_plane_db", "substrate": "control_plane_db",
"file_lock_only": False, "file_lock_only": False,
"comment_lease_only": False, "comment_lease_only": False,
@@ -902,9 +1134,12 @@ def allocate_next_work(
"controller_excluded": list(controller_excluded), "controller_excluded": list(controller_excluded),
"exclude_issue_numbers": list(exclude_nums), "exclude_issue_numbers": list(exclude_nums),
"candidate_set_fingerprint": cas_fp, "candidate_set_fingerprint": cas_fp,
"controller_allowed_actions": list(controller_allowed_actions),
"controller_forbidden_actions": list(controller_forbidden_actions),
"downstream_note": ( "downstream_note": (
"#612 incident bridge remains downstream of #600; " "#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents" "allocator never assigns raw monitoring incidents; "
"controller routes only under cross_role (#840)"
), ),
} }
@@ -913,7 +1148,7 @@ def allocate_next_work(
try: try:
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"session_id": session_id, "session_id": session_id,
"role": role_norm, "role": lease_role,
"remote": remote, "remote": remote,
"org": org, "org": org,
"repo": repo, "repo": repo,
@@ -992,11 +1227,27 @@ def allocate_next_work(
} }
# assigned # assigned
lease_proof = {
"assignment_id": result.assignment_id,
"lease_id": result.lease_id,
"expires_at": result.expires_at,
"expected_head_sha": result.expected_head_sha,
"allowed_actions": list(result.allowed_actions),
"forbidden_actions": list(result.forbidden_actions),
"lease_role": lease_role,
"source": "control_plane_db.assign_and_lease",
}
return { return {
"success": True, "success": True,
"outcome": OUTCOME_ASSIGNED, "outcome": OUTCOME_ASSIGNED,
"apply": True, "apply": True,
"role": role_norm, "role": role_norm,
"allocation_mode": mode,
"routing_role": role_norm,
"required_role": expected_role,
"selected_action": selection["selected_action"],
"required_profile": selection["required_profile"],
"required_namespace": selection["required_namespace"],
"profile_name": profile_name, "profile_name": profile_name,
"username": username, "username": username,
"session_id": session_id, "session_id": session_id,
@@ -1012,16 +1263,16 @@ def allocate_next_work(
"skipped": [s.as_dict() for s in skipped], "skipped": [s.as_dict() for s in skipped],
"terminal_pr": terminal_pr, "terminal_pr": terminal_pr,
"assignment": result.as_dict(), "assignment": result.as_dict(),
"lease_proof": { "lease_proof": lease_proof,
"assignment_id": result.assignment_id, "allocation_evidence": {
"lease_id": result.lease_id, "mode": "assigned",
"expires_at": result.expires_at, "allocation_mode": mode,
"expected_head_sha": result.expected_head_sha, "lease_created": True,
"allowed_actions": list(result.allowed_actions), "lease_role": lease_role,
"forbidden_actions": list(result.forbidden_actions), "lease_proof": lease_proof,
"source": "control_plane_db.assign_and_lease", "selection_policy": SELECTION_POLICY,
}, },
"next_valid_command": _next_command(role_norm, selected), "next_valid_command": _next_command(lease_role, selected),
"substrate": "control_plane_db", "substrate": "control_plane_db",
"file_lock_only": False, "file_lock_only": False,
"comment_lease_only": False, "comment_lease_only": False,
@@ -1031,9 +1282,12 @@ def allocate_next_work(
"controller_excluded": list(controller_excluded), "controller_excluded": list(controller_excluded),
"exclude_issue_numbers": list(exclude_nums), "exclude_issue_numbers": list(exclude_nums),
"candidate_set_fingerprint": cas_fp, "candidate_set_fingerprint": cas_fp,
"controller_allowed_actions": list(controller_allowed_actions),
"controller_forbidden_actions": list(controller_forbidden_actions),
"downstream_note": ( "downstream_note": (
"#612 incident bridge remains downstream of #600; " "#612 incident bridge remains downstream of #600; "
"allocator never assigns raw monitoring incidents" "allocator never assigns raw monitoring incidents; "
"controller routes only under cross_role (#840)"
), ),
} }
+1
View File
@@ -87,6 +87,7 @@ MUTATION_TASKS = frozenset({
"edit_pr", "edit_pr",
"commit_files", "commit_files",
"gitea_commit_files", "gitea_commit_files",
"publish_unpublished_branch",
"delete_branch", "delete_branch",
"cleanup_merged_pr_branch", "cleanup_merged_pr_branch",
"cleanup_stale_claims", "cleanup_stale_claims",
+892
View File
@@ -0,0 +1,892 @@
"""ARCH-01 Foundation Slice A — atomic platform installation + authority kernel (#822).
Parents: #820, #821. **First implementation leaf of the ARCH-01 program.**
This module implements the smallest executable ARCH-01 foundation:
* a connection-bound authenticated actor context (``cp_actor_*`` /
``cp_operation_mode`` / ``cp_context_epoch`` SQLite scalar functions that SQL
may *read* but can never *set* — ``[TRUSTED-SERVICE]`` authenticity);
* an immutable authority-dominance lattice with an exact seeded tuple set
(``[SCHEMA]``);
* the principal-equivalence root (a class exists *before* its first principal;
``principals.current_class_id`` is ``NOT NULL``; ``[SCHEMA]``);
* a single-transaction platform installation that seeds the initial
``platform.bootstrap`` grant and an immutable ``installed`` marker, validated
by a fail-closed ``install_state`` ``BEFORE INSERT`` trigger (``[SCHEMA]``).
Everything else in the ARCH-01/02/04 program (evidence stores, repository
bindings, workspaces, PostgreSQL parity, full grant succession, full principal
merge) is out of scope here and tracked in its own issue — see #822 §5/§17.
**Readiness / production posture.** This subsystem is *disabled by default*.
Nothing in the running MCP server imports or enables it. It becomes a security
boundary only once its readiness checks (the ACs in #822) pass in the target
environment. Instantiating :class:`PlatformKernel` creates an isolated SQLite
database and never touches the operational control-plane store.
Enforcement classification (per #820 vocabulary):
* ``[TRUSTED-SERVICE]`` — actor-context authenticity: the scalar functions are
registered by the trusted Python process; SQL cannot define or redefine them.
* ``[SCHEMA]`` — fail-closed aborts, the dominance/immutability/NOT-NULL-class/
last-active-grant invariants, enforced by CHECK/FK/trigger.
* ``[RUNTIME-ADAPTER]`` — *none* in this slice.
SQLite-first. ``BEGIN IMMEDIATE`` serializes concurrent installs and concurrent
grant/revoke on the singleton invariant row. PostgreSQL parity is a distinct
issue (#827); this module does **not** claim it.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterator, Optional
# --------------------------------------------------------------------------- #
# Closed enumerations (#822 §4).
# --------------------------------------------------------------------------- #
ACTOR_KINDS = ("operator", "supervisor", "service", "installer")
OPERATION_MODES = ("normal", "install", "merge", "internal_service")
# Exact seeded authority-dominance tuple set (#822 §4). This set is normative:
# the install-state trigger rejects any missing, additional, or malformed tuple.
DOMINANCE_TUPLES = (
("platform.bootstrap", "platform.bootstrap"),
("platform.bootstrap", "project.admin"),
("platform.bootstrap", "supervisor.root.establish"),
("supervisor.root", "supervisor.register"),
("supervisor.root", "supervisor.verify"),
("supervisor.root", "supervisor.recover"),
)
# The distinguished operator-key issuer seeded during install.
DISTINGUISHED_ISSUER_KIND = "operator-key"
DISTINGUISHED_ISSUER_ID = "platform.bootstrap.operator-key"
# Structured result codes (#822 §10).
INSTALLED = "INSTALLED"
ALREADY_INSTALLED = "ALREADY_INSTALLED"
INVALID_ACTOR_CONTEXT = "INVALID_ACTOR_CONTEXT"
INVALID_BOOTSTRAP_STATE = "INVALID_BOOTSTRAP_STATE"
DOMINANCE_SET_MISMATCH = "DOMINANCE_SET_MISMATCH"
AUTHORIZATION_DENIED = "AUTHORIZATION_DENIED"
CONCURRENT_INSTALLATION_LOST = "CONCURRENT_INSTALLATION_LOST"
# Required audit events (#822 §14).
EVT_PLATFORM_INSTALLED = "platform_installed"
EVT_GRANT_CREATED = "platform_grant_created"
EVT_GRANT_REVOKED = "platform_grant_revoked"
EVT_PRINCIPAL_REGISTERED = "principal_registered"
SCHEMA_VERSION = 1
DB_PATH_ENV = "ARCH01_PLATFORM_DB"
class PlatformKernelError(RuntimeError):
"""Base class for structured, code-bearing kernel failures."""
def __init__(self, code: str, message: str = "") -> None:
super().__init__(message or code)
self.code = code
class ActorContextError(PlatformKernelError):
"""Raised when a mutation is attempted without a valid actor context."""
# --------------------------------------------------------------------------- #
# Schema (#822 §6). Tables + fail-closed triggers.
#
# Every *mutating* trigger opens with the actor protocol: read the context
# epoch, read the actor fields, and abort unless the context is present,
# non-null, mode/kind well-formed, and epoch-consistent with the active
# transaction. The scalar functions ``cp_*`` are registered from Python only;
# SQL has no statement that can set them, which is the trusted-service boundary.
# --------------------------------------------------------------------------- #
_ACTOR_KINDS_SQL = ", ".join("'%s'" % k for k in ACTOR_KINDS)
_OP_MODES_SQL = ", ".join("'%s'" % m for m in OPERATION_MODES)
# Actor-protocol predicate: TRUE when the context is INVALID and the trigger
# must abort. ``cp_actor_context_valid()`` folds "present + non-expired +
# live-epoch == bound-epoch" (the read/re-read epoch equality of #822 §4) into
# one trusted-service answer; the remaining reads assert field well-formedness.
_INVALID_ACTOR = (
"cp_actor_context_valid() IS NOT 1 "
"OR cp_context_epoch() IS NULL "
"OR cp_actor_principal() IS NULL "
"OR cp_actor_kind() NOT IN (%s) "
"OR cp_operation_mode() NOT IN (%s)" % (_ACTOR_KINDS_SQL, _OP_MODES_SQL)
)
_ACTOR_GUARD = (
"SELECT CASE WHEN (%s) "
"THEN RAISE(ABORT, 'INVALID_ACTOR_CONTEXT') END;" % _INVALID_ACTOR
)
# require_installed: abort a privileged mutation when there is no install
# marker and we are not currently installing (#822 §4).
_REQUIRE_INSTALLED = (
"SELECT CASE WHEN ((SELECT COUNT(*) FROM install_state) = 0 "
"AND cp_operation_mode() <> 'install') "
"THEN RAISE(ABORT, 'NOT_INSTALLED') END;"
)
_SCHEMA_SQL = f"""
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS arch01_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Equivalence classes are created BEFORE their first principal (#822 §4).
CREATE TABLE IF NOT EXISTS principal_equivalence_classes (
class_id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authoritative_issuers (
issuer_id INTEGER PRIMARY KEY AUTOINCREMENT,
issuer_kind TEXT NOT NULL,
issuer_ref TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (issuer_kind, issuer_ref)
);
-- current_class_id is NOT NULL: a principal cannot exist without a class
-- (#822 AC6). issuer_id is nullable ONLY for the installer during install
-- (#822 AC7), enforced by trg_principals_null_issuer below.
CREATE TABLE IF NOT EXISTS principals (
principal_id TEXT PRIMARY KEY,
actor_kind TEXT NOT NULL CHECK (actor_kind IN ({_ACTOR_KINDS_SQL})),
current_class_id INTEGER NOT NULL REFERENCES principal_equivalence_classes(class_id),
issuer_id INTEGER REFERENCES authoritative_issuers(issuer_id),
registered_by TEXT REFERENCES principals(principal_id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS authority_dominance (
dominant TEXT NOT NULL,
subordinate TEXT NOT NULL,
PRIMARY KEY (dominant, subordinate)
);
CREATE TABLE IF NOT EXISTS platform_bootstrap_seed (
seed_id INTEGER PRIMARY KEY CHECK (seed_id = 1),
installer_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS platform_bootstrap_grants (
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
grantee_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
granted_by TEXT REFERENCES principals(principal_id),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL,
revoked_at TEXT
);
-- Singleton row; active_count floored at 1 by CHECK so the last active grant
-- can never be revoked (#822 AC11).
CREATE TABLE IF NOT EXISTS platform_active_invariant (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_count INTEGER NOT NULL CHECK (active_count >= 1)
);
-- The immutable install marker; inserted LAST in the install transaction.
CREATE TABLE IF NOT EXISTS install_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
marker TEXT NOT NULL CHECK (marker = 'installed'),
installed_at TEXT NOT NULL
);
-- Append-only (#822 AC14).
CREATE TABLE IF NOT EXISTS audit_records (
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
event TEXT NOT NULL,
principal_id TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
-- ------------------------------------------------------------------------- --
-- Actor protocol on every mutating trigger (#822 §4, [SCHEMA] fail-closed).
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_classes_actor
BEFORE INSERT ON principal_equivalence_classes
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_issuers_actor
BEFORE INSERT ON authoritative_issuers
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_principals_actor
BEFORE INSERT ON principals
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_dominance_actor
BEFORE INSERT ON authority_dominance
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_seed_actor
BEFORE INSERT ON platform_bootstrap_seed
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_insert
BEFORE INSERT ON platform_bootstrap_grants
BEGIN
{_ACTOR_GUARD}
{_REQUIRE_INSTALLED}
END;
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_update
BEFORE UPDATE ON platform_bootstrap_grants
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_insert
BEFORE INSERT ON platform_active_invariant
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_update
BEFORE UPDATE ON platform_active_invariant
BEGIN
{_ACTOR_GUARD}
END;
CREATE TRIGGER IF NOT EXISTS trg_audit_actor
BEFORE INSERT ON audit_records
BEGIN
{_ACTOR_GUARD}
END;
-- ------------------------------------------------------------------------- --
-- NOT-NULL-issuer exception for the installer only (#822 AC7).
-- A NULL issuer_id is accepted solely for an installer principal during
-- install mode, before the marker exists; any other NULL-issuer principal is
-- rejected. install-time issuer linkage (installer -> distinguished issuer)
-- is applied by a later UPDATE, permitted while no marker exists.
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_principals_null_issuer
BEFORE INSERT ON principals
WHEN NEW.issuer_id IS NULL
BEGIN
SELECT CASE WHEN NOT (
NEW.actor_kind = 'installer'
AND cp_operation_mode() = 'install'
AND (SELECT COUNT(*) FROM install_state) = 0
AND (SELECT COUNT(*) FROM principals WHERE issuer_id IS NULL) = 0
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
END;
-- ------------------------------------------------------------------------- --
-- Post-install immutability of the authority root (#822 §4, AC9).
-- Registration fields freeze only AFTER the marker exists, so the install
-- transaction's own installer issuer-linkage UPDATE is permitted.
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_update
BEFORE UPDATE ON principals
WHEN (SELECT COUNT(*) FROM install_state) > 0
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
END;
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_delete
BEFORE DELETE ON principals
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
END;
-- Distinguished issuer identity is immutable once written.
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_update
BEFORE UPDATE ON authoritative_issuers
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
END;
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_delete
BEFORE DELETE ON authoritative_issuers
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
END;
-- The dominance lattice is immutable once seeded.
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_update
BEFORE UPDATE ON authority_dominance
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
END;
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_delete
BEFORE DELETE ON authority_dominance
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
END;
-- The bootstrap seed is immutable once written.
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_update
BEFORE UPDATE ON platform_bootstrap_seed
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
END;
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_delete
BEFORE DELETE ON platform_bootstrap_seed
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
END;
-- The install marker is immutable once written.
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_update
BEFORE UPDATE ON install_state
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
END;
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_delete
BEFORE DELETE ON install_state
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
END;
-- Grants: identity is immutable; the ONLY permitted mutation is a single
-- active 1 -> 0 revocation (#822 §4 initial-grant identity immutability +
-- grant/revoke). Reactivation and identity edits are rejected.
CREATE TRIGGER IF NOT EXISTS trg_grants_identity_frozen
BEFORE UPDATE ON platform_bootstrap_grants
WHEN NOT (
NEW.grant_id = OLD.grant_id
AND NEW.grantee_principal_id = OLD.grantee_principal_id
AND NEW.granted_by IS OLD.granted_by
AND NEW.created_at = OLD.created_at
AND OLD.active = 1
AND NEW.active = 0
)
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
END;
CREATE TRIGGER IF NOT EXISTS trg_grants_no_delete
BEFORE DELETE ON platform_bootstrap_grants
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
END;
-- audit_records is append-only.
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_update
BEFORE UPDATE ON audit_records
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
END;
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_delete
BEFORE DELETE ON audit_records
BEGIN
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
END;
-- ------------------------------------------------------------------------- --
-- install_state BEFORE INSERT: validate the whole bootstrap atomically
-- (#822 §4, AC4). Each dominance tuple is checked individually; a missing,
-- additional, or malformed tuple -> DOMINANCE_SET_MISMATCH. The seed<->installer
-- link, the single active NULL-grantor installer grant, the installer's
-- non-NULL issuer, the active invariant, and "no extra principal created under
-- the NULL-issuer exception" -> INVALID_BOOTSTRAP_STATE.
-- ------------------------------------------------------------------------- --
CREATE TRIGGER IF NOT EXISTS trg_install_state_validate
BEFORE INSERT ON install_state
BEGIN
SELECT CASE WHEN NOT (
(SELECT COUNT(*) FROM authority_dominance) = {len(DOMINANCE_TUPLES)}
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='platform.bootstrap')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='project.admin')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='supervisor.root.establish')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.register')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.verify')
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.recover')
) THEN RAISE(ABORT, 'DOMINANCE_SET_MISMATCH') END;
SELECT CASE WHEN NOT (
(SELECT COUNT(*) FROM platform_bootstrap_seed) = 1
AND (SELECT COUNT(*) FROM principals) = 1
AND (SELECT actor_kind FROM principals
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
) = 'installer'
AND (SELECT issuer_id FROM principals
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
) IS NOT NULL
AND (SELECT COUNT(*) FROM platform_bootstrap_grants
WHERE granted_by IS NULL AND active = 1
AND grantee_principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
) = 1
AND (SELECT COUNT(*) FROM platform_bootstrap_grants) = 1
AND (SELECT active_count FROM platform_active_invariant WHERE id = 1) = 1
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
END;
"""
def default_db_path() -> str:
return os.environ.get(
DB_PATH_ENV,
os.path.expanduser("~/.cache/gitea-tools/arch01/platform.sqlite3"),
)
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@dataclass(frozen=True)
class OperationResult:
"""Structured result of a kernel operation (#822 §10)."""
code: str
detail: str = ""
@property
def ok(self) -> bool:
return self.code in (INSTALLED, ALREADY_INSTALLED)
@dataclass
class _ActorContext:
principal: str
kind: str
mode: str
session: Optional[str]
bound_epoch: int
live_epoch: int
expired: bool = False
class PlatformKernel:
"""ARCH-01 authority kernel over a single SQLite connection.
The connection carries the trusted-service actor context: the ``cp_*``
scalar functions read the context this object holds. Only Python code here
can bind or clear it, so no SQL statement can assert an actor identity — the
trusted-service authenticity boundary of #822 §4.
"""
def __init__(self, db_path: Optional[str] = None, *, busy_timeout_ms: int = 5000) -> None:
self.db_path = db_path or default_db_path()
if self.db_path != ":memory:":
parent = os.path.dirname(self.db_path)
if parent:
os.makedirs(parent, exist_ok=True)
self._ctx: Optional[_ActorContext] = None
self._epoch_seq = 0
self._lock = threading.Lock()
# check_same_thread=False is safe: every mutation path is serialized
# by self._lock, so the connection is never used concurrently even when
# callers drive the kernel from different threads (concurrency tests).
self._conn = sqlite3.connect(
self.db_path, isolation_level=None, check_same_thread=False
)
self._conn.execute("PRAGMA foreign_keys = ON")
self._conn.execute(f"PRAGMA busy_timeout = {int(busy_timeout_ms)}")
self._register_actor_functions()
self._migrate()
# -- trusted-service actor functions ---------------------------------- #
def _register_actor_functions(self) -> None:
c = self._conn
c.create_function("cp_actor_principal", 0, lambda: self._ctx.principal if self._ctx else None)
c.create_function("cp_actor_kind", 0, lambda: self._ctx.kind if self._ctx else None)
c.create_function("cp_operation_mode", 0, lambda: self._ctx.mode if self._ctx else None)
c.create_function("cp_service_session", 0, lambda: self._ctx.session if self._ctx else None)
c.create_function("cp_context_epoch", 0, self._fn_context_epoch)
# Trusted-service helper: folds present + non-expired + epoch-consistent
# into the read/re-read epoch equality of #822 §4.
c.create_function("cp_actor_context_valid", 0, self._fn_context_valid)
def _fn_context_epoch(self) -> Optional[int]:
if self._ctx is None or self._ctx.expired:
return None
return self._ctx.live_epoch
def _fn_context_valid(self) -> int:
ctx = self._ctx
if ctx is None or ctx.expired:
return 0
# read/re-read epoch equality: a context whose live epoch has drifted
# from the epoch it was bound to (a stale/replaced connection context)
# is not bound to the active transaction and fails closed.
if ctx.live_epoch != ctx.bound_epoch:
return 0
if ctx.principal is None:
return 0
if ctx.kind not in ACTOR_KINDS or ctx.mode not in OPERATION_MODES:
return 0
return 1
# -- context lifecycle ------------------------------------------------ #
@contextmanager
def actor_context(
self, principal: str, kind: str, mode: str, session: Optional[str] = None
) -> Iterator[None]:
"""Bind a trusted actor context for the duration of the block."""
prev = self._ctx
self._epoch_seq += 1
epoch = self._epoch_seq
self._ctx = _ActorContext(
principal=principal, kind=kind, mode=mode, session=session,
bound_epoch=epoch, live_epoch=epoch,
)
try:
yield
finally:
self._ctx = prev
def _clear_context(self) -> None:
self._ctx = None
# -- migration -------------------------------------------------------- #
def _migrate(self) -> None:
self._conn.executescript(_SCHEMA_SQL)
self._conn.execute(
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES ('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
self._conn.execute(
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES "
"('architecture', 'ARCH-01 Slice A: atomic install + authority kernel (#822); "
"disabled by default until readiness checks pass')"
)
# -- introspection ---------------------------------------------------- #
def is_installed(self) -> bool:
row = self._conn.execute("SELECT COUNT(*) FROM install_state").fetchone()
return bool(row[0])
def active_grant_count(self) -> int:
row = self._conn.execute(
"SELECT active_count FROM platform_active_invariant WHERE id = 1"
).fetchone()
return int(row[0]) if row else 0
def audit_events(self) -> list[str]:
return [
r[0]
for r in self._conn.execute(
"SELECT event FROM audit_records ORDER BY audit_id"
).fetchall()
]
def close(self) -> None:
self._conn.close()
# -- operations ------------------------------------------------------- #
def install_platform(
self,
installer_principal_id: str = "platform.installer",
*,
session: Optional[str] = None,
) -> OperationResult:
"""Single atomic install transaction (#822 §4/§7).
``BEGIN IMMEDIATE`` serializes concurrent installs; the loser rechecks
the marker and returns ``ALREADY_INSTALLED``, or — if it never acquires
the write lock — ``CONCURRENT_INSTALLATION_LOST``. On any stage failure
the whole transaction rolls back leaving no partial rows (AC3/AC5).
"""
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
return OperationResult(CONCURRENT_INSTALLATION_LOST, str(exc))
raise
try:
if self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(ALREADY_INSTALLED, "install marker already present")
with self.actor_context(installer_principal_id, "installer", "install", session):
c = self._conn
# class -> installer principal (temporary NULL issuer)
cur = c.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
(now,),
)
class_id = cur.lastrowid
c.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, 'installer', ?, NULL, ?, ?)",
(installer_principal_id, class_id, installer_principal_id, now),
)
# distinguished operator-key issuer
cur = c.execute(
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) "
"VALUES (?, ?, ?)",
(DISTINGUISHED_ISSUER_KIND, DISTINGUISHED_ISSUER_ID, now),
)
issuer_id = cur.lastrowid
# link installer -> issuer (permitted pre-marker)
c.execute(
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
(issuer_id, installer_principal_id),
)
# dominance tuples
c.executemany(
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
DOMINANCE_TUPLES,
)
# seed
c.execute(
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) "
"VALUES (1, ?, ?)",
(installer_principal_id, now),
)
# initial grant (granted_by NULL, active)
c.execute(
"INSERT INTO platform_bootstrap_grants"
"(grantee_principal_id, granted_by, active, created_at) "
"VALUES (?, NULL, 1, ?)",
(installer_principal_id, now),
)
# active invariant
c.execute(
"INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)"
)
# audit rows for the security-sensitive operation
c.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_PRINCIPAL_REGISTERED, installer_principal_id, "installer", now),
)
c.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_GRANT_CREATED, installer_principal_id, "initial platform.bootstrap grant", now),
)
# install marker LAST -> fires the whole-bootstrap validator
c.execute(
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
(now,),
)
c.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_PLATFORM_INSTALLED, installer_principal_id, "platform installed", now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, "platform installed")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
def register_principal(
self,
principal_id: str,
actor_kind: str,
issuer_ref: str,
*,
actor_principal: str,
actor_kind_ctx: str = "operator",
session: Optional[str] = None,
) -> OperationResult:
"""Atomically create an equivalence class and its first principal.
The class is inserted *before* the principal, and ``current_class_id``
is ``NOT NULL`` (#822 AC6): a principal can never exist classless.
The principal references an existing issuer (non-NULL); the temporary
NULL-issuer exception is reserved for the installer during install
(AC7).
"""
if actor_kind not in ACTOR_KINDS:
return OperationResult(INVALID_BOOTSTRAP_STATE, f"bad actor_kind {actor_kind!r}")
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
return OperationResult(AUTHORIZATION_DENIED, str(exc))
try:
if not self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
row = self._conn.execute(
"SELECT issuer_id FROM authoritative_issuers WHERE issuer_ref = ?",
(issuer_ref,),
).fetchone()
if row is None:
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, f"unknown issuer {issuer_ref!r}")
issuer_id = row[0]
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
cur = self._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
(now,),
)
class_id = cur.lastrowid
self._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(principal_id, actor_kind, class_id, issuer_id, actor_principal, now),
)
self._conn.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_PRINCIPAL_REGISTERED, principal_id, actor_kind, now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, f"registered {principal_id}")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
def grant_platform_bootstrap(
self,
grantee_principal_id: str,
granted_by: str,
*,
actor_kind_ctx: str = "operator",
session: Optional[str] = None,
) -> OperationResult:
"""Create an additional active platform.bootstrap grant.
Serialized on the singleton invariant row via ``BEGIN IMMEDIATE``.
"""
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
return OperationResult(AUTHORIZATION_DENIED, str(exc))
try:
if not self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
with self.actor_context(granted_by, actor_kind_ctx, "normal", session):
self._conn.execute(
"INSERT INTO platform_bootstrap_grants"
"(grantee_principal_id, granted_by, active, created_at) "
"VALUES (?, ?, 1, ?)",
(grantee_principal_id, granted_by, now),
)
self._conn.execute(
"UPDATE platform_active_invariant SET active_count = active_count + 1 WHERE id = 1"
)
self._conn.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_GRANT_CREATED, grantee_principal_id, f"granted_by={granted_by}", now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, f"granted to {grantee_principal_id}")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
def revoke_platform_bootstrap(
self,
grant_id: int,
*,
actor_principal: str,
actor_kind_ctx: str = "operator",
session: Optional[str] = None,
) -> OperationResult:
"""Revoke an active grant, floored so the last one can never drop.
The ``active_count >= 1`` CHECK plus ``BEGIN IMMEDIATE`` serialization
make two concurrent revocations unable to remove the final active grant
(#822 AC11): the decrement that would reach zero fails and rolls back.
"""
now = _utc_now_iso()
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
except sqlite3.OperationalError as exc:
return OperationResult(AUTHORIZATION_DENIED, str(exc))
try:
if not self.is_installed():
self._conn.execute("ROLLBACK")
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
row = self._conn.execute(
"SELECT active, grantee_principal_id FROM platform_bootstrap_grants WHERE grant_id = ?",
(grant_id,),
).fetchone()
if row is None or row[0] != 1:
self._conn.execute("ROLLBACK")
return OperationResult(AUTHORIZATION_DENIED, "grant absent or already inactive")
grantee = row[1]
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
# Decrement first: the CHECK floor rejects dropping below 1,
# aborting the whole revoke before the grant flips inactive.
self._conn.execute(
"UPDATE platform_active_invariant SET active_count = active_count - 1 WHERE id = 1"
)
self._conn.execute(
"UPDATE platform_bootstrap_grants SET active = 0, revoked_at = ? WHERE grant_id = ?",
(now, grant_id),
)
self._conn.execute(
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
"VALUES (?, ?, ?, ?)",
(EVT_GRANT_REVOKED, grantee, f"grant_id={grant_id}", now),
)
self._conn.execute("COMMIT")
return OperationResult(INSTALLED, f"revoked grant {grant_id}")
except sqlite3.Error as exc:
self._safe_rollback()
return OperationResult(self._classify(exc), str(exc))
# -- helpers ---------------------------------------------------------- #
def _safe_rollback(self) -> None:
try:
self._conn.execute("ROLLBACK")
except sqlite3.Error:
pass
@staticmethod
def _classify(exc: sqlite3.Error) -> str:
msg = str(exc)
if "INVALID_ACTOR_CONTEXT" in msg:
return INVALID_ACTOR_CONTEXT
if "DOMINANCE_SET_MISMATCH" in msg:
return DOMINANCE_SET_MISMATCH
if "active_count" in msg or "CHECK constraint failed: platform_active_invariant" in msg:
# last-active-grant floor tripped
return AUTHORIZATION_DENIED
if any(tag in msg for tag in (
"INVALID_BOOTSTRAP_STATE", "IMMUTABLE_", "NOT_INSTALLED",
)):
return INVALID_BOOTSTRAP_STATE
return INVALID_BOOTSTRAP_STATE
+13 -1
View File
@@ -163,7 +163,19 @@ _TERMINAL_OWNERSHIP_STATUSES = frozenset(
{"released", "abandoned", "done", "blocked", "terminal", "closed"} {"released", "abandoned", "done", "blocked", "terminal", "closed"}
) )
_EXPIRED_STATUSES = frozenset({"expired"}) _EXPIRED_STATUSES = frozenset({"expired"})
_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"}) _STALE_STATUSES = frozenset(
{
"stale",
"stale_dead_process",
"stale_missing_worktree",
# #790 Slice A heartbeat-lifecycle bands. Listed here so they are
# *classified* rather than falling through to the unknown-status branch;
# they still block unless the ownership record proves
# ``reclaim_allowed is True``, so the O2 fail-closed rule is unchanged.
"stale_missed_heartbeat",
"stale_absolute_cap",
}
)
def _norm_str(value: Any) -> str: def _norm_str(value: Any) -> str:
+591
View File
@@ -0,0 +1,591 @@
"""Publish an unpublished local commit on a registered issue worktree (#812 AC20).
Entry point B of #812 is the state where an author's work has already advanced
to a local commit: the worktree is registered, clean, on the issue branch, and
carries the only copy of the implementation, but the branch has never been
published. That state deadlocks, because two individually correct predicates
close a cycle:
* ``issue_lock_renewal.assess_exact_owner_lease_renewal`` refuses to renew an
expired lease without an observable remote head — an unpublished branch has
none.
* Every publication path (``gitea_commit_files``, ``gitea_create_pr``) derives
its workspace from the author issue lock under #618, so nothing can create
that remote head without first holding the lock.
This module supplies the missing operation: it publishes an *already committed*
local head to the remote branch, so exact-owner renewal has the evidence it
requires. It deliberately does **not** renew, reclaim, rebind, or clear any
lock. Publication is the whole of its authority.
Why this is not a lock bypass
-----------------------------
The operation can only publish a branch whose **durable issue-lock record
already names the caller as claimant**. Ownership is read from the lock file on
disk (``issue_lock_store``), never from a caller-supplied flag, so the tool
cannot manufacture a claim it does not already hold. Nothing here weakens the
#510/#618/#713 guards: a dirty tree, an unregistered worktree, a foreign
claimant, a changed HEAD, or a divergent remote head each refuse, exactly as
they do today. The only thing this adds is the ability to make an existing,
owned, committed, clean branch observable on the remote.
Separation of records (#812 AC23)
---------------------------------
The durable **issue-lock file** and the control-plane **workflow lease** are
distinct records. This module reads the former as ownership evidence and writes
neither. Publishing changes remote git state only; no lock is renewed,
abandoned, reclaimed, or generation-bumped here.
Process evidence (#812 AC24)
----------------------------
Liveness of the lock's recorded pid is **not consulted**. That is deliberate:
the recorded pid routinely belongs to the long-running MCP daemon rather than to
an active author client, and the existing reclaim predicate
(``assess_expired_lock_reclaim``) can never be satisfied while that daemon runs.
Publication does not require the recording process to be dead, so this module
never asserts, infers, or depends on a process being dead. Ownership is proven
by identity and profile match against the recorded claimant instead.
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
from reviewer_worktree import parse_dirty_tracked_files
from stable_branch_push_guard import is_stable_ref, redact_command
# Assessment outcomes.
PUBLISH_SANCTIONED = "publish_sanctioned"
ALREADY_PUBLISHED = "already_published"
REFUSED = "refused"
#: Implementation branches must stay traceable to their issue (#713 lineage).
ISSUE_BRANCH_RE = re.compile(r"^(fix|feat|docs|chore)/issue-(\d+)-.+$")
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
def _text(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _realpath(value: str | None) -> str | None:
path = _text(value)
return os.path.realpath(path) if path else None
def parse_untracked_files(porcelain_status: str) -> list[str]:
"""Return untracked paths from ``git status --porcelain`` output.
``reviewer_worktree.parse_dirty_tracked_files`` deliberately skips ``??``
entries. Publication needs both halves: an untracked file in the worktree is
unpublished content that the commit does not carry, so publishing would
silently leave it behind.
"""
untracked: list[str] = []
for line in (porcelain_status or "").splitlines():
if not line.startswith("??"):
continue
path = line[2:].strip()
if path:
untracked.append(path)
return untracked
def hash_worktree_files(worktree_path: str, paths) -> dict[str, str | None]:
"""SHA-256 each path under *worktree_path*; ``None`` when unreadable."""
root = _text(worktree_path)
hashes: dict[str, str | None] = {}
for rel in paths or ():
rel_text = _text(rel)
if not rel_text:
continue
full = os.path.join(root, rel_text)
try:
with open(full, "rb") as handle:
digest = hashlib.sha256()
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
hashes[rel_text] = digest.hexdigest()
except OSError:
hashes[rel_text] = None
return hashes
def read_remote_branch_head(
worktree_path: str, remote_name: str, branch_name: str
) -> dict:
"""Observe the remote head for *branch_name*, read-only.
``probe_ok`` False means git could not answer at all. That is kept distinct
from "the branch does not exist": an unobservable remote must fail closed
rather than be mistaken for an absent branch, because the two lead to
opposite dispositions.
"""
path = _text(worktree_path)
remote = _text(remote_name)
branch = _text(branch_name)
result: dict = {
"probe_ok": False,
"remote_branch_exists": False,
"remote_head_sha": None,
"reasons": [],
}
if not (path and remote and branch):
result["reasons"].append(
"remote head probe requires a worktree path, remote name, and branch"
)
return result
try:
res = subprocess.run(
["git", "-C", path, "ls-remote", remote, f"refs/heads/{branch}"],
capture_output=True,
text=True,
check=False,
)
except OSError as exc: # git unavailable — fail closed, never assume absent
result["reasons"].append(f"remote head probe could not run: {exc}")
return result
if res.returncode != 0:
result["reasons"].append(
f"remote head probe failed for '{branch}' on remote '{remote}'"
)
return result
result["probe_ok"] = True
for line in (res.stdout or "").splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}":
result["remote_branch_exists"] = True
result["remote_head_sha"] = parts[0].strip()
break
return result
def read_is_ancestor(
worktree_path: str, ancestor_sha: str, descendant_sha: str
) -> dict:
"""Observe whether *ancestor_sha* is an ancestor of *descendant_sha*."""
path = _text(worktree_path)
ancestor = _text(ancestor_sha)
descendant = _text(descendant_sha)
result: dict = {"probe_ok": False, "is_ancestor": False, "reasons": []}
if not (path and ancestor and descendant):
result["reasons"].append(
"ancestry probe requires a worktree path and both commit SHAs"
)
return result
try:
present = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", "--quiet",
f"{ancestor}^{{commit}}"],
capture_output=True, text=True, check=False,
)
if present.returncode != 0:
result["reasons"].append(
f"remote head {ancestor} is not present locally, so it cannot be "
"proven an ancestor of the commit being published"
)
return result
res = subprocess.run(
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
capture_output=True, text=True, check=False,
)
except OSError as exc:
result["reasons"].append(f"ancestry probe could not run: {exc}")
return result
result["probe_ok"] = res.returncode in (0, 1)
result["is_ancestor"] = res.returncode == 0
return result
def assess_unpublished_commit_publication(
existing_lock,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str,
org: str,
repo: str,
identity: str | None,
profile: str | None,
worktree_state,
worktree_registered: bool | None = None,
remote_probe=None,
ancestry=None,
competing_open_prs=(),
expected_file_hashes=None,
observed_file_hashes=None,
) -> dict:
"""Decide whether an unpublished local commit may be published (#812 AC20).
Pure predicate. Every input is either a caller-declared expectation that
must be *matched* against observation, or a server-side observation. No
caller-supplied boolean is accepted as proof of ownership, liveness, or
eligibility: ``existing_lock`` comes from the durable lock file and the
git/PR state is observed by the server.
The single mutating disposition it can return is "publish this exact commit
to this exact branch". It never sanctions renewal, reclamation, force
updates, history rewriting, or publication of uncommitted content.
"""
reasons: list[str] = []
branch = _text(branch_name)
head = _text(expected_head).lower()
workspace = _realpath(worktree_path)
state = worktree_state if isinstance(worktree_state, dict) else {}
lock = existing_lock if isinstance(existing_lock, dict) else None
evidence: dict = {
"issue_number": issue_number,
"branch_name": branch or None,
"worktree_path": workspace,
"expected_head": head or None,
"remote": _text(remote) or None,
"org": _text(org) or None,
"repo": _text(repo) or None,
"identity": _text(identity) or None,
"profile": _text(profile) or None,
"lock_record_present": lock is not None,
"recorded_claimant": None,
"recorded_branch": None,
"recorded_worktree": None,
"lock_generation": None,
"local_head_sha": _text(state.get("head_sha")) or None,
"current_branch": _text(state.get("current_branch")) or None,
"dirty_tracked_files": [],
"untracked_files": [],
"worktree_registered": worktree_registered,
"remote_branch_exists": None,
"remote_head_sha": None,
"fast_forward_from_remote": None,
"competing_open_prs": [],
"file_hashes_verified": None,
"hash_mismatches": [],
# Recorded explicitly so no reader mistakes silence for a liveness
# claim, and so the audit shows which records were left alone (AC23/AC24).
"owner_pid_liveness_consulted": False,
"workflow_lease_touched": False,
"issue_lock_record_mutated": False,
}
# ── declared shape ────────────────────────────────────────────────────
if not branch:
reasons.append("branch name not declared; fail closed")
if not head:
reasons.append(
"expected_head not declared; publication must name the exact commit"
)
elif not _SHA_RE.match(head):
reasons.append(
f"expected_head '{head}' is not a full 40-character commit SHA; "
"abbreviated or symbolic revisions are refused"
)
if not workspace:
reasons.append("worktree path not declared; fail closed")
if branch:
match = ISSUE_BRANCH_RE.match(branch)
if not match:
reasons.append(
f"branch '{branch}' is not an issue-linked implementation branch "
"((fix|feat|docs|chore)/issue-<number>-<description>); fail closed"
)
elif int(match.group(2)) != int(issue_number):
reasons.append(
f"branch '{branch}' does not carry issue number {issue_number}; "
"fail closed"
)
if is_stable_ref(branch):
reasons.append(
f"refusing to publish stable branch '{branch}'; this operation "
"publishes issue branches only"
)
# ── ownership: durable issue-lock record only (AC8, AC20, AC24) ───────
if lock is None:
reasons.append(
"no durable issue-lock record for this issue; publication requires an "
"existing recorded claim naming the caller, so this operation cannot "
"be used to bypass the author lock"
)
else:
lease = lock.get("work_lease")
lease = lease if isinstance(lease, dict) else {}
claimant = lease.get("claimant")
claimant = claimant if isinstance(claimant, dict) else {}
recorded_user = _text(claimant.get("username"))
recorded_profile = _text(claimant.get("profile"))
recorded_branch = _text(lock.get("branch_name")) or _text(lease.get("branch"))
recorded_worktree = _realpath(
_text(lock.get("worktree_path")) or _text(lease.get("worktree_path"))
)
evidence["recorded_claimant"] = {
"username": recorded_user or None,
"profile": recorded_profile or None,
}
evidence["recorded_branch"] = recorded_branch or None
evidence["recorded_worktree"] = recorded_worktree
try:
evidence["lock_generation"] = int(lock.get("lock_generation") or 0)
except (TypeError, ValueError):
evidence["lock_generation"] = 0
try:
recorded_issue = int(lock.get("issue_number") or 0)
except (TypeError, ValueError):
recorded_issue = 0
if recorded_issue != int(issue_number):
reasons.append(
f"durable lock records issue {lock.get('issue_number')}, not "
f"{issue_number}; ambiguous ownership, fail closed"
)
for field, declared in (
("remote", _text(remote)),
("org", _text(org)),
("repo", _text(repo)),
):
recorded = _text(lock.get(field))
if recorded and declared and recorded != declared:
reasons.append(
f"durable lock records {field} '{recorded}' but the request "
f"declares '{declared}'; repository mismatch, fail closed"
)
if recorded_branch and branch and recorded_branch != branch:
reasons.append(
f"durable lock records branch '{recorded_branch}' but the request "
f"declares '{branch}'; fail closed"
)
if recorded_worktree and workspace and recorded_worktree != workspace:
reasons.append(
f"durable lock records worktree '{recorded_worktree}' but the "
f"request declares '{workspace}'; fail closed"
)
if not recorded_user or not recorded_profile:
reasons.append(
"durable lock does not record a claimant username and profile; "
"ownership cannot be proven, fail closed"
)
else:
if recorded_user != _text(identity):
reasons.append(
f"durable lock claimant '{recorded_user}' is not the acting "
f"identity '{_text(identity) or '(unknown)'}'; foreign claim, "
"fail closed"
)
if recorded_profile != _text(profile):
reasons.append(
f"durable lock claimant profile '{recorded_profile}' is not "
f"the active profile '{_text(profile) or '(unknown)'}'; "
"fail closed"
)
# ── worktree: registered, on-branch, clean, at the expected commit ────
if worktree_registered is False:
reasons.append(
f"worktree '{workspace}' is not listed in git worktree list; #713 "
"requires a genuinely registered worktree, fail closed"
)
current_branch = _text(state.get("current_branch"))
if not current_branch:
reasons.append("worktree branch could not be observed; fail closed")
elif branch and current_branch != branch:
reasons.append(
f"worktree is on branch '{current_branch}', not '{branch}'; fail closed"
)
porcelain = state.get("porcelain_status") or ""
dirty_tracked = parse_dirty_tracked_files(porcelain)
untracked = parse_untracked_files(porcelain)
evidence["dirty_tracked_files"] = dirty_tracked
evidence["untracked_files"] = untracked
if dirty_tracked:
reasons.append(
"worktree has dirty tracked files, so the commit is not the whole of "
f"the work: {', '.join(dirty_tracked)}. This operation publishes an "
"existing clean commit only; uncommitted content is out of scope"
)
if untracked:
reasons.append(
"worktree has untracked files that the commit does not carry: "
f"{', '.join(untracked)}. Publishing would silently leave them "
"behind; fail closed"
)
local_head = _text(state.get("head_sha")).lower()
if not local_head:
reasons.append("local HEAD could not be observed; fail closed")
elif head and local_head != head:
reasons.append(
f"worktree HEAD is {local_head} but the request declares {head}; the "
"local commit changed since it was recorded, fail closed"
)
# ── remote state ──────────────────────────────────────────────────────
probe = remote_probe if isinstance(remote_probe, dict) else {}
already_published = False
if not probe.get("probe_ok"):
reasons.append(
"remote branch head could not be observed; publication must not "
"proceed against an unknown remote state, fail closed"
)
reasons.extend(probe.get("reasons") or [])
else:
remote_exists = bool(probe.get("remote_branch_exists"))
remote_head = _text(probe.get("remote_head_sha")).lower() or None
evidence["remote_branch_exists"] = remote_exists
evidence["remote_head_sha"] = remote_head
if remote_exists and remote_head and head:
if remote_head == head:
already_published = True
evidence["fast_forward_from_remote"] = True
else:
anc = ancestry if isinstance(ancestry, dict) else {}
is_anc = bool(anc.get("probe_ok")) and bool(anc.get("is_ancestor"))
evidence["fast_forward_from_remote"] = is_anc
if not is_anc:
reasons.append(
f"remote branch '{branch}' already exists at {remote_head}, "
f"which is not an ancestor of {head}; publishing would "
"discard or rewrite published history, fail closed"
)
reasons.extend(anc.get("reasons") or [])
elif remote_exists and not remote_head:
reasons.append(
f"remote branch '{branch}' exists but its head could not be read; "
"fail closed"
)
# ── competing claims ──────────────────────────────────────────────────
competing = [p for p in (competing_open_prs or ()) if p]
evidence["competing_open_prs"] = list(competing)
if competing:
reasons.append(
f"open pull request(s) {competing} already claim issue {issue_number} "
"or this branch; ambiguous ownership, fail closed"
)
# ── content verification before publication ───────────────────────────
if expected_file_hashes:
observed = (
observed_file_hashes if isinstance(observed_file_hashes, dict) else {}
)
mismatches: list[str] = []
for path, expected_digest in dict(expected_file_hashes).items():
actual = observed.get(path)
if actual is None:
mismatches.append(f"{path}: missing or unreadable in the worktree")
elif _text(actual).lower() != _text(expected_digest).lower():
mismatches.append(
f"{path}: expected {expected_digest}, observed {actual}"
)
evidence["hash_mismatches"] = mismatches
evidence["file_hashes_verified"] = not mismatches
if mismatches:
reasons.append(
"declared content hashes do not match the worktree: "
+ "; ".join(mismatches)
+ ". Refusing to publish content that is not what was recorded"
)
if reasons:
return {
"outcome": REFUSED,
"publish_sanctioned": False,
"already_published": False,
"reasons": reasons,
"evidence": evidence,
}
return {
"outcome": ALREADY_PUBLISHED if already_published else PUBLISH_SANCTIONED,
# Idempotent retry: a remote head that already equals the assessed commit
# needs no second push, so the caller verifies instead of acting.
"publish_sanctioned": not already_published,
"already_published": already_published,
"reasons": [],
"evidence": evidence,
}
def publish_commit_to_remote_branch(
*,
worktree_path: str,
remote_name: str,
branch_name: str,
expected_head: str,
) -> dict:
"""Send exactly *expected_head* to ``refs/heads/<branch_name>``.
The refspec names the commit SHA explicitly rather than ``HEAD`` or the
local branch, so what lands is the commit that was assessed and nothing
else. No force, no lease, no ``+`` prefix: a non-fast-forward is rejected by
git itself, the last of several independent guards against overwriting
published history.
"""
path = _text(worktree_path)
remote = _text(remote_name)
branch = _text(branch_name)
head = _text(expected_head)
result: dict = {
"success": False,
"pushed_ref": f"refs/heads/{branch}" if branch else None,
"pushed_sha": head or None,
"stderr": None,
"reasons": [],
}
if not (path and remote and branch and head):
result["reasons"].append(
"publication requires a worktree path, remote, branch, and commit SHA"
)
return result
refspec = f"{head}:refs/heads/{branch}"
try:
res = subprocess.run(
["git", "-C", path, "push", remote, refspec],
capture_output=True,
text=True,
check=False,
)
except OSError as exc:
result["reasons"].append(f"publication could not run: {exc}")
return result
if res.returncode != 0:
# Redact before surfacing: failures can echo credentialed remote URLs.
result["stderr"] = redact_command(res.stderr or "")
result["reasons"].append(
f"publication of {head} to '{branch}' on remote '{remote}' failed"
)
return result
result["success"] = True
return result
def verify_published_head(
*, worktree_path: str, remote_name: str, branch_name: str, expected_head: str
) -> dict:
"""Read-after-write: confirm the remote head equals *expected_head* (AC20)."""
probe = read_remote_branch_head(worktree_path, remote_name, branch_name)
head = _text(expected_head).lower()
observed = _text(probe.get("remote_head_sha")).lower() or None
verified = bool(head) and bool(probe.get("probe_ok")) and observed == head
reasons: list[str] = list(probe.get("reasons") or [])
if probe.get("probe_ok") and not verified:
reasons.append(
"read-after-write verification failed: remote head is "
f"{observed or '(absent)'}, expected {head}"
)
return {
"verified": verified,
"remote_head_sha": observed,
"expected_head": head or None,
"reasons": reasons,
}
+2
View File
@@ -100,6 +100,7 @@ that gates each call, not which tools exist.
- `gitea_get_profile` - `gitea_get_profile`
- `gitea_get_runtime_context` - `gitea_get_runtime_context`
- `gitea_get_shell_health` - `gitea_get_shell_health`
- `gitea_heartbeat_issue_lock`
- `gitea_heartbeat_reviewer_pr_lease` - `gitea_heartbeat_reviewer_pr_lease`
- `gitea_inspect_workflow_lease` - `gitea_inspect_workflow_lease`
- `gitea_issue_irrecoverable_provenance_authorization` - `gitea_issue_irrecoverable_provenance_authorization`
@@ -120,6 +121,7 @@ that gates each call, not which tools exist.
- `gitea_observability_list_projects` - `gitea_observability_list_projects`
- `gitea_observability_reconcile_incident` - `gitea_observability_reconcile_incident`
- `gitea_post_heartbeat` - `gitea_post_heartbeat`
- `gitea_publish_unpublished_issue_branch`
- `gitea_quarantine_contaminated_review` - `gitea_quarantine_contaminated_review`
- `gitea_reclaim_expired_workflow_lease` - `gitea_reclaim_expired_workflow_lease`
- `gitea_reconcile_already_landed_pr` - `gitea_reconcile_already_landed_pr`
+295
View File
@@ -0,0 +1,295 @@
# Web console authorization, RBAC, redaction, and audit model (#633)
**Phase 1. Read-only. This document defines the model that future console
writes must pass through; it enables none of them.**
The MVP deployment boundary ([`webui-deployment.md`](webui-deployment.md), #435)
documents internal-only serving and states plainly that MVP authentication is
*none* — protection comes from network placement. That is adequate while every
route is a GET, and inadequate the moment a gated write ships. This document
and the three modules it describes land **before** any write exists, so no
Phase 2 action can be added without an authority to check it against.
| Concern | Module |
|---------|--------|
| Identity, roles, authorization decision | `webui/console_authz.py` |
| Secret redaction for every surface | `webui/console_redaction.py` |
| Audit event schema, retention, sink | `webui/console_audit.py` |
| Machine-readable publication | `GET /api/console/security-model` |
Two invariants hold everywhere and are non-negotiable for every child of #631:
1. **No secrets reach the browser.** Credentials are resolved server-side and
redacted before any payload, page, log line, or audit record leaves.
2. **No ungated mutations.** Authorization is necessary but never sufficient;
execution stays disabled until the Phase 2 framework ships.
## Identity sources
The console performs *authorization*. Authentication is delegated, because a
console that mints its own sessions is a credential store, and this one must
not be.
| Source | Mode value | Authenticated | Shared host | Phase |
|--------|-----------|---------------|-------------|-------|
| None | `none` (default) | No — anonymous, capped at `viewer` | No | 1 |
| Local dev | `local-dev` / `local_dev` | Yes, **asserted not verified** | No | 1 |
| Access proxy | `access-proxy` / `access_proxy` | Yes, asserted by trusted proxy | Yes | 2 |
Selected by `WEBUI_AUTH_MODE`. An unrecognised value falls back to `none`
rather than erroring open.
**Access-proxy mode** reads the subject from the
`Cf-Access-Authenticated-User-Email` header, set by Cloudflare Access, WARP, or
an equivalent org portal that terminates authentication in front of the
console. If the header is absent the request did not traverse the proxy, so the
principal degrades to anonymous — it is never trusted by default.
The **role is always server-side configuration**, never a client assertion. It
comes from `WEBUI_ROLE_MAP`, a JSON object of subject → role:
```json
{"[email protected]": "operator", "[email protected]": "controller"}
```
An unmapped subject gets `viewer`. Malformed JSON yields an empty map, so
everyone gets `viewer` — a parse failure loses authority rather than granting
it.
Full SSO is explicitly a non-goal of this issue.
## Role matrix
Four roles, ordered least to most authority. Each role inherits every lower
role's actions; the table states the *minimum* rank required.
| Role | Authority |
|------|-----------|
| `viewer` | Read every console view. No write, ever, in any phase. |
| `operator` | Viewer, plus author-class work: claim, comment, open a PR. |
| `controller` | Operator, plus reviewer/merger-class decisions on a PR. |
| `admin` | Controller, plus destructive and policy-editing actions. |
`viewer` holds the empty write set by construction, and a test asserts it stays
empty.
## Privileged actions
Every console action maps to a `task_key` in `task_capability_map.py`, the same
single source of truth `gitea_resolve_task_capability` and the MCP tool gates
use. The console therefore cannot invent an authority the MCP layer does not
already define, and a regression test asserts each mapping matches.
| Action | Minimum role | Class | MCP permission | Confirm | Dual control | Break-glass | Phase |
|--------|--------------|-------|----------------|---------|--------------|-------------|-------|
| `claim_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
| `comment_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
| `create_issue` | operator | gated_write | `gitea.issue.create` | Yes | No | No | 2 |
| `comment_pr` | operator | gated_write | `gitea.pr.comment` | Yes | No | No | 2 |
| `create_pr` | operator | gated_write | `gitea.pr.create` | Yes | No | No | 2 |
| `review_pr` | controller | privileged | `gitea.pr.review` | Yes | No | No | 3 |
| `close_pr` | controller | privileged | `gitea.pr.close` | Yes | No | No | 3 |
| `merge_pr` | controller | privileged | `gitea.pr.merge` | Yes | **Yes** | **Yes** | 3 |
| `delete_branch` | admin | destructive | `gitea.branch.delete` | Yes | **Yes** | **Yes** | 3 |
**Dual control** means the acting principal may not be the sole authority: a
second distinct principal must confirm. **Break-glass** means the action is
expected to be unavailable in normal operation and its use is retained for two
years. Both are declared here and enforced by the Phase 2 framework; Phase 1
records the requirement on every decision so the framework cannot ship without
honouring it.
`delete_branch` is admin-only rather than controller because it is the one
irreversible action in the set.
### Authorization decision
`authorize(action_id, principal, for_execution=False)` returns a decision
record and **denies by default**. The deny reasons are closed and enumerated:
| Reason code | Meaning |
|-------------|---------|
| `unknown_action` | No such console action is registered. |
| `unauthenticated` | The principal is anonymous. |
| `unknown_role` | The role is not in the matrix. |
| `insufficient_role` | The role ranks below the action's minimum. |
| `phase_not_active` | Execution requested for an action whose phase is not open. |
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
There is no implicit allow branch. Even the allow result reports
`execution_enabled: false` while the console is in Phase 1, so no caller can
read an allow as permission to mutate.
## Secret redaction
One pass applies to **API payloads, rendered HTML, server logs, and audit
records** — the four surfaces where a credential could escape.
Redaction reuses `gitea_audit.redact` rather than forking it: that remains the
authority for secret-looking dict keys, `Authorization` material, and raw URLs.
The console layer then applies its own patterns:
Each rule below matches an *assignment form*: the named key, followed by `=` or
`:`, followed by the value. The keys are listed bare rather than spelled out as
complete assignments, because this document is itself scanned by
`scan_for_secrets` — writing the examples in full assignment form would make the
documentation trip the very detectors it documents.
| Rule | Catches (as an assignment) |
|------|----------------------------|
| `credential_assignment` | `token`, `password`, `passwd`, `secret`, `api_key`, `access_key`, `client_secret`, `private_key` |
| `credential_env_assignment` | `GITEA_TOKEN`, `GITEA_PASS`, `GITEA_PASSWORD` and suffixed variants |
| `keychain_reference` | `keychain:` entry references |
| `keychain_command` | macOS `security` keychain lookups (`find-generic-password`, `find-internet-password`) |
| `private_key_block` | PEM `BEGIN ... PRIVATE KEY` blocks |
| `json_web_token` | Three-segment `eyJ...` JWTs |
| `bearer_credential` | `Bearer` / `Basic` credentials |
Assignments keep the key and replace only the value, so an operator can still
see *what* was removed. Two behaviours are deliberate:
- **Fail closed.** A value that cannot be redacted becomes `[REDACTED]`
outright rather than being emitted raw. Redaction never raises.
- **Redact before persist.** `console_audit.build_event` redacts before
serialization, and `write_event` re-scans and **drops** any record that still
trips a detector. An unredacted record is never durable.
`scan_for_secrets` is the assertion helper: it returns the detector names still
matching a payload, and already-redacted hits are not findings. Tests use it to
prove the published policy, the security-model endpoint, and this document
itself carry no secret material.
## Audit event schema
`gitea_audit` records MCP-side *mutations* — which profile and Gitea user
performed which tool call. It has no console actor, no identity source, no
correlation identifier, and no retention class, and an authorization **denial**
is not a mutation, so it would never appear there at all. The console record is
additive, not a replacement: a Phase 2 action emits both, joined on
`correlation.request_id`.
Required fields, all asserted by tests so an edit cannot quietly drop one:
| Field | Content |
|-------|---------|
| `schema_version` | Currently `1`. |
| `event_id` | Unique per record. |
| `timestamp` | Timezone-aware ISO-8601, UTC. |
| `actor` | `subject`, `role`, `identity_source`, `authenticated`. |
| `action` | Console action id. |
| `action_class` | `gated_write`, `privileged`, `destructive`, or `unknown`. |
| `target` | `{kind, ref}`, e.g. `{"kind": "pr", "ref": "#123"}`. |
| `result` | `allowed`, `denied`, `previewed`, `failed`, `succeeded`. |
| `reason_code` | The authorization reason code above. |
| `correlation` | `request_id`, `session_id`, `mcp_task`, `mcp_permission`. |
| `retention` | `class`, `days`, `expires_at`. |
| `redacted` | Always `true`; records are redacted at build time. |
An unrecognised `result` degrades to `failed` rather than being stored
verbatim.
The sink is an append-only JSON Lines file named by
`WEBUI_CONSOLE_AUDIT_LOG`. It is **off by default**: with the variable unset,
events are still built — so callers and tests exercise the schema — but nothing
is written. Auditing never raises; a failed write returns `False` rather than
breaking the request it describes.
## Retention
| Class | Applies to | Default |
|-------|-----------|---------|
| `standard` | Routine gated writes | 90 days |
| `privileged` | `review_pr`, `close_pr`, and any unclassifiable action | 365 days |
| `break_glass` | `merge_pr`, `delete_branch` | 730 days |
Each record carries its own class, day count, and computed `expires_at`, so
retention is auditable per record rather than inferred from file age. An
**unknown action is retained as privileged, not standard** — for a safety
control the conservative direction is to keep the record longer.
Nothing in this module updates or deletes. Expiry is enforced by an
operator-run policy against `expires_at`, never by the console silently
rewriting its own history.
## Phase 2 integration
Phase 2 opens gated writes. It must reuse this model rather than introduce a
second one. The integration points are already wired and observable:
- **`GET /api/actions/{action_id}/preview`** attaches an `authorization` block
to the existing preview payload and records a `previewed` audit event.
- **`POST /api/actions/{action_id}/attempt`** attaches the same block and
records a `denied` event. The terminal outcome is unchanged — the MVP
registry in `webui/gated_actions.py` still fails closed for every action — so
Phase 1 cannot loosen anything. Phase 2 enforces on this same decision
instead of adding a parallel check.
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
policy, and audit policy as JSON for operators and tests.
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
confirmation and dual-control flow the matrix already declares, emit a
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
keep `viewer` unable to reach any of it. Turning on execution without the
confirmation flow contradicts a declared requirement and is a review failure,
not a shortcut.
## Local-dev mode
`WEBUI_AUTH_MODE=local-dev` reads the principal straight from the environment:
| Variable | Purpose |
|----------|---------|
| `WEBUI_DEV_SUBJECT` | Subject string; absent ⇒ anonymous |
| `WEBUI_DEV_ROLE` | One of `viewer`, `operator`, `controller`, `admin`; unrecognised ⇒ `viewer` |
**INSECURE — this mode is for loopback development only.** The subject and role
are *asserted by the developer running the process and verified by nothing*.
Anyone able to set an environment variable on the host is an `admin`, and
anyone able to reach the port inherits that principal. It provides no
authentication whatsoever; it exists so Phase 2 authorization paths can be
exercised without standing up a proxy.
Never enable local-dev mode on a non-loopback bind. Combining it with
`WEBUI_ALLOW_PUBLIC_BIND=1` or `WEBUI_ALLOW_REMOTE_BIND=1` publishes an
unauthenticated admin console.
For anything beyond a laptop use `access-proxy` mode behind Cloudflare Access,
WARP, or a VPN, as [`webui-deployment.md`](webui-deployment.md) requires.
### Probe authentication
`WEBUI_REQUIRE_PROBE_AUTH=1` declares that non-public probes should require an
authenticated principal. It is **opt-in**: the default is off so the MVP
`/health` contract is unchanged.
**This flag is declarative in Phase 1 and enforces nothing today.**
`console_authz.probe_auth_required()` reports the operator's intent, and no
route consults it — setting the variable does not currently change the
behaviour of `/health` or any other endpoint. It is published here so the Phase
2 action framework has a declared policy to honour rather than inventing a
second one, exactly as `ACTIVE_PHASE` gates execution while the matrix is
already declared. A regression test pins this "declared, not enforced" status,
so wiring it later is a deliberate change rather than a silent one.
Until Phase 2 wires it, probe protection rests on network placement alone, as
[`webui-deployment.md`](webui-deployment.md) (#435) states.
## Environment variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `WEBUI_AUTH_MODE` | `none` | Identity source selection |
| `WEBUI_DEV_SUBJECT` | unset | Local-dev subject (insecure) |
| `WEBUI_DEV_ROLE` | `viewer` | Local-dev role (insecure) |
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
All are read server-side only. None is ever rendered into a page or returned by
an API.
## Non-goals
- No full SSO product; authentication stays delegated to the proxy.
- No browser-initiated merges or approvals in any phase covered here.
- No tokens in the frontend, in browser storage, or in committed config.
+4 -1
View File
@@ -7,7 +7,10 @@ only.
## MVP deployment model ## MVP deployment model
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`) - **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
- **Authentication:** none in MVP — protection comes from network placement - **Authentication:** none in MVP — protection comes from network placement.
The authorization, RBAC, redaction, and audit model that future gated writes
must pass through is defined in
[`webui-authz-audit.md`](webui-authz-audit.md) (#633).
- **Mutations:** read-only routes; gated write actions remain disabled (#434) - **Mutations:** read-only routes; gated write actions remain disabled (#434)
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never - **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
embedded in HTML, JavaScript, or browser storage embedded in HTML, JavaScript, or browser storage
+437 -8
View File
@@ -234,12 +234,25 @@ def _effective_workspace_role() -> str:
def _profile_role_kind(profile: dict) -> str: def _profile_role_kind(profile: dict) -> str:
"""Resolve a profile's declared role before inferring from permissions.""" """Resolve a profile's declared role before inferring from permissions.
role = (profile.get("role") or profile.get("role_kind") or "").strip()
Declared ``role`` / ``role_kind`` always wins so a controller profile is
never reclassified as reconciler from permission inference (#840).
"""
role = (profile.get("role") or profile.get("role_kind") or "").strip().lower()
if role: if role:
# Normalize aliases / case.
if "control" in role:
return "controller"
return role return role
profile_name = (profile.get("profile_name") or "").strip().lower() profile_name = (profile.get("profile_name") or "").strip().lower()
for candidate in ("reconciler", "merger", "reviewer", "author"): for candidate in (
"controller",
"reconciler",
"merger",
"reviewer",
"author",
):
if candidate in profile_name: if candidate in profile_name:
return candidate return candidate
return _role_kind( return _role_kind(
@@ -2007,6 +2020,7 @@ import allocator_dependencies # noqa: E402
import dependency_graph # noqa: E402 # #784 durable dependency edges import dependency_graph # noqa: E402 # #784 durable dependency edges
import control_plane_db # noqa: E402 import control_plane_db # noqa: E402
import lease_lifecycle # noqa: E402 import lease_lifecycle # noqa: E402
import lease_policy # noqa: E402
import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard import workflow_dashboard # noqa: E402 # #605 live queue/lease dashboard
import incident_bridge # noqa: E402 import incident_bridge # noqa: E402
import sentry_observability # noqa: E402 (#606 optional Sentry observability) import sentry_observability # noqa: E402 (#606 optional Sentry observability)
@@ -2024,6 +2038,7 @@ import review_quarantine # noqa: E402 # #695 contaminated formal-review quaran
import mcp_daemon_guard # noqa: E402 # #695 native transport provenance import mcp_daemon_guard # noqa: E402 # #695 native transport provenance
import already_landed_reconcile # noqa: E402 import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402 import author_mutation_worktree # noqa: E402
import branch_publish # noqa: E402 # #812 AC20 unpublished-commit publication
import root_checkout_guard # noqa: E402 import root_checkout_guard # noqa: E402
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
import stable_branch_push_guard # noqa: E402 import stable_branch_push_guard # noqa: E402
@@ -2248,7 +2263,6 @@ import canonical_comment_validator as ccv # noqa: E402
# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer. # GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer.
# Legacy global path retained only for test/doc references — do not seed manually. # Legacy global path retained only for test/doc references — do not seed manually.
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json" ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work" AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
VALID_WORK_LEASE_OPERATIONS = frozenset({ VALID_WORK_LEASE_OPERATIONS = frozenset({
AUTHOR_ISSUE_WORK_LEASE, AUTHOR_ISSUE_WORK_LEASE,
@@ -2548,7 +2562,12 @@ def _build_author_issue_work_lease(
host: str | None, host: str | None,
) -> dict: ) -> dict:
created = _work_lease_now() created = _work_lease_now()
expires = created + timedelta(hours=WORK_LEASE_TTL_HOURS) # #790 Slice A: the window comes from the central policy, not a literal here.
# It is also now a *sliding* window — the lease lives ``initial_ttl_minutes``
# past its last valid heartbeat rather than a fixed four hours past its
# creation, so an abandoned task stops holding the claim within one TTL.
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
expires = created + timedelta(minutes=policy.initial_ttl_minutes)
return { return {
"operation_type": AUTHOR_ISSUE_WORK_LEASE, "operation_type": AUTHOR_ISSUE_WORK_LEASE,
"issue_number": issue_number, "issue_number": issue_number,
@@ -2559,6 +2578,15 @@ def _build_author_issue_work_lease(
"created_at": _work_lease_timestamp(created), "created_at": _work_lease_timestamp(created),
"expires_at": _work_lease_timestamp(expires), "expires_at": _work_lease_timestamp(expires),
"last_heartbeat_at": _work_lease_timestamp(created), "last_heartbeat_at": _work_lease_timestamp(created),
# #790 AC-N1: the ownership key for this task. Distinct from the recorded
# PID, which is the shared daemon and identifies no individual task.
"task_session_id": issue_lock_store.mint_task_session_id(
AUTHOR_ISSUE_WORK_LEASE
),
# #790 AC-N8: the explicit lifecycle marker. Its absence — never a
# timestamp comparison — is what makes a lock legacy.
"lifecycle_version": lease_policy.LIFECYCLE_HEARTBEAT_V1,
"heartbeat_count": 1,
} }
@@ -4328,6 +4356,138 @@ def gitea_lock_issue(
return result return result
@mcp.tool()
def gitea_heartbeat_issue_lock(
issue_number: int,
branch_name: str,
task_session_id: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
expected_generation: int | None = None,
) -> dict:
"""Prove an owned author issue lease is still active (#790 Slice A).
The task-liveness signal the lifecycle was missing. Before this, an author
lease carried a fixed four-hour expiry that nothing could shorten, and the
only liveness evidence was the recorded PID the long-lived MCP daemon,
which stays alive across every task it serves and so proved nothing about
whether the authoring task still held the work.
Each successful call slides the lease ``initial_ttl_minutes`` past *now*
from the central policy, so an actively heartbeating session is never
evicted while an abandoned one releases its claim within one TTL.
What this tool cannot do, by construction:
* **Acquire.** It refuses when no durable lock exists.
* **Take over.** Exact issue, branch, realpath-normalized worktree,
claimant username, claimant profile, and recorded task-session identifier
must all match; a superseded session holding an older identifier is
refused.
* **Revive.** A lease already past its grace is not heartbeatable that
would let a session restore ownership it had stopped proving. It must use
the sanctioned reclaim path, which mints a new generation.
A lock predating the heartbeat lifecycle is rebound rather than heartbeated:
its exact owner is re-verified and a genuine task-session identifier and
first heartbeat are minted (#790 AC-N8). The rebind is decided server-side
from the durable lifecycle marker; there is no caller-facing switch.
Args:
issue_number: The locked issue number.
branch_name: The branch recorded on the lock.
task_session_id: The identifier this session received when it acquired
or rebound the lock. It is a fencing token, not an ownership
assertion: it is compared against durable state and can only ever
cause a refusal, never grant anything. Omitted only when rebinding a
legacy lock, which has no identifier yet and mints one.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
worktree_path: Author worktree recorded on the lock.
expected_generation: Optional fencing value. The per-issue flock already
serializes the read and the write, so this is for a caller that
wants to pin the generation it last observed across calls; a moved
generation fails closed.
Returns:
dict with 'success', 'performed', the sliding 'expires_at',
'last_heartbeat_at', 'lock_generation', 'task_session_id', the applied
'policy', and post-write 'freshness'; on refusal 'success'/'performed'
False with 'reasons' naming exactly what did not match.
"""
blocked = _profile_permission_block(
task_capability_map.required_permission("heartbeat_issue_lock"),
issue_number=issue_number,
remote=remote,
host=host,
org=org,
repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, _canonical_local_git_root()
)
h, o, r = _resolve(remote, host, org, repo)
claimant = _work_lease_claimant(h)
identity = claimant.get("username")
profile = claimant.get("profile")
existing = _load_existing_issue_lock(
remote=remote, org=o, repo=r, issue_number=issue_number
)
if not existing:
return {
"success": False,
"performed": False,
"issue_number": issue_number,
"reasons": [
f"no durable lock for issue #{issue_number}; heartbeat cannot "
"acquire a claim (fail closed)"
],
}
if issue_lock_store.is_legacy_lease(existing):
# AC-N8 exit route one: canonical exact-owner rebinding. The other exit
# is terminal retirement, which is Slice B.
outcome = issue_lock_store.rebind_legacy_lock(
remote=remote,
org=o,
repo=r,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved_worktree,
identity=identity,
profile=profile,
expected_generation=expected_generation,
)
outcome["operation"] = "legacy_rebind"
return outcome
outcome = issue_lock_store.heartbeat_session_lock(
remote=remote,
org=o,
repo=r,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=resolved_worktree,
identity=identity,
profile=profile,
task_session_id=str(task_session_id or ""),
expected_generation=expected_generation,
)
outcome["operation"] = "heartbeat"
return outcome
@mcp.tool() @mcp.tool()
def gitea_assess_work_issue_duplicate( def gitea_assess_work_issue_duplicate(
issue_number: int, issue_number: int,
@@ -9082,6 +9242,253 @@ def gitea_commit_files(
} }
def _publication_block(reasons: list[str], **extra) -> dict:
"""Uniform fail-closed shape for publication refusals (#812 AC20)."""
payload = {
"success": False,
"performed": False,
"published": False,
"verified": False,
"outcome": branch_publish.REFUSED,
"reasons": reasons,
# State every record this operation left alone, so a refusal can never
# be misread as a lock mutation (#812 AC23).
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
}
payload.update(extra)
return payload
@mcp.tool()
def gitea_publish_unpublished_issue_branch(
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
git_remote_name: str | None = None,
expected_file_hashes: dict | None = None,
dry_run: bool = False,
) -> dict:
"""Publish an already-committed, unpublished issue branch (#812 AC20).
Creates the remote head for a branch whose work is *already* a local commit
on a registered, clean worktree, so exact-owner lease renewal
(``issue_lock_renewal``) has the published head its evidence model requires.
This is the one step of the entry point B deadlock that no existing tool can
perform: publication is otherwise lock-derived under #618, and the lock
itself is withheld until a remote head exists.
Not a lock bypass. The branch's **durable issue-lock record must already
name the caller as claimant** ownership is read from the lock file, never
asserted by the caller so this can only publish work the caller already
owns. It refuses a dirty or untracked-carrying worktree, an unregistered
worktree, a changed local HEAD, a remote head that is not an ancestor of the
commit, a competing open PR on another branch for the same issue, and any
declared-hash mismatch. It renews, reclaims, and clears nothing: the durable
issue-lock file and the control-plane workflow lease are both left untouched
(#812 AC23), and the recorded owner pid's liveness is never consulted or
asserted (#812 AC24).
Args:
issue_number: The issue whose recorded claim authorizes publication.
branch_name: Issue branch to publish, ``(fix|feat|docs|chore)/issue-N-``.
worktree_path: Registered worktree holding the commit.
expected_head: Full 40-character SHA of the commit to publish. Required:
publication names the exact commit, and a mismatch fails closed.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
git_remote_name: Git remote to publish to; defaults to *remote*.
expected_file_hashes: Optional ``{path: sha256}`` verified against the
worktree before publication. Any mismatch or missing file refuses.
dry_run: Report the decision and evidence, mutate nothing.
Returns:
dict with 'success', 'performed', 'published', 'verified', 'outcome',
'remote_head_sha', 'reasons', and 'evidence'.
"""
task = "publish_unpublished_branch"
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
task
)
if not ok:
return _publication_block(block_reasons)
blocked = _namespace_mutation_block(task, remote=remote)
if blocked:
return blocked
blocked = _profile_permission_block(
task_capability_map.required_permission(task),
remote=remote, host=host, org=org, repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
# #815: resolve the caller's worktree *before* preflight and forward it, so
# every workspace-resolution layer behind verify_preflight_purity — including
# the #618 branches-only guard — judges the registered issue worktree this
# publication actually operates on. Resolving it afterwards let preflight
# fall back to the MCP process root, so a daemon rooted at the stable control
# checkout refused a valid explicit worktree before the assessor ever ran.
# A caller supplying nothing usable forwards None and keeps the ordinary
# fail-closed fallback.
explicit_worktree = (worktree_path or "").strip()
workspace = os.path.realpath(os.path.abspath(explicit_worktree or "."))
verify_preflight_purity(
remote,
worktree_path=workspace if explicit_worktree else None,
task=task,
org=org,
repo=repo,
)
h, o, r = _resolve(remote, host, org, repo)
git_remote = (git_remote_name or remote or "").strip()
existing_lock = issue_lock_store.load_issue_lock(
remote=remote, org=o, repo=r, issue_number=int(issue_number)
)
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
registered = author_mutation_worktree.path_in_git_worktree_list(
workspace, PROJECT_ROOT
)
remote_probe = branch_publish.read_remote_branch_head(
workspace, git_remote, branch_name
)
ancestry = None
probe_head = (remote_probe.get("remote_head_sha") or "").strip()
if probe_head and probe_head.lower() != (expected_head or "").strip().lower():
ancestry = branch_publish.read_is_ancestor(workspace, probe_head, expected_head)
# A PR on this very branch is this work's own PR, not a rival claim. Only an
# open PR for the same issue on a *different* branch is a competing claim.
competing: list = []
for pull in _list_open_pulls(h, o, r, _auth(h)):
ref = str((pull.get("head") or {}).get("ref") or "")
if not ref or ref == branch_name:
continue
if issue_lock_adoption.branch_carries_issue_marker(ref, int(issue_number)):
competing.append(pull.get("number"))
observed_hashes = None
if expected_file_hashes:
observed_hashes = branch_publish.hash_worktree_files(
workspace, list(expected_file_hashes.keys())
)
claimant = _work_lease_claimant(h)
assessment = branch_publish.assess_unpublished_commit_publication(
existing_lock,
issue_number=int(issue_number),
branch_name=branch_name,
worktree_path=workspace,
expected_head=expected_head,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
worktree_state=git_state,
worktree_registered=registered,
remote_probe=remote_probe,
ancestry=ancestry,
competing_open_prs=competing,
expected_file_hashes=expected_file_hashes,
observed_file_hashes=observed_hashes,
)
if assessment["outcome"] == branch_publish.REFUSED:
return _publication_block(
assessment["reasons"], evidence=assessment["evidence"]
)
if dry_run:
return {
"success": True,
"performed": False,
"published": False,
"verified": False,
"dry_run": True,
"outcome": assessment["outcome"],
"would_publish": assessment["publish_sanctioned"],
"remote_head_sha": assessment["evidence"].get("remote_head_sha"),
"reasons": [],
"evidence": assessment["evidence"],
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
}
performed = False
if assessment["publish_sanctioned"]:
with _audited(
task, host=h, remote=remote, org=o, repo=r,
target_branch=branch_name,
request_metadata={
"issue_number": int(issue_number),
"expected_head": expected_head,
"git_remote": git_remote,
},
):
push = branch_publish.publish_commit_to_remote_branch(
worktree_path=workspace,
remote_name=git_remote,
branch_name=branch_name,
expected_head=expected_head,
)
if not push.get("success"):
return _publication_block(
push.get("reasons") or ["publication failed"],
evidence=assessment["evidence"],
stderr=push.get("stderr"),
)
performed = True
verification = branch_publish.verify_published_head(
worktree_path=workspace,
remote_name=git_remote,
branch_name=branch_name,
expected_head=expected_head,
)
if not verification.get("verified"):
return _publication_block(
verification.get("reasons") or ["read-after-write verification failed"],
evidence=assessment["evidence"],
performed=performed,
published=performed,
remote_head_sha=verification.get("remote_head_sha"),
)
return {
"success": True,
"performed": performed,
"published": True,
"verified": True,
"outcome": assessment["outcome"],
"remote_head_sha": verification.get("remote_head_sha"),
"reasons": [],
"evidence": assessment["evidence"],
# Publication is the whole of this operation's authority (#812 AC23).
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
"exact_next_action": (
f"Remote head for '{branch_name}' is now observable. Call "
f"gitea_lock_issue(issue_number={int(issue_number)}, "
f"branch_name='{branch_name}', worktree_path='{workspace}') to renew "
"the exact-owner lease, then gitea_create_pr."
),
}
# Merge methods supported by the Gitea merge API. # Merge methods supported by the Gitea merge API.
_MERGE_METHODS = ("merge", "squash", "rebase") _MERGE_METHODS = ("merge", "squash", "rebase")
@@ -15290,7 +15697,8 @@ def mcp_get_control_plane_guide(
profile = get_profile() profile = get_profile()
allowed = profile["allowed_operations"] allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"] forbidden = profile["forbidden_operations"]
role = _role_kind(allowed, forbidden) # Prefer declared profile role so controller is not mislabeled reconciler (#840).
role = _profile_role_kind(profile)
username = _authenticated_username(h) username = _authenticated_username(h)
identity = { identity = {
@@ -15349,6 +15757,16 @@ def mcp_get_control_plane_guide(
"user, and merging requires explicit operator authorization plus the " "user, and merging requires explicit operator authorization plus the "
"'MERGE PR <n>' confirmation. " "'MERGE PR <n>' confirmation. "
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.") "Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
elif role == "controller":
guidance.append(
"Controller profile: route work via "
"gitea_route_task_session(task_type='process_work_queue') then "
"gitea_allocate_next_work (allocation_mode=cross_role by default). "
"The allocator returns exactly one authoritative selection with "
"required_role / required_profile / selected_action. Do not "
"implement, review, approve, or merge in this session — schedule "
"the matching role namespace instead. Dashboard output is "
"explanatory only and never replaces allocator selection.")
elif role == "mixed": elif role == "mixed":
guidance.append( guidance.append(
"WARNING: this profile allows both authoring and " "WARNING: this profile allows both authoring and "
@@ -15558,7 +15976,8 @@ def gitea_whoami(
"environment": profile.get("environment"), "environment": profile.get("environment"),
"service": profile.get("service"), "service": profile.get("service"),
"identity": profile.get("identity"), "identity": profile.get("identity"),
"role": profile.get("role"), "role": profile.get("role") or _profile_role_kind(profile),
"role_kind": _profile_role_kind(profile),
"profile_address": profile.get("profile_path"), "profile_address": profile.get("profile_path"),
"execution_profile": profile.get("execution_profile"), "execution_profile": profile.get("execution_profile"),
"audit_label": profile.get("audit_label"), "audit_label": profile.get("audit_label"),
@@ -20342,9 +20761,10 @@ def gitea_allocate_next_work(
candidates_json: Any = None, candidates_json: Any = None,
exclude_issue_numbers: list[int] | None = None, exclude_issue_numbers: list[int] | None = None,
expected_candidate_set_fingerprint: str | None = None, expected_candidate_set_fingerprint: str | None = None,
allocation_mode: str | None = None,
limit: int = 50, limit: int = 50,
) -> dict: ) -> dict:
"""Controller-owned next-work allocator using the #613 control-plane DB (#600). """Controller-owned next-work allocator using the #613 control-plane DB (#600/#840).
Workers must not self-select exclusive work under the standard multi-LLM Workers must not self-select exclusive work under the standard multi-LLM
workflow. Call this tool instead. workflow. Call this tool instead.
@@ -20354,6 +20774,14 @@ def gitea_allocate_next_work(
``ControlPlaneDB.assign_and_lease`` (never file locks or comment-only ``ControlPlaneDB.assign_and_lease`` (never file locks or comment-only
leases as the coordination source). leases as the coordination source).
*allocation_mode* (#840): when the active role is controller (or mode is
``cross_role``), inspect the complete queue and return exactly one
authoritative selection with selected item, action, required_role,
required_profile/namespace, pins, and allocation/lease evidence.
Role-scoped workers pass ``role=author|reviewer|merger|reconciler`` (or
omit for profile role) for single-role filtering. Controller routes only
and does not perform downstream mutations.
Outcomes include: ``assigned_work``, ``preview``, ``wait``, Outcomes include: ``assigned_work``, ``preview``, ``wait``,
``blocked_by_terminal_path``, ``no_safe_work``, ``role_ineligible``, ``blocked_by_terminal_path``, ``no_safe_work``, ``role_ineligible``,
``blocked_by_excluded_own_lease``, ``candidate_set_drift``. ``blocked_by_excluded_own_lease``, ``candidate_set_drift``.
@@ -20488,6 +20916,7 @@ def gitea_allocate_next_work(
controller_instance_id=allocator_service.resolve_controller_instance_id(), controller_instance_id=allocator_service.resolve_controller_instance_id(),
exclude_issue_numbers=exclude_issue_numbers, exclude_issue_numbers=exclude_issue_numbers,
expected_candidate_set_fingerprint=expected_candidate_set_fingerprint, expected_candidate_set_fingerprint=expected_candidate_set_fingerprint,
allocation_mode=allocation_mode,
) )
except ValueError as exc: except ValueError as exc:
return { return {
+553 -29
View File
@@ -15,15 +15,27 @@ import json
import os import os
import re import re
import tempfile import tempfile
import uuid
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import lease_policy
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR" LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks") DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work" AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
# Freshness classifications. ``STATUS_STALE`` remains the dead-PID band that
# #753 recovery keys on; the two bands below are new in #790 Slice A and apply
# only to leases minted under the heartbeat lifecycle.
STATUS_LIVE = "live"
STATUS_EXPIRED = "expired"
STATUS_ABSENT = "absent"
STATUS_STALE = "stale"
STATUS_STALE_MISSED_HEARTBEAT = "stale_missed_heartbeat"
STATUS_STALE_ABSOLUTE_CAP = "stale_absolute_cap"
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+") _SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
@@ -253,6 +265,331 @@ def bind_session_lock(
return path return path
def _ownership_refusals(
lock: dict[str, Any],
*,
issue_number: int,
branch_name: str,
worktree_path: str,
identity: str | None,
profile: str | None,
) -> list[str]:
"""Exact-ownership mismatches between a durable lock and a live caller.
Shared by the heartbeat writer and the legacy rebind path so the two cannot
disagree about what "the same owner" means. Every field is compared against
durable state; nothing is taken on the caller's word beyond the identity the
server itself resolved.
"""
reasons: list[str] = []
if lock.get("issue_number") != issue_number:
reasons.append(
f"lock targets issue #{lock.get('issue_number')}, not #{issue_number}"
)
if str(lock.get("branch_name") or "") != str(branch_name or ""):
reasons.append(
f"lock branch '{lock.get('branch_name')}' does not match '{branch_name}'"
)
if not _same_realpath(str(lock.get("worktree_path") or ""), worktree_path):
reasons.append(
f"lock worktree '{lock.get('worktree_path')}' does not match "
f"'{worktree_path}'"
)
lease = lock.get("work_lease") if isinstance(lock, dict) else None
claimant = lease.get("claimant") if isinstance(lease, dict) else None
claimant = claimant if isinstance(claimant, dict) else {}
recorded_identity = str(claimant.get("username") or "").strip()
recorded_profile = str(claimant.get("profile") or "").strip()
if not recorded_identity or not recorded_profile:
reasons.append("lock does not record both a claimant username and profile")
if recorded_identity and recorded_identity != str(identity or "").strip():
reasons.append(
f"lock claimant '{recorded_identity}' does not match active identity "
f"'{str(identity or '').strip() or 'unknown'}'"
)
if recorded_profile and recorded_profile != str(profile or "").strip():
reasons.append(
f"lock profile '{recorded_profile}' does not match active profile "
f"'{str(profile or '').strip() or 'unknown'}'"
)
return reasons
def _refusal(reasons: list[str], **extra: Any) -> dict[str, Any]:
return {"success": False, "performed": False, "reasons": reasons, **extra}
def heartbeat_session_lock(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
branch_name: str,
worktree_path: str,
identity: str | None,
profile: str | None,
task_session_id: str,
expected_generation: int | None = None,
lock_dir: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Slide a heartbeat-lifecycle lease forward (#790 Slice A, A4).
The write happens inside the same per-issue ``flock`` that serializes
acquisition, and under the #772 generation compare-and-swap, so a heartbeat
can never race a concurrent reclaim: whichever lands first moves the
generation and the other fails closed.
Refuses — never revives — in every ambiguous case. A lease that has already
lapsed past its grace is *not* heartbeatable: allowing that would let a
session that stopped proving liveness restore ownership retroactively, which
is precisely the revival AC-N5 forbids. Such a session must go through the
sanctioned reclaim path, which mints a fresh generation.
"""
current = _lease_now(now)
root = _ensure_lock_dir(lock_dir)
path = lock_file_path(
remote=remote, org=org, repo=repo, issue_number=issue_number, lock_dir=root
)
declared_session = str(task_session_id or "").strip()
if not declared_session:
return _refusal(["no task_session_id supplied (fail closed)"])
sentinel = flock_path(path)
try:
with _exclusive_file_lock(sentinel):
lock = read_lock_file(path)
if not lock:
return _refusal([f"no durable lock for issue #{issue_number}"])
if is_legacy_lease(lock):
return _refusal(
[
"lock predates the heartbeat lifecycle; it must be rebound "
"by its exact owner before it can be heartbeated"
],
lifecycle=lease_lifecycle_version(lock),
legacy_lease=True,
)
reasons = _ownership_refusals(
lock,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
identity=identity,
profile=profile,
)
recorded_session = lease_task_session_id(lock)
if not recorded_session:
reasons.append(
"lock declares the heartbeat lifecycle but records no "
"task_session_id (fail closed)"
)
elif recorded_session != declared_session:
# A superseded session holding an old identifier cannot heartbeat
# over the session that replaced it.
reasons.append(
"task_session_id does not match the session recorded on the lock"
)
if reasons:
return _refusal(reasons)
current_generation = lock_generation(lock)
if (
expected_generation is not None
and current_generation != expected_generation
):
return _refusal(
[
f"lock generation changed: expected {expected_generation}, "
f"found {current_generation}; another session reclaimed or "
"replaced this claim (fail closed)"
],
lock_generation=current_generation,
)
freshness = assess_lock_freshness(lock, now=current)
if not freshness.get("live"):
return _refusal(
[
f"lease is not live ({freshness.get('status')}): "
f"{freshness.get('reason')}; a lapsed lease must be "
"reclaimed, not heartbeated"
],
freshness=freshness,
)
policy = lease_policy.policy_for(lease_task_class(lock))
expires = current + timedelta(minutes=policy.initial_ttl_minutes)
record = dict(lock)
lease = dict(record.get("work_lease") or {})
prior_heartbeat = lease.get("last_heartbeat_at")
lease["last_heartbeat_at"] = _format_lease_timestamp(current)
lease["expires_at"] = _format_lease_timestamp(expires)
try:
lease["heartbeat_count"] = int(lease.get("heartbeat_count") or 0) + 1
except (TypeError, ValueError):
lease["heartbeat_count"] = 1
record["work_lease"] = lease
record["lock_generation"] = current_generation + 1
save_lock_file(path, record)
except LockContentionError as exc:
return _refusal([f"issue #{issue_number} lock contention: {exc} (fail closed)"])
return {
"success": True,
"performed": True,
"issue_number": issue_number,
"branch_name": branch_name,
"worktree_path": worktree_path,
"task_session_id": declared_session,
"lock_generation": record["lock_generation"],
"prior_generation": current_generation,
"prior_heartbeat_at": prior_heartbeat,
"last_heartbeat_at": lease["last_heartbeat_at"],
"expires_at": lease["expires_at"],
"heartbeat_count": lease["heartbeat_count"],
"lock_file_path": path,
"policy": lease_policy.describe(lease_task_class(record)),
"freshness": assess_lock_freshness(record, now=current),
}
def rebind_legacy_lock(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
branch_name: str,
worktree_path: str,
identity: str | None,
profile: str | None,
expected_generation: int | None = None,
lock_dir: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Move a legacy lock into the heartbeat lifecycle (#790 AC-N8).
One of the two sanctioned exits from the preserved-expiry legacy state; the
other is terminal retirement, which is Slice B. Only the exact recorded
owner may rebind, and only while the legacy lock is still live under its
original absolute expiry — an already-expired legacy lease belongs to the
#760 renewal path or #601 reclaim, and this must not become a second, weaker
way to revive one.
The rebind mints a genuine task-session identifier and a genuine first
heartbeat. It does not fabricate history: the original creation and expiry
are preserved under ``legacy_origin`` for audit, and the new lifecycle's
absolute cap runs from the rebind, not from the legacy claim.
"""
current = _lease_now(now)
root = _ensure_lock_dir(lock_dir)
path = lock_file_path(
remote=remote, org=org, repo=repo, issue_number=issue_number, lock_dir=root
)
sentinel = flock_path(path)
try:
with _exclusive_file_lock(sentinel):
lock = read_lock_file(path)
if not lock:
return _refusal([f"no durable lock for issue #{issue_number}"])
if not is_legacy_lease(lock):
return _refusal(
[
"lock is already on the heartbeat lifecycle; use the "
"heartbeat path"
],
lifecycle=lease_lifecycle_version(lock),
legacy_lease=False,
)
reasons = _ownership_refusals(
lock,
issue_number=issue_number,
branch_name=branch_name,
worktree_path=worktree_path,
identity=identity,
profile=profile,
)
if reasons:
return _refusal(reasons)
current_generation = lock_generation(lock)
if (
expected_generation is not None
and current_generation != expected_generation
):
return _refusal(
[
f"lock generation changed: expected {expected_generation}, "
f"found {current_generation} (fail closed)"
],
lock_generation=current_generation,
)
freshness = assess_lock_freshness(lock, now=current)
if not freshness.get("live"):
return _refusal(
[
f"legacy lease is not live ({freshness.get('status')}): "
f"{freshness.get('reason')}; rebinding is not a recovery "
"path for a lapsed lease"
],
freshness=freshness,
)
policy = lease_policy.policy_for(lease_task_class(lock))
expires = current + timedelta(minutes=policy.initial_ttl_minutes)
session_id = mint_task_session_id(lease_task_class(lock))
record = dict(lock)
lease = dict(record.get("work_lease") or {})
legacy_origin = {
"created_at": lease.get("created_at"),
"expires_at": lease.get("expires_at"),
"last_heartbeat_at": lease.get("last_heartbeat_at"),
"lifecycle": lease_policy.LIFECYCLE_LEGACY,
}
lease["lifecycle_version"] = lease_policy.LIFECYCLE_HEARTBEAT_V1
lease["task_session_id"] = session_id
lease["created_at"] = _format_lease_timestamp(current)
lease["last_heartbeat_at"] = _format_lease_timestamp(current)
lease["expires_at"] = _format_lease_timestamp(expires)
lease["heartbeat_count"] = 1
record["work_lease"] = lease
record["legacy_rebind"] = {
"rebound_at": _format_lease_timestamp(current),
"task_session_id": session_id,
"prior_generation": current_generation,
"legacy_origin": legacy_origin,
"reason": (
"legacy lock rebound into the heartbeat lifecycle by its exact "
"recorded owner"
),
}
record["lock_generation"] = current_generation + 1
save_lock_file(path, record)
except LockContentionError as exc:
return _refusal([f"issue #{issue_number} lock contention: {exc} (fail closed)"])
return {
"success": True,
"performed": True,
"issue_number": issue_number,
"task_session_id": session_id,
"lock_generation": record["lock_generation"],
"prior_generation": current_generation,
"lifecycle": lease_policy.LIFECYCLE_HEARTBEAT_V1,
"legacy_rebind": record["legacy_rebind"],
"expires_at": lease["expires_at"],
"last_heartbeat_at": lease["last_heartbeat_at"],
"lock_file_path": path,
"freshness": assess_lock_freshness(record, now=current),
}
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None: def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
root = (lock_dir or default_lock_dir()).strip() root = (lock_dir or default_lock_dir()).strip()
pointer = read_lock_file(session_pointer_path(root)) pointer = read_lock_file(session_pointer_path(root))
@@ -336,6 +673,16 @@ def _parse_lease_timestamp(value: str | None) -> datetime | None:
return None return None
def _format_lease_timestamp(value: datetime) -> str:
"""Serialize a lease timestamp in the durable ``...Z`` form already on disk."""
return (
value.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None: def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
if not lock: if not lock:
return None return None
@@ -356,60 +703,216 @@ def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -
return assess_lock_freshness(lock, now=now)["live"] return assess_lock_freshness(lock, now=now)["live"]
def lease_task_class(lock_data: dict[str, Any] | None) -> str:
"""Policy task class for a durable lock; author work when unrecorded."""
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
if isinstance(lease, dict):
recorded = str(lease.get("operation_type") or "").strip()
if recorded:
return recorded
return AUTHOR_ISSUE_WORK_LEASE
def lease_lifecycle_version(lock_data: dict[str, Any] | None) -> str:
"""Read the durable lifecycle marker (#790 AC-N8).
The marker is the *only* discriminator between a heartbeat-lifecycle lease
and a legacy one. Timestamps are deliberately not consulted: a lock minted
before this lifecycle existed has ``last_heartbeat_at == created_at``
forever, and reading that equality as "recently heartbeated" would treat
every never-heartbeated legacy lock as fresh — the precise inversion AC-N8
forbids. A newly minted heartbeat lease also has the two equal, so the
equality carries no information in either direction.
"""
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
if isinstance(lease, dict):
recorded = str(lease.get("lifecycle_version") or "").strip()
if recorded:
return recorded
return lease_policy.LIFECYCLE_LEGACY
def is_legacy_lease(lock_data: dict[str, Any] | None) -> bool:
"""True when a lock predates the shared heartbeat lifecycle."""
return lease_lifecycle_version(lock_data) != lease_policy.LIFECYCLE_HEARTBEAT_V1
def lease_task_session_id(lock_data: dict[str, Any] | None) -> str:
"""Recorded per-task session identifier, or empty for a legacy lock."""
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
if isinstance(lease, dict):
return str(lease.get("task_session_id") or "").strip()
return ""
def mint_task_session_id(task_class: str = AUTHOR_ISSUE_WORK_LEASE) -> str:
"""Mint an ownership key for one task (#790 AC-N1).
Deliberately contains no process identifier. The recorded PID belongs to the
long-lived MCP daemon, which outlives any individual task and is reused by
every task it serves, so PID digits cannot identify *which* task holds a
claim. The PID is still recorded alongside this value as evidence.
"""
prefix = _sanitize_segment(str(task_class or AUTHOR_ISSUE_WORK_LEASE))
return f"{prefix}-{uuid.uuid4().hex[:16]}"
def _lease_heartbeat_at(lock_data: dict[str, Any] | None) -> datetime | None:
lease = lock_data.get("work_lease") if isinstance(lock_data, dict) else None
heartbeat_at = None
if isinstance(lock_data, dict):
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
if heartbeat_at is None and isinstance(lease, dict):
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
return heartbeat_at
def assess_lock_freshness( def assess_lock_freshness(
lock_data: dict[str, Any] | None, lock_data: dict[str, Any] | None,
*, *,
now: datetime | None = None, now: datetime | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Classify a lock as live, expired, stale, or absent.""" """Classify a lock as live, expired, stale, or absent.
#790 Slice A makes the heartbeat load-bearing. Before this change
``last_heartbeat_at`` was parsed and then never consulted: liveness was
decided entirely by the absolute ``expires_at`` and by PID liveness, and
since the recorded PID is the long-lived MCP daemon, an abandoned author
task stayed "live" for the full four-hour TTL.
Two rules govern the rewrite:
* **An alive PID never establishes freshness** (AC-N2). It proves the daemon
is up, nothing about the task. It is recorded as evidence and no branch
returns ``live`` because of it.
* **A dead PID still corroborates staleness.** The dead-PID band is
unchanged and still precedes every heartbeat evaluation, so #753
dead-session recovery keys on exactly the classification it always did.
Legacy leases (AC-N8) keep their recorded absolute expiry and are never
evaluated against the short heartbeat grace, so deploying this change cannot
make an existing claim instantly reclaimable.
"""
current = _lease_now(now) current = _lease_now(now)
if not lock_data: if not lock_data:
return { return {
"status": "absent", "status": STATUS_ABSENT,
"live": False, "live": False,
"stale": False, "stale": False,
"reason": "no lock record", "reason": "no lock record",
} }
expires_at = lease_expires_at(lock_data)
lease = lock_data.get("work_lease") lease = lock_data.get("work_lease")
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at")) expires_at = lease_expires_at(lock_data)
if heartbeat_at is None and isinstance(lease, dict): heartbeat_at = _lease_heartbeat_at(lock_data)
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at")) created_at = (
_parse_lease_timestamp(lease.get("created_at"))
if isinstance(lease, dict)
else None
)
pid = lock_data.get("session_pid") pid = lock_data.get("session_pid")
if pid is None: if pid is None:
pid = lock_data.get("pid") pid = lock_data.get("pid")
# Evidence only. Never consulted to grant liveness (AC-N2).
pid_alive = is_process_alive(pid) if pid is not None else False pid_alive = is_process_alive(pid) if pid is not None else False
if expires_at and expires_at <= current: lifecycle = lease_lifecycle_version(lock_data)
return { legacy = lifecycle != lease_policy.LIFECYCLE_HEARTBEAT_V1
"status": "expired", policy = lease_policy.policy_for(lease_task_class(lock_data))
"live": False,
"stale": True,
"reason": f"lease expired at {expires_at.isoformat()}",
"pid_alive": pid_alive,
}
if pid is not None and not pid_alive: evidence: dict[str, Any] = {
return {
"status": "stale",
"live": False,
"stale": True,
"reason": f"owner pid {pid} is not alive",
"pid_alive": False,
}
return {
"status": "live",
"live": True,
"stale": False,
"reason": "lock heartbeat and lease are fresh",
"pid_alive": pid_alive, "pid_alive": pid_alive,
"lifecycle": lifecycle,
"legacy_lease": legacy,
"task_session_id": lease_task_session_id(lock_data) or None,
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None, "heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
"expires_at": expires_at.isoformat() if expires_at else None, "expires_at": expires_at.isoformat() if expires_at else None,
} }
def _result(status: str, *, live: bool, reason: str, **extra: Any) -> dict[str, Any]:
return {
"status": status,
"live": live,
"stale": not live and status != STATUS_ABSENT,
"reason": reason,
**evidence,
**extra,
}
if legacy:
# AC-N8: the preserved absolute expiry is the only clock for a lock
# written before task-session heartbeats existed.
if expires_at and expires_at <= current:
return _result(
STATUS_EXPIRED,
live=False,
reason=f"lease expired at {expires_at.isoformat()}",
)
if pid is not None and not pid_alive:
return _result(
STATUS_STALE, live=False, reason=f"owner pid {pid} is not alive"
)
return _result(
STATUS_LIVE,
live=True,
reason=(
"legacy lease is within its recorded absolute expiry; the "
"heartbeat grace does not apply retroactively"
),
legacy_expiry_preserved=True,
)
# ── Heartbeat lifecycle ──
if pid is not None and not pid_alive:
# Unchanged dead-PID band: #753 recovery depends on this exact status.
return _result(STATUS_STALE, live=False, reason=f"owner pid {pid} is not alive")
if heartbeat_at is None:
# Contradictory: a heartbeat lease must carry a heartbeat. Fail closed.
return _result(
STATUS_STALE_MISSED_HEARTBEAT,
live=False,
reason=(
f"lease declares lifecycle '{lifecycle}' but records no "
"last_heartbeat_at (fail closed)"
),
)
if policy.absolute_cap_hours and created_at is not None:
cap_at = created_at + timedelta(hours=policy.absolute_cap_hours)
if cap_at <= current:
return _result(
STATUS_STALE_ABSOLUTE_CAP,
live=False,
reason=(
f"lease exceeded its {policy.absolute_cap_hours}h absolute cap "
f"at {cap_at.isoformat()}; canonical re-adoption is required"
),
absolute_cap_at=cap_at.isoformat(),
)
grace_at = heartbeat_at + timedelta(minutes=policy.missed_heartbeat_grace_minutes)
if grace_at <= current or (expires_at is not None and expires_at <= current):
return _result(
STATUS_STALE_MISSED_HEARTBEAT,
live=False,
reason=(
f"no valid heartbeat since {heartbeat_at.isoformat()}; the "
f"{policy.missed_heartbeat_grace_minutes}min grace lapsed at "
f"{grace_at.isoformat()}"
),
missed_heartbeat_since=grace_at.isoformat(),
)
warning_at = heartbeat_at + timedelta(minutes=policy.stale_warning_minutes)
return _result(
STATUS_LIVE,
live=True,
reason="lease heartbeat is fresh within the configured grace",
heartbeat_warning=warning_at <= current,
)
def _same_realpath(left: str | None, right: str | None) -> bool: def _same_realpath(left: str | None, right: str | None) -> bool:
if not left or not right: if not left or not right:
@@ -446,6 +949,27 @@ def assess_expired_lock_reclaim(
"reasons": ["lock is still live; cannot reclaim (fail closed)"], "reasons": ["lock is still live; cannot reclaim (fail closed)"],
"freshness": freshness, "freshness": freshness,
} }
status = str(freshness.get("status") or "")
if status in (STATUS_STALE_MISSED_HEARTBEAT, STATUS_STALE_ABSOLUTE_CAP):
# #790: under the heartbeat lifecycle the heartbeat *is* the liveness
# proof, so a session that stopped heartbeating past its grace has
# released its claim by definition. Requiring a dead PID on top of that
# would reinstate the original defect — the recorded PID is the shared
# daemon, which stays alive across every abandoned task it ever served.
#
# This band is unreachable for a legacy lease (AC-N8), so no lock
# written before this lifecycle can be reclaimed by this path.
return {
"reclaim_allowed": True,
"reasons": [
f"heartbeat-lifecycle lease is {status}: {freshness.get('reason')}"
],
"freshness": freshness,
"prior_branch": existing_lock.get("branch_name"),
"prior_worktree": existing_lock.get("worktree_path"),
"prior_pid": existing_lock.get("session_pid") or existing_lock.get("pid"),
"prior_task_session_id": lease_task_session_id(existing_lock) or None,
}
pid = existing_lock.get("session_pid") pid = existing_lock.get("session_pid")
if pid is None: if pid is None:
pid = existing_lock.get("pid") pid = existing_lock.get("pid")
+212
View File
@@ -0,0 +1,212 @@
"""Central lease policy configuration (#790 Slice A, AC-N7).
The single authoritative source for every lease duration in the project. Before
this module the numbers were scattered: a four-hour author TTL was declared
twice (``issue_lock_store`` and ``gitea_mcp_server``), the reviewer/merger
sliding window lived in ``reviewer_pr_lease``, the conflict-fix window in
``pr_work_lease``, and the control-plane default in ``control_plane_db``.
Nothing tied them together, so tuning one class silently diverged from the
others and no reader could answer "how long does a lease live?" without
grepping four files.
AC-N7 requires that this configuration exist *before* the first heartbeat and
TTL behavior that reads from it, so it ships in Slice A rather than trailing the
code it governs.
Deliberate boundaries:
* **Declaration is not rewiring.** Every task class is declared here, but only
those with ``heartbeat_lifecycle_active`` were migrated onto the shared
heartbeat lifecycle in Slice A — currently ``author_issue_work`` alone.
Reviewer, merger, and conflict-fix leases keep their own existing behavior
until Slice C moves them; their numbers are recorded here so the two cannot
drift apart unnoticed, and ``tests/test_issue_790_lease_policy.py`` asserts
the recorded values still equal the constants those modules use.
* **No policy decision lives here.** This module answers "how long", never "may
this session proceed". Freshness, reclaim, and renewal dispositions stay in
``issue_lock_store``.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
# Task classes. Only the first is migrated onto the shared lifecycle in Slice A.
TASK_CLASS_AUTHOR_ISSUE_WORK = "author_issue_work"
TASK_CLASS_REVIEWER_PR = "reviewer_pr"
TASK_CLASS_MERGER_PR = "merger_pr"
TASK_CLASS_CONFLICT_FIX = "conflict_fix"
# Durable marker for a lease minted under the shared heartbeat lifecycle.
#
# #790 AC-N8: this explicit marker — never a timestamp comparison — is what
# distinguishes a heartbeat-lifecycle lease from a legacy one. A lock written
# before this lifecycle existed carries no marker and reads as
# ``LIFECYCLE_LEGACY``.
LIFECYCLE_HEARTBEAT_V1 = "heartbeat-v1"
LIFECYCLE_LEGACY = "legacy"
_ENV_PREFIX = "GITEA_LEASE_POLICY"
@dataclass(frozen=True)
class LeasePolicy:
"""Durations governing one task class.
All intervals are minutes except ``absolute_cap_hours``. ``None`` for the
cap means the class has no maximum continuous duration.
"""
task_class: str
initial_ttl_minutes: float
heartbeat_cadence_minutes: float
stale_warning_minutes: float
missed_heartbeat_grace_minutes: float
absolute_cap_hours: float | None
recovery_grace_minutes: float
terminal_race_drain_minutes: float
terminal_retirement_eligible: bool
heartbeat_lifecycle_active: bool
# Defaults. ``author_issue_work`` adopts the reviewer window proven by #747
# rather than inventing new numbers: a lease expires 10 minutes after its last
# valid heartbeat, warns at half that, and an actively heartbeating session is
# never evicted. The prior value was a fixed four hours (240 minutes) that no
# heartbeat could shorten — the defect this issue exists to correct.
_DEFAULTS: dict[str, LeasePolicy] = {
TASK_CLASS_AUTHOR_ISSUE_WORK: LeasePolicy(
task_class=TASK_CLASS_AUTHOR_ISSUE_WORK,
initial_ttl_minutes=10.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=8.0,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=True,
heartbeat_lifecycle_active=True,
),
# Declared, not rewired. These mirror reviewer_pr_lease.LEASE_TTL_MINUTES
# and STALE_WARNING_MINUTES; Slice C migrates the call sites.
TASK_CLASS_REVIEWER_PR: LeasePolicy(
task_class=TASK_CLASS_REVIEWER_PR,
initial_ttl_minutes=10.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=None,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=False,
heartbeat_lifecycle_active=False,
),
TASK_CLASS_MERGER_PR: LeasePolicy(
task_class=TASK_CLASS_MERGER_PR,
initial_ttl_minutes=10.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=None,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=False,
heartbeat_lifecycle_active=False,
),
# Mirrors pr_work_lease.DEFAULT_CONFLICT_FIX_TTL_MINUTES. Deliberately left
# at its current window; shortening it is Slice C's call, not this slice's.
TASK_CLASS_CONFLICT_FIX: LeasePolicy(
task_class=TASK_CLASS_CONFLICT_FIX,
initial_ttl_minutes=120.0,
heartbeat_cadence_minutes=2.0,
stale_warning_minutes=5.0,
missed_heartbeat_grace_minutes=10.0,
absolute_cap_hours=None,
recovery_grace_minutes=10.0,
terminal_race_drain_minutes=2.0,
terminal_retirement_eligible=False,
heartbeat_lifecycle_active=False,
),
}
_NUMERIC_FIELDS = (
"initial_ttl_minutes",
"heartbeat_cadence_minutes",
"stale_warning_minutes",
"missed_heartbeat_grace_minutes",
"absolute_cap_hours",
"recovery_grace_minutes",
"terminal_race_drain_minutes",
)
def env_var_name(task_class: str, field: str) -> str:
"""Environment variable that overrides one field of one task class."""
return f"{_ENV_PREFIX}_{task_class.upper()}_{field.upper()}"
def _override(task_class: str, field: str, default: float | None) -> float | None:
"""Read one override, falling back to *default* on anything unusable.
A malformed or non-positive override is ignored rather than raised: a typo
in an environment variable must not be able to mint a zero-length lease that
makes every claim instantly reclaimable, nor crash the server at import.
"""
raw = (os.environ.get(env_var_name(task_class, field)) or "").strip()
if not raw:
return default
try:
value = float(raw)
except (TypeError, ValueError):
return default
if value <= 0:
return default
return value
def policy_for(task_class: str) -> LeasePolicy:
"""Return the effective policy for *task_class*.
Unknown task classes fall back to the author policy, which is the most
conservative migrated class, rather than raising — a new caller must never
be able to crash a lock write by naming a class this table has not learned.
"""
key = str(task_class or "").strip() or TASK_CLASS_AUTHOR_ISSUE_WORK
base = _DEFAULTS.get(key) or _DEFAULTS[TASK_CLASS_AUTHOR_ISSUE_WORK]
resolved = {
field: _override(base.task_class, field, getattr(base, field))
for field in _NUMERIC_FIELDS
}
if all(resolved[field] == getattr(base, field) for field in _NUMERIC_FIELDS):
return base
return LeasePolicy(
task_class=base.task_class,
terminal_retirement_eligible=base.terminal_retirement_eligible,
heartbeat_lifecycle_active=base.heartbeat_lifecycle_active,
**resolved,
)
def known_task_classes() -> tuple[str, ...]:
"""Every declared task class, migrated or not."""
return tuple(_DEFAULTS)
def describe(task_class: str) -> dict[str, Any]:
"""Serializable view of a policy, for audit records and tool payloads."""
policy = policy_for(task_class)
return {
"task_class": policy.task_class,
"initial_ttl_minutes": policy.initial_ttl_minutes,
"heartbeat_cadence_minutes": policy.heartbeat_cadence_minutes,
"stale_warning_minutes": policy.stale_warning_minutes,
"missed_heartbeat_grace_minutes": policy.missed_heartbeat_grace_minutes,
"absolute_cap_hours": policy.absolute_cap_hours,
"recovery_grace_minutes": policy.recovery_grace_minutes,
"terminal_race_drain_minutes": policy.terminal_race_drain_minutes,
"terminal_retirement_eligible": policy.terminal_retirement_eligible,
"heartbeat_lifecycle_active": policy.heartbeat_lifecycle_active,
"lifecycle_version": LIFECYCLE_HEARTBEAT_V1,
}
+19 -5
View File
@@ -24,7 +24,12 @@ ROLE_WORKTREE_ENVS: dict[str, str] = {
"reconciler": RECONCILER_WORKTREE_ENV, "reconciler": RECONCILER_WORKTREE_ENV,
} }
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"}) # Controller has no task worktree env — it routes only (#840).
KNOWN_ROLE_KINDS = frozenset(
{"author", "reviewer", "merger", "reconciler", "controller"}
)
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler", "controller"})
def normalize_role_kind( def normalize_role_kind(
@@ -37,8 +42,12 @@ def normalize_role_kind(
profile = (profile_name or "").strip().lower() profile = (profile_name or "").strip().lower()
if role == "reviewer" and "merger" in profile: if role == "reviewer" and "merger" in profile:
return "merger" return "merger"
if "controller" in profile or role == "controller":
return "controller"
if role in ROLE_WORKTREE_ENVS: if role in ROLE_WORKTREE_ENVS:
return role return role
if role in KNOWN_ROLE_KINDS:
return role
return "author" return "author"
@@ -80,7 +89,7 @@ def resolve_namespace_workspace(
""" """
env_map = env if env is not None else os.environ env_map = env if env is not None else os.environ
role = normalize_role_kind(role_kind, profile_name=profile_name) role = normalize_role_kind(role_kind, profile_name=profile_name)
role_env_key = ROLE_WORKTREE_ENVS[role] role_env_key = ROLE_WORKTREE_ENVS.get(role)
# #618: durable author resolution — no silent control/master fallback. # #618: durable author resolution — no silent control/master fallback.
if role == "author" and verify_paths: if role == "author" and verify_paths:
@@ -108,13 +117,17 @@ def resolve_namespace_workspace(
) )
return workspace, source return workspace, source
role_env_candidate = (
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable", True)
if role_env_key
else (None, "no role worktree env", True)
)
for candidate, source, env_sourced in ( for candidate, source, env_sourced in (
(worktree_path, "worktree_path argument", False), (worktree_path, "worktree_path argument", False),
(worktree, "worktree argument", False), (worktree, "worktree argument", False),
(_env_value(env_map, ACTIVE_WORKTREE_ENV), (_env_value(env_map, ACTIVE_WORKTREE_ENV),
f"{ACTIVE_WORKTREE_ENV} environment variable", True), f"{ACTIVE_WORKTREE_ENV} environment variable", True),
(_env_value(env_map, role_env_key), role_env_candidate,
f"{role_env_key} environment variable", True),
(session_lease_worktree if role in {"reviewer", "merger"} else None, (session_lease_worktree if role in {"reviewer", "merger"} else None,
"reviewer PR lease worktree", False), "reviewer PR lease worktree", False),
# Author lock derivation is handled by the durable path above when # Author lock derivation is handled by the durable path above when
@@ -433,7 +446,8 @@ def assess_namespace_mutation_workspace(
reasons.append( reasons.append(
f"{role} mutation blocked: workspace is the stable control checkout; " f"{role} mutation blocked: workspace is the stable control checkout; "
f"create or reconnect to a session-owned worktree under branches/ " f"create or reconnect to a session-owned worktree under branches/ "
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}" f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
f"{ACTIVE_WORKTREE_ENV}"
) )
elif ( elif (
role in {"reviewer", "merger"} role in {"reviewer", "merger"}
+35
View File
@@ -81,6 +81,12 @@ AUTHOR_TASKS = frozenset({
"reconcile_landed_pr", "reconcile_landed_pr",
}) })
CONTROLLER_TASKS = frozenset({
"process_work_queue",
"process-work-queue",
"cross_role_allocate",
})
RECONCILER_TASKS = frozenset({ RECONCILER_TASKS = frozenset({
"cleanup_merged_pr_branch", "cleanup_merged_pr_branch",
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted # #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
@@ -132,6 +138,10 @@ TASK_REQUIRED_ROLE = {
"reconcile_close_superseded_pr": "reconciler", "reconcile_close_superseded_pr": "reconciler",
"reconcile_close_satisfied_issue": "reconciler", "reconcile_close_satisfied_issue": "reconciler",
"reconcile_create_followup_issue": "reconciler", "reconcile_create_followup_issue": "reconciler",
# #840: controller-owned generic queue allocation / routing.
"process_work_queue": "controller",
"process-work-queue": "controller",
"cross_role_allocate": "controller",
} }
WRONG_ROLE_REVIEWER_MSG = ( WRONG_ROLE_REVIEWER_MSG = (
@@ -147,6 +157,10 @@ WRONG_ROLE_MERGER_MSG = (
"Wrong role/session for merger task. Launch merger MCP namespace." "Wrong role/session for merger task. Launch merger MCP namespace."
) )
WRONG_ROLE_CONTROLLER_MSG = (
"Wrong role/session for controller task. Launch controller MCP namespace."
)
_session_last_route: dict | None = None _session_last_route: dict | None = None
@@ -281,6 +295,27 @@ def route_task_session(
_record_route(result) _record_route(result)
return result return result
if required_role == "controller":
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_WRONG_ROLE,
"downstream_allowed": False,
"reasons": [
WRONG_ROLE_CONTROLLER_MSG,
"Controller tasks (process_work_queue / cross-role allocate) "
"cannot run in author, reviewer, merger, or reconciler "
"worker sessions.",
],
"message": WRONG_ROLE_CONTROLLER_MSG,
"runtime_switching_supported": runtime_switching_supported,
"profile_switch_blocked": not runtime_switching_supported,
}
_record_route(result)
return result
if required_role == "author": if required_role == "author":
route = ROUTE_TO_AUTHOR route = ROUTE_TO_AUTHOR
message = ( message = (
+35 -2
View File
@@ -32,6 +32,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment", "permission": "gitea.issue.comment",
"role": "author", "role": "author",
}, },
# #790 Slice A: prove an owned author lease is still active. Strictly
# narrower than lock_issue — it can only slide a lease this exact session
# already owns, never acquire, take over, or revive one — so it gates on the
# same authority rather than introducing an operation name that every
# already-configured author profile would be missing.
"heartbeat_issue_lock": {
"permission": "gitea.issue.comment",
"role": "author",
},
"set_issue_labels": { "set_issue_labels": {
"permission": "gitea.issue.comment", "permission": "gitea.issue.comment",
"role": "author", "role": "author",
@@ -62,6 +71,14 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.push", "permission": "gitea.branch.push",
"role": "author", "role": "author",
}, },
# #812 AC20: publish an already-committed, unpublished local head so
# exact-owner lease renewal has an observable remote head to reason about.
# Same authority as any other author push — deliberately not a new
# operation name, so it cannot widen an already-configured author profile.
"publish_unpublished_branch": {
"permission": "gitea.branch.push",
"role": "author",
},
"create_pr": { "create_pr": {
"permission": "gitea.pr.create", "permission": "gitea.pr.create",
"role": "author", "role": "author",
@@ -301,8 +318,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.create", "permission": "gitea.pr.create",
"role": "author", "role": "author",
}, },
# #600: controller-owned allocator — any authenticated profile may call; # #600: workers and controller may call with gitea.read; role-scoped workers
# routing enforces role match to selected work. Uses control-plane DB (#613). # pass role=author|reviewer|merger|reconciler. Cross-role routing is the
# controller default (#840). The canonical generic queue *task type* is
# process_work_queue (controller-only below).
"allocate_next_work": { "allocate_next_work": {
"permission": "gitea.read", "permission": "gitea.read",
"role": "author", "role": "author",
@@ -311,6 +330,19 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.read", "permission": "gitea.read",
"role": "author", "role": "author",
}, },
# #840: documented generic queue task — controller routes only.
"process_work_queue": {
"permission": "gitea.read",
"role": "controller",
},
"process-work-queue": {
"permission": "gitea.read",
"role": "controller",
},
"cross_role_allocate": {
"permission": "gitea.read",
"role": "controller",
},
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on # #601 first-class lease lifecycle — inspect/list need read; mutations gate on
# ownership in the control-plane DB (not a separate Gitea write permission). # ownership in the control-plane DB (not a separate Gitea write permission).
@@ -500,6 +532,7 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
"gitea_release_merger_pr_lease", "gitea_release_merger_pr_lease",
"create_branch", "create_branch",
"push_branch", "push_branch",
"publish_unpublished_branch",
"create_pr", "create_pr",
"commit_files", "commit_files",
"gitea_commit_files", "gitea_commit_files",
+572
View File
@@ -0,0 +1,572 @@
"""Executable acceptance tests for ARCH-01 Slice A (#822).
Each acceptance criterion (#822 §12) and named test (#822 §13) is exercised
against a real SQLite database. The migration runs on a fresh DB in ``setUp``;
the test-run output is the durable evidence the issue requires (§14).
Enforcement being proven:
* ``[TRUSTED-SERVICE]`` — the ``cp_*`` actor functions exist only on the
trusted kernel connection; a raw connection cannot satisfy the triggers.
* ``[SCHEMA]`` — fail-closed aborts, exact dominance set, NOT-NULL class,
immutability, and the last-active-grant floor are enforced by
CHECK/FK/trigger, verified here including raw-write bypass and concurrency.
"""
from __future__ import annotations
import os
import sqlite3
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
import arch01_platform as ap
from arch01_platform import (
ALREADY_INSTALLED,
AUTHORIZATION_DENIED,
CONCURRENT_INSTALLATION_LOST,
DISTINGUISHED_ISSUER_ID,
DOMINANCE_SET_MISMATCH,
DOMINANCE_TUPLES,
INSTALLED,
INVALID_ACTOR_CONTEXT,
INVALID_BOOTSTRAP_STATE,
PlatformKernel,
)
INSTALLER = "platform.installer"
_BOOTSTRAP_TABLES = (
"principal_equivalence_classes",
"principals",
"authoritative_issuers",
"authority_dominance",
"platform_bootstrap_seed",
"platform_bootstrap_grants",
"platform_active_invariant",
"install_state",
)
def _count(kernel: PlatformKernel, table: str) -> int:
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
def _count_where(kernel: PlatformKernel, table: str, where: str) -> int:
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}").fetchone()[0]
def _all_bootstrap_empty(kernel: PlatformKernel) -> bool:
return all(_count(kernel, t) == 0 for t in _BOOTSTRAP_TABLES)
class Arch01MemoryTest(unittest.TestCase):
"""Single-connection behavior on an in-memory database."""
def setUp(self) -> None:
self.kernel = PlatformKernel(":memory:")
def tearDown(self) -> None:
self.kernel.close()
# -- AC1 -------------------------------------------------------------- #
def test_install_clean(self) -> None: # t_install_clean(+)
res = self.kernel.install_platform(INSTALLER)
self.assertEqual(res.code, INSTALLED)
self.assertTrue(self.kernel.is_installed())
self.assertEqual(_count(self.kernel, "install_state"), 1)
self.assertEqual(self.kernel.active_grant_count(), 1)
self.assertIn(ap.EVT_PLATFORM_INSTALLED, self.kernel.audit_events())
rows = set(
self.kernel._conn.execute(
"SELECT dominant, subordinate FROM authority_dominance"
).fetchall()
)
self.assertEqual(rows, set(DOMINANCE_TUPLES))
issuer_ref = self.kernel._conn.execute(
"SELECT i.issuer_ref FROM principals p JOIN authoritative_issuers i "
"ON p.issuer_id = i.issuer_id WHERE p.principal_id = ?",
(INSTALLER,),
).fetchone()
self.assertEqual(issuer_ref[0], DISTINGUISHED_ISSUER_ID)
# -- AC2 -------------------------------------------------------------- #
def test_install_twice(self) -> None: # t_install_twice(-)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
res2 = self.kernel.install_platform(INSTALLER)
self.assertEqual(res2.code, ALREADY_INSTALLED)
self.assertEqual(_count(self.kernel, "principals"), 1)
self.assertEqual(_count(self.kernel, "platform_bootstrap_grants"), 1)
self.assertEqual(_count(self.kernel, "install_state"), 1)
# -- AC3 / AC5 -------------------------------------------------------- #
def test_install_stage_rollback(self) -> None: # t_install_stage_rollback
for stop in range(1, 9):
with self.subTest(stages=stop):
k = PlatformKernel(":memory:")
try:
self._partial_bootstrap_then_rollback(k, stop)
self.assertTrue(
_all_bootstrap_empty(k),
f"partial rows survived rollback at stage {stop}",
)
self.assertFalse(k.is_installed())
finally:
k.close()
def test_no_partial_after_rollback(self) -> None: # t_no_partial_after_rollback
k = PlatformKernel(":memory:")
try:
code = self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1])
self.assertEqual(code, DOMINANCE_SET_MISMATCH)
self.assertTrue(_all_bootstrap_empty(k))
self.assertFalse(k.is_installed())
finally:
k.close()
# -- AC4 -------------------------------------------------------------- #
def test_dominance_missing(self) -> None: # t_dominance_missing(-)
k = PlatformKernel(":memory:")
try:
self.assertEqual(
self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1]),
DOMINANCE_SET_MISMATCH,
)
self.assertFalse(k.is_installed())
finally:
k.close()
def test_dominance_extra(self) -> None: # t_dominance_extra(-)
k = PlatformKernel(":memory:")
try:
extra = DOMINANCE_TUPLES + (("platform.bootstrap", "rogue.extra"),)
self.assertEqual(
self._seed_bootstrap_and_mark(k, dominance=extra),
DOMINANCE_SET_MISMATCH,
)
self.assertFalse(k.is_installed())
finally:
k.close()
def test_dominance_malformed(self) -> None: # t_dominance_malformed(-)
k = PlatformKernel(":memory:")
try:
malformed = DOMINANCE_TUPLES[:-1] + (("supervisor.root", "WRONG.subordinate"),)
self.assertEqual(
self._seed_bootstrap_and_mark(k, dominance=malformed),
DOMINANCE_SET_MISMATCH,
)
self.assertFalse(k.is_installed())
finally:
k.close()
# -- AC6 -------------------------------------------------------------- #
def test_principal_no_class(self) -> None: # t_principal_no_class(-)
with self.kernel.actor_context("op", "operator", "install"):
with self.assertRaises(sqlite3.IntegrityError):
self.kernel._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES ('x', 'operator', NULL, NULL, NULL, '2026-01-01T00:00:00Z')"
)
# -- AC7 -------------------------------------------------------------- #
def test_noninstaller_null_issuer(self) -> None: # t_nonobstaller_null_issuer(-)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
with self.kernel.actor_context("op", "operator", "normal"):
cur = self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
class_id = cur.lastrowid
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES ('rogue', 'operator', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
(class_id,),
)
self.assertIn("INVALID_BOOTSTRAP_STATE", str(ctx.exception))
def test_installer_null_issuer_only_during_install(self) -> None:
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
with self.kernel.actor_context("i2", "installer", "install"):
cur = self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
class_id = cur.lastrowid
with self.assertRaises(sqlite3.IntegrityError):
self.kernel._conn.execute(
"INSERT INTO principals"
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES ('i2', 'installer', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
(class_id,),
)
# -- AC8 -------------------------------------------------------------- #
def test_context_missing(self) -> None: # t_context_missing(-)
self.assertIsNone(self.kernel._ctx)
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
def test_context_stale(self) -> None: # t_context_stale(-)
with self.kernel.actor_context("op", "operator", "normal"):
self.kernel._ctx.expired = True
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
def test_context_epoch_shift(self) -> None: # t_context_epoch_shift(-)
with self.kernel.actor_context("op", "operator", "normal"):
self.kernel._ctx.live_epoch = self.kernel._ctx.bound_epoch + 99
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
)
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
def test_bad_actor_kind_or_mode_rejected(self) -> None:
for kind, mode in (("intruder", "normal"), ("operator", "sabotage")):
with self.subTest(kind=kind, mode=mode):
with self.kernel.actor_context("op", kind, mode):
with self.assertRaises(sqlite3.IntegrityError):
self.kernel._conn.execute(
"INSERT INTO principal_equivalence_classes(created_at) "
"VALUES ('2026-01-01T00:00:00Z')"
)
# -- AC9 -------------------------------------------------------------- #
def test_bootstrap_immutable_update(self) -> None: # t_bootstrap_immutable_{update}
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
cases = [
("UPDATE install_state SET installed_at = 'x' WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
("UPDATE platform_bootstrap_seed SET created_at = 'x' WHERE seed_id = 1", "IMMUTABLE_SEED"),
("UPDATE authority_dominance SET subordinate = 'x' WHERE dominant = 'supervisor.root'", "IMMUTABLE_DOMINANCE"),
(f"UPDATE authoritative_issuers SET issuer_ref = 'x' WHERE issuer_ref = '{DISTINGUISHED_ISSUER_ID}'", "IMMUTABLE_ISSUER"),
(f"UPDATE principals SET actor_kind = 'operator' WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
]
for sql, tag in cases:
with self.subTest(sql=sql):
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(sql)
self.assertIn(tag, str(ctx.exception))
def test_bootstrap_immutable_delete(self) -> None: # t_bootstrap_immutable_{delete}
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
cases = [
("DELETE FROM install_state WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
("DELETE FROM platform_bootstrap_seed WHERE seed_id = 1", "IMMUTABLE_SEED"),
("DELETE FROM authority_dominance", "IMMUTABLE_DOMINANCE"),
("DELETE FROM authoritative_issuers", "IMMUTABLE_ISSUER"),
(f"DELETE FROM principals WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
("DELETE FROM platform_bootstrap_grants", "IMMUTABLE_GRANT"),
]
for sql, tag in cases:
with self.subTest(sql=sql):
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(sql)
self.assertIn(tag, str(ctx.exception))
def test_grant_reactivation_rejected(self) -> None:
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
self.kernel.register_principal(
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
)
self.assertEqual(
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
)
gid = self.kernel._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
).fetchone()[0]
self.assertEqual(
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
INSTALLED,
)
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as ctx:
self.kernel._conn.execute(
"UPDATE platform_bootstrap_grants SET active = 1 WHERE grant_id = ?",
(gid,),
)
self.assertIn("IMMUTABLE_GRANT", str(ctx.exception))
# -- AC12 ------------------------------------------------------------- #
def test_raw_write_bypass(self) -> None: # t_raw_write_bypass(raw-bypass)
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "p.sqlite3")
k = PlatformKernel(path)
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
k.close()
raw = sqlite3.connect(path)
raw.execute("PRAGMA foreign_keys = ON")
try:
with self.assertRaises(sqlite3.Error):
raw.execute(
"INSERT INTO audit_records(event, created_at) "
"VALUES ('forged', '2026-01-01T00:00:00Z')"
)
raw.commit()
with self.assertRaises(sqlite3.Error):
raw.execute("UPDATE install_state SET installed_at = 'x' WHERE id = 1")
raw.commit()
with self.assertRaises(sqlite3.Error):
raw.execute("DELETE FROM platform_bootstrap_grants")
raw.commit()
finally:
raw.close()
# -- AC13 ------------------------------------------------------------- #
def test_audit_created(self) -> None: # t_audit_created(+)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
self.kernel.register_principal(
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
)
self.assertEqual(
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
)
gid = self.kernel._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
).fetchone()[0]
self.assertEqual(
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
INSTALLED,
)
events = self.kernel.audit_events()
for evt in (
ap.EVT_PLATFORM_INSTALLED,
ap.EVT_GRANT_CREATED,
ap.EVT_GRANT_REVOKED,
ap.EVT_PRINCIPAL_REGISTERED,
):
self.assertIn(evt, events)
# -- AC14 ------------------------------------------------------------- #
def test_audit_immutable(self) -> None: # t_audit_immutable(raw-bypass)
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
with self.kernel.actor_context("op", "operator", "normal"):
with self.assertRaises(sqlite3.IntegrityError) as up:
self.kernel._conn.execute("UPDATE audit_records SET event = 'x' WHERE audit_id = 1")
self.assertIn("IMMUTABLE_AUDIT", str(up.exception))
with self.assertRaises(sqlite3.IntegrityError) as dl:
self.kernel._conn.execute("DELETE FROM audit_records WHERE audit_id = 1")
self.assertIn("IMMUTABLE_AUDIT", str(dl.exception))
# -- meta ------------------------------------------------------------- #
def test_schema_meta(self) -> None:
rows = dict(self.kernel._conn.execute("SELECT key, value FROM arch01_meta").fetchall())
self.assertEqual(rows["schema_version"], str(ap.SCHEMA_VERSION))
self.assertIn("disabled by default", rows["architecture"])
def test_register_principal_creates_class_first(self) -> None:
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
res = self.kernel.register_principal(
"svc1", "service", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
)
self.assertEqual(res.code, INSTALLED)
row = self.kernel._conn.execute(
"SELECT current_class_id FROM principals WHERE principal_id = 'svc1'"
).fetchone()
self.assertIsNotNone(row[0])
# -- helpers ---------------------------------------------------------- #
def _partial_bootstrap_then_rollback(self, k: PlatformKernel, stop: int) -> None:
"""Execute the first ``stop`` bootstrap statements, then ROLLBACK."""
now = "2026-01-01T00:00:00Z"
k._conn.execute("BEGIN IMMEDIATE")
class_id = None
issuer_id = None
try:
with k.actor_context(INSTALLER, "installer", "install"):
c = k._conn
if stop >= 1:
class_id = c.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
).lastrowid
if stop >= 2:
c.execute(
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, 'installer', ?, NULL, ?, ?)",
(INSTALLER, class_id, INSTALLER, now),
)
if stop >= 3:
issuer_id = c.execute(
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
(DISTINGUISHED_ISSUER_ID, now),
).lastrowid
if stop >= 4:
c.execute(
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
(issuer_id, INSTALLER),
)
if stop >= 5:
c.executemany(
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
DOMINANCE_TUPLES,
)
if stop >= 6:
c.execute(
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
(INSTALLER, now),
)
if stop >= 7:
c.execute(
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
(INSTALLER, now),
)
if stop >= 8:
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
finally:
k._conn.execute("ROLLBACK")
def _seed_bootstrap_and_mark(self, k: PlatformKernel, dominance) -> str:
"""Seed a full bootstrap with a caller-supplied dominance set, then
attempt the marker insert. Returns the classified failure code (or
INSTALLED). Rolls back on failure so no partial rows remain."""
now = "2026-01-01T00:00:00Z"
k._conn.execute("BEGIN IMMEDIATE")
try:
with k.actor_context(INSTALLER, "installer", "install"):
c = k._conn
class_id = c.execute(
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
).lastrowid
c.execute(
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
"VALUES (?, 'installer', ?, NULL, ?, ?)",
(INSTALLER, class_id, INSTALLER, now),
)
issuer_id = c.execute(
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
(DISTINGUISHED_ISSUER_ID, now),
).lastrowid
c.execute(
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
(issuer_id, INSTALLER),
)
c.executemany(
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
dominance,
)
c.execute(
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
(INSTALLER, now),
)
c.execute(
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
(INSTALLER, now),
)
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
c.execute(
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
(now,),
)
k._conn.execute("COMMIT")
return INSTALLED
except sqlite3.Error as exc:
k._safe_rollback()
return PlatformKernel._classify(exc)
class Arch01ConcurrencyTest(unittest.TestCase):
"""Concurrency invariants require file-backed DBs and independent connections."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self._tmp.name, "p.sqlite3")
def tearDown(self) -> None:
self._tmp.cleanup()
# -- AC10 ------------------------------------------------------------- #
def test_concurrent_install(self) -> None: # t_concurrent_install(concurrency)
k1 = PlatformKernel(self.path, busy_timeout_ms=0)
k2 = PlatformKernel(self.path, busy_timeout_ms=0)
barrier = threading.Barrier(2)
results = {}
def _install(name, kernel):
barrier.wait()
results[name] = kernel.install_platform(INSTALLER).code
try:
with ThreadPoolExecutor(max_workers=2) as ex:
f1 = ex.submit(_install, "a", k1)
f2 = ex.submit(_install, "b", k2)
f1.result()
f2.result()
codes = sorted(results.values())
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one install expected: {results}")
other = [c for c in results.values() if c != INSTALLED][0]
self.assertIn(other, (ALREADY_INSTALLED, CONCURRENT_INSTALLATION_LOST))
self.assertTrue(k1.is_installed())
self.assertEqual(_count(k1, "install_state"), 1)
self.assertEqual(_count(k1, "principals"), 1)
finally:
k1.close()
k2.close()
# -- AC11 ------------------------------------------------------------- #
def test_concurrent_last_grant_revoke(self) -> None: # t_concurrent_last_grant_revoke
setup = PlatformKernel(self.path)
self.assertEqual(setup.install_platform(INSTALLER).code, INSTALLED)
setup.register_principal("op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER)
self.assertEqual(setup.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED)
self.assertEqual(setup.active_grant_count(), 2)
gids = [
r[0]
for r in setup._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1 ORDER BY grant_id"
).fetchall()
]
setup.close()
self.assertEqual(len(gids), 2)
k1 = PlatformKernel(self.path, busy_timeout_ms=3000)
k2 = PlatformKernel(self.path, busy_timeout_ms=3000)
barrier = threading.Barrier(2)
results = {}
def _revoke(name, kernel, gid):
barrier.wait()
results[name] = kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code
try:
with ThreadPoolExecutor(max_workers=2) as ex:
f1 = ex.submit(_revoke, "a", k1, gids[0])
f2 = ex.submit(_revoke, "b", k2, gids[1])
f1.result()
f2.result()
codes = list(results.values())
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one revoke should win: {results}")
self.assertEqual(codes.count(AUTHORIZATION_DENIED), 1, f"one revoke must be denied: {results}")
self.assertEqual(k1.active_grant_count(), 1)
self.assertEqual(_count_where(k1, "platform_bootstrap_grants", "active = 1"), 1)
finally:
k1.close()
k2.close()
def test_revoke_final_grant_denied(self) -> None:
k = PlatformKernel(self.path)
try:
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
gid = k._conn.execute(
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1"
).fetchone()[0]
res = k.revoke_platform_bootstrap(gid, actor_principal=INSTALLER)
self.assertEqual(res.code, AUTHORIZATION_DENIED)
self.assertEqual(k.active_grant_count(), 1)
self.assertEqual(_count_where(k, "platform_bootstrap_grants", "active = 1"), 1)
finally:
k.close()
if __name__ == "__main__":
unittest.main()
+581
View File
@@ -0,0 +1,581 @@
"""Authoritative controller cross-role generic queue allocation (#840)."""
from __future__ import annotations
import os
import tempfile
import unittest
from unittest.mock import patch
from allocator_service import (
ALLOCATION_MODE_CROSS_ROLE,
ALLOCATION_MODE_ROLE_SCOPED,
OUTCOME_NO_SAFE,
OUTCOME_PREVIEW,
OUTCOME_WAIT,
ROLE_AUTHOR,
ROLE_CONTROLLER,
ROLE_MERGER,
ROLE_RECONCILER,
ROLE_REVIEWER,
WorkCandidate,
allocate_next_work,
build_selection_dict,
classify_skip,
required_namespace_for_role,
required_profile_for_role,
resolve_allocation_mode,
selected_action_for_candidate,
)
from control_plane_db import ControlPlaneDB
import role_session_router
from role_session_router import (
ROUTE_ALLOWED,
ROUTE_AMBIGUOUS,
ROUTE_WRONG_ROLE,
route_task_session,
)
import namespace_workspace_binding as nwb
import task_capability_map
class CrossRoleAllocationModeTest(unittest.TestCase):
def test_controller_defaults_to_cross_role(self) -> None:
self.assertEqual(
resolve_allocation_mode(ROLE_CONTROLLER),
ALLOCATION_MODE_CROSS_ROLE,
)
def test_worker_defaults_to_role_scoped(self) -> None:
for role in (ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER):
self.assertEqual(
resolve_allocation_mode(role),
ALLOCATION_MODE_ROLE_SCOPED,
)
def test_explicit_modes(self) -> None:
self.assertEqual(
resolve_allocation_mode(ROLE_CONTROLLER, "role_scoped"),
ALLOCATION_MODE_ROLE_SCOPED,
)
self.assertEqual(
resolve_allocation_mode(ROLE_AUTHOR, "cross_role"),
ALLOCATION_MODE_CROSS_ROLE,
)
class CrossRoleSelectionPayloadTest(unittest.TestCase):
def test_selection_contains_required_fields(self) -> None:
c = WorkCandidate(
kind="issue",
number=840,
labels=("status:ready",),
title="cross-role",
priority=20,
)
sel = build_selection_dict(
c,
active_role=ROLE_CONTROLLER,
required_role=ROLE_AUTHOR,
profile_name="prgs-controller",
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
)
self.assertEqual(sel["number"], 840)
self.assertEqual(sel["kind"], "issue")
self.assertEqual(sel["required_role"], ROLE_AUTHOR)
self.assertEqual(sel["selected_action"], "implement")
self.assertEqual(sel["action"], "implement")
self.assertEqual(sel["required_profile"], "prgs-author")
self.assertEqual(sel["required_namespace"], "gitea-author")
self.assertEqual(sel["pinned"]["number"], 840)
self.assertIsNone(sel["pinned"]["head_sha"])
def test_profile_prefix_preserved(self) -> None:
self.assertEqual(
required_profile_for_role(ROLE_REVIEWER, profile_name="dadeschools-controller"),
"dadeschools-reviewer",
)
self.assertEqual(
required_namespace_for_role(ROLE_MERGER),
"gitea-merger",
)
def test_selected_actions_per_role(self) -> None:
issue = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
pr_review = WorkCandidate(kind="pr", number=2, head_sha="a" * 40)
pr_rc = WorkCandidate(
kind="pr",
number=3,
head_sha="b" * 40,
request_changes_current_head=True,
)
pr_merge = WorkCandidate(
kind="pr",
number=4,
head_sha="c" * 40,
approval_on_current_head=True,
mergeable=True,
)
pr_recon = WorkCandidate(
kind="pr",
number=5,
head_sha="d" * 40,
approval_contaminated=True,
)
self.assertEqual(selected_action_for_candidate(issue, ROLE_AUTHOR), "implement")
self.assertEqual(
selected_action_for_candidate(pr_rc, ROLE_AUTHOR),
"address_pr_change_requests",
)
self.assertEqual(
selected_action_for_candidate(pr_review, ROLE_REVIEWER), "review"
)
self.assertEqual(selected_action_for_candidate(pr_merge, ROLE_MERGER), "merge")
self.assertEqual(
selected_action_for_candidate(pr_recon, ROLE_RECONCILER),
"reconcile_contaminated_approval",
)
class CrossRoleAllocateServiceTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
def tearDown(self) -> None:
self._tmp.cleanup()
def _alloc(self, **kwargs):
defaults = dict(
db=self.db,
session_id="ctrl-session",
role=ROLE_CONTROLLER,
remote="prgs",
org="org",
repo="repo",
candidates=[],
apply=False,
profile_name="prgs-controller",
username="controller-bot",
controller_instance_id="ctrl-1",
)
defaults.update(kwargs)
return allocate_next_work(**defaults)
def test_eligible_author_work(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=100,
labels=("status:ready",),
title="author work",
priority=20,
),
]
res = self._alloc(candidates=cands)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
self.assertIsNotNone(res["selected"])
self.assertEqual(res["selected"]["number"], 100)
self.assertEqual(res["required_role"], ROLE_AUTHOR)
self.assertEqual(res["selected_action"], "implement")
self.assertEqual(res["required_profile"], "prgs-author")
self.assertEqual(res["required_namespace"], "gitea-author")
self.assertIn("allocate", res["controller_allowed_actions"])
self.assertIn("merge", res["controller_forbidden_actions"])
self.assertFalse(res["allocation_evidence"]["lease_created"])
def test_eligible_reviewer_work(self) -> None:
cands = [
WorkCandidate(
kind="pr",
number=200,
head_sha="e" * 40,
title="needs review",
priority=30,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 200)
self.assertEqual(res["required_role"], ROLE_REVIEWER)
self.assertEqual(res["selected_action"], "review")
self.assertEqual(res["required_profile"], "prgs-reviewer")
self.assertEqual(res["selected"]["pinned"]["head_sha"], "e" * 40)
def test_eligible_merger_work(self) -> None:
cands = [
WorkCandidate(
kind="pr",
number=300,
head_sha="f" * 40,
approval_on_current_head=True,
mergeable=True,
priority=40,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 300)
self.assertEqual(res["required_role"], ROLE_MERGER)
self.assertEqual(res["selected_action"], "merge")
def test_eligible_reconciler_work(self) -> None:
cands = [
WorkCandidate(
kind="pr",
number=400,
head_sha="1" * 40,
approval_contaminated=True,
priority=50,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 400)
self.assertEqual(res["required_role"], ROLE_RECONCILER)
self.assertIn("reconcile", res["selected_action"])
def test_no_eligible_work(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=10,
labels=("status:blocked",),
blocked=True,
priority=99,
),
WorkCandidate(
kind="issue",
number=11,
labels=("status:ready",),
dependency_unmet=True,
dependency_reason="blocked by #10",
priority=98,
),
]
res = self._alloc(candidates=cands)
self.assertTrue(res["success"])
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
self.assertIsNone(res["selected"])
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
def test_leased_work_skipped(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=50,
labels=("status:ready",),
priority=20,
),
WorkCandidate(
kind="issue",
number=51,
labels=("status:ready",),
priority=10,
),
]
# Seed a foreign lease on issue 50 via assign_and_lease under another session.
other = allocate_next_work(
self.db,
session_id="other-worker",
role=ROLE_AUTHOR,
remote="prgs",
org="org",
repo="repo",
candidates=cands[:1],
apply=True,
profile_name="prgs-author",
controller_instance_id="other-ctrl",
)
self.assertEqual(other["outcome"], "assigned_work")
res = self._alloc(candidates=cands)
self.assertIsNotNone(res["selected"])
self.assertEqual(res["selected"]["number"], 51)
self.assertTrue(any(s["number"] == 50 for s in res["skipped"]))
self.assertTrue(res["claims_excluded"])
def test_dependencies_skipped(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=1,
labels=("status:ready",),
priority=99,
dependency_unmet=True,
dependency_reason="needs #2",
),
WorkCandidate(
kind="issue",
number=2,
labels=("status:ready",),
priority=1,
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 2)
skipped = {s["number"]: s["reason"] for s in res["skipped"]}
self.assertIn(1, skipped)
self.assertIn("needs #2", skipped[1])
def test_pagination_limit_only_truncates_skip_report(self) -> None:
"""Ranking uses full inventory; reporting limit is MCP-layer only.
Service ranks all candidates; prove higher-priority eligible item
wins even when many skipped precede it.
"""
cands = []
for n in range(1, 30):
cands.append(
WorkCandidate(
kind="issue",
number=n,
labels=("status:ready",),
priority=100 - n,
dependency_unmet=True,
dependency_reason=f"dep {n}",
)
)
cands.append(
WorkCandidate(
kind="issue",
number=999,
labels=("status:ready",),
priority=1,
)
)
res = self._alloc(candidates=cands)
self.assertEqual(res["selected"]["number"], 999)
self.assertGreaterEqual(len(res["skipped"]), 29)
def test_role_scoped_controller_legacy_still_restricts(self) -> None:
"""role_scoped controller only takes reconciler-needed items."""
cands = [
WorkCandidate(
kind="issue",
number=1,
labels=("status:ready",),
priority=50,
),
WorkCandidate(
kind="pr",
number=2,
head_sha="a" * 40,
approval_contaminated=True,
priority=1,
),
]
res = self._alloc(
candidates=cands,
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
)
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_ROLE_SCOPED)
self.assertEqual(res["selected"]["number"], 2)
self.assertEqual(res["required_role"], ROLE_RECONCILER)
def test_cross_role_prefers_highest_priority_across_roles(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=10,
labels=("status:ready",),
priority=10,
),
WorkCandidate(
kind="pr",
number=20,
head_sha="b" * 40,
priority=50,
),
WorkCandidate(
kind="pr",
number=30,
head_sha="c" * 40,
approval_on_current_head=True,
mergeable=True,
priority=20,
),
]
res = self._alloc(candidates=cands)
# PR #20 highest priority → reviewer
self.assertEqual(res["selected"]["number"], 20)
self.assertEqual(res["required_role"], ROLE_REVIEWER)
def test_apply_creates_lease_evidence_for_required_role(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=777,
labels=("status:ready",),
priority=20,
),
]
res = self._alloc(candidates=cands, apply=True)
self.assertEqual(res["outcome"], "assigned_work")
self.assertTrue(res["allocation_evidence"]["lease_created"])
self.assertEqual(res["allocation_evidence"]["lease_role"], ROLE_AUTHOR)
proof = res["lease_proof"]
self.assertIsNotNone(proof["lease_id"])
self.assertEqual(proof["lease_role"], ROLE_AUTHOR)
self.assertIn("implement", proof["allowed_actions"])
# Controller isolation: controller still forbids merge/push/create_pr
self.assertIn("merge", res["controller_forbidden_actions"])
self.assertIn("push", res["controller_forbidden_actions"])
def test_metadata_consistency_role_is_controller(self) -> None:
cands = [
WorkCandidate(
kind="issue",
number=1,
labels=("status:ready",),
),
]
res = self._alloc(candidates=cands)
self.assertEqual(res["role"], ROLE_CONTROLLER)
self.assertEqual(res["routing_role"], ROLE_CONTROLLER)
self.assertEqual(res["required_role"], ROLE_AUTHOR)
class ProcessWorkQueueRouterTest(unittest.TestCase):
def tearDown(self) -> None:
role_session_router.clear_route_state()
def test_process_work_queue_allowed_for_controller(self) -> None:
res = route_task_session(
"process_work_queue",
active_profile="prgs-controller",
active_role_kind="controller",
allowed_in_current_session=True,
)
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
self.assertEqual(res["required_role"], "controller")
self.assertTrue(res["downstream_allowed"])
def test_process_work_queue_hyphen_alias(self) -> None:
res = route_task_session(
"process-work-queue",
active_profile="prgs-controller",
active_role_kind="controller",
allowed_in_current_session=True,
)
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
def test_process_work_queue_wrong_role_for_author(self) -> None:
res = route_task_session(
"process_work_queue",
active_profile="prgs-author",
active_role_kind="author",
allowed_in_current_session=False,
)
self.assertEqual(res["route_result"], ROUTE_WRONG_ROLE)
self.assertEqual(res["required_role"], "controller")
self.assertFalse(res["downstream_allowed"])
def test_unknown_still_ambiguous(self) -> None:
res = route_task_session(
"not_a_real_task",
active_profile="prgs-controller",
active_role_kind="controller",
allowed_in_current_session=False,
)
self.assertEqual(res["route_result"], ROUTE_AMBIGUOUS)
def test_capability_map_process_work_queue_is_controller(self) -> None:
self.assertEqual(
task_capability_map.required_role("process_work_queue"),
"controller",
)
self.assertEqual(
task_capability_map.required_permission("process_work_queue"),
"gitea.read",
)
class ControllerRoleMetadataTest(unittest.TestCase):
def test_normalize_role_kind_controller(self) -> None:
self.assertEqual(
nwb.normalize_role_kind("controller"),
"controller",
)
self.assertEqual(
nwb.normalize_role_kind("author", profile_name="prgs-controller"),
"controller",
)
self.assertEqual(
nwb.normalize_role_kind("reconciler", profile_name="prgs-controller"),
"controller",
)
def test_profile_role_kind_prefers_declared_controller(self) -> None:
# Import from worktree package path via sys.path already set by pytest.
import gitea_mcp_server as mcp
profile = {
"profile_name": "prgs-controller",
"role": "controller",
"allowed_operations": [
"gitea.read",
"gitea.issue.comment",
"gitea.pr.close",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
],
}
# Declared role wins even if permissions look reconciler-like.
self.assertEqual(mcp._profile_role_kind(profile), "controller")
# Name-based fallback.
profile_no_role = dict(profile)
profile_no_role["role"] = None
profile_no_role["role_kind"] = None
self.assertEqual(mcp._profile_role_kind(profile_no_role), "controller")
def test_permission_inference_without_controller_name_stays_reconciler(self) -> None:
import gitea_mcp_server as mcp
# Pure permission inference still may return reconciler when no controller
# declaration exists — that is intentional for reconciler profiles.
role = mcp._role_kind(
["gitea.read", "gitea.pr.close", "gitea.issue.comment"],
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create", "gitea.branch.push"],
)
self.assertEqual(role, "reconciler")
class DashboardRemainsExplanatoryTest(unittest.TestCase):
def test_dashboard_prompt_points_at_allocator_not_self_select(self) -> None:
import workflow_dashboard as wd
self.assertIn("gitea_allocate_next_work", wd.PROMPT_CONTROLLER)
self.assertIn("process_work_queue", wd.PROMPT_CONTROLLER)
self.assertIn("never replaces allocator", wd.PROMPT_CONTROLLER.lower())
self.assertNotIn("self-select", wd.PROMPT_CONTROLLER.lower())
class ClassifySkipCrossRoleTest(unittest.TestCase):
def test_controller_cross_role_accepts_author_issue(self) -> None:
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
self.assertIsNone(
classify_skip(
c,
role=ROLE_CONTROLLER,
terminal_pr=None,
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
)
)
def test_legacy_controller_skips_author_issue(self) -> None:
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
reason = classify_skip(
c,
role=ROLE_CONTROLLER,
terminal_pr=None,
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
)
self.assertIsNotNone(reason)
self.assertIn("does not require controller", reason or "")
if __name__ == "__main__":
unittest.main()
+444
View File
@@ -0,0 +1,444 @@
"""Task heartbeat through the native MCP author path (#790 Slice A, AC-N6).
Assessor-level coverage is not sufficient here, and this project has already
paid for learning that: in review #499 on PR #791 the #760 renewal waiver was
computed correctly and then *discarded* at two later gates, so every real
renewal still failed while the unit suite stayed green. AC-N6 exists because of
that, and requires driving the real tools against a real git repository and a
real durable lock file, composing the gates in production order.
These tests therefore call ``gitea_lock_issue`` and
``gitea_heartbeat_issue_lock`` themselves and assert on what lands on disk,
never on an assessor's return value alone.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mutation_profile_fixture import shared_mutation_env # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import lease_policy # noqa: E402
import mcp_server # noqa: E402
ISSUE = 9791
BRANCH = f"fix/issue-{ISSUE}-heartbeat-mcp"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
def _ts(moment: datetime) -> str:
return (
moment.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
class _HeartbeatMcpBase(unittest.TestCase):
"""Real git repo plus a real durable lock, driven through the real tools."""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.repo = tempfile.mkdtemp(prefix="issue790-mcp-")
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.repo], check=False))
self._init_worktree()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
def _git(self, *args):
return subprocess.run(
["git", "-C", self.repo, *args], capture_output=True, text=True, check=True
)
def _init_worktree(self):
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
fh.write("seed\n")
self._git("add", "seed.txt")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
# A fresh claim starts base-equivalent, which is the ordinary first-lock
# shape and exercises assess_issue_lock_worktree on its normal path.
self._git("checkout", "-q", "-b", BRANCH)
self.head_sha = self.base_sha
self.worktree = os.path.realpath(self.repo)
def _lock_path(self):
return issue_lock_store.lock_file_path(
remote="prgs",
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def _tool_env(self):
env = shared_mutation_env(
PROFILE, include_example_repo=True, GITEA_ISSUE_LOCK_DIR=self.lock_dir.name
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return env
def _git_state(self, *, porcelain="", base_equivalent=True):
return {
"current_branch": BRANCH,
"porcelain_status": porcelain,
"base_equivalent": base_equivalent,
"head_sha": self.head_sha,
"inspected_git_root": self.worktree,
"base_branch": "master",
}
def run_lock_issue(
self,
*,
branch_entries=None,
open_prs=None,
git_state=None,
identity=IDENTITY,
profile=PROFILE,
):
branch_entries = branch_entries if branch_entries is not None else []
open_prs = open_prs if open_prs is not None else []
git_state = git_state or self._git_state()
env = self._tool_env()
with patch(
"mcp_server.api_get_all", return_value=list(branch_entries)
), patch(
"mcp_server._list_open_pulls", return_value=list(open_prs)
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": identity, "profile": profile},
), patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=git_state,
), patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=lambda h, o, r, auth, issue_number: (
list(open_prs),
[b.get("name") for b in branch_entries if isinstance(b, dict)],
{"status": "not_claimed"},
),
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_lock_issue(
issue_number=ISSUE,
branch_name=BRANCH,
remote="prgs",
worktree_path=self.worktree,
)
def run_heartbeat(
self, *, task_session_id, identity=IDENTITY, profile=PROFILE, **kwargs
):
env = self._tool_env()
with patch(
"mcp_server._work_lease_claimant",
return_value={"username": identity, "profile": profile},
), patch("mcp_server.get_auth_header", return_value="token x"), patch.dict(
os.environ, env, clear=True
):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_heartbeat_issue_lock(
issue_number=ISSUE,
branch_name=kwargs.pop("branch_name", BRANCH),
task_session_id=task_session_id,
remote="prgs",
worktree_path=kwargs.pop("worktree_path", self.worktree),
**kwargs,
)
def write_legacy_lock(self, *, hours_old: float = 3.0, ttl_hours: float = 4.0):
"""A durable lock in the shape the store wrote before this slice."""
now = datetime.now(timezone.utc)
claimant = {"username": IDENTITY, "profile": PROFILE}
created = now - timedelta(hours=hours_old)
record = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": os.getpid(),
"pid": os.getpid(),
"lock_generation": 1,
"work_lease": {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": BRANCH,
"worktree_path": self.worktree,
"claimant": claimant,
"created_at": _ts(created),
# The legacy signature: never advanced past creation.
"last_heartbeat_at": _ts(created),
"expires_at": _ts(created + timedelta(hours=ttl_hours)),
},
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue", claimant=claimant
),
}
path = self._lock_path()
record["lock_file_path"] = path
issue_lock_store.save_lock_file(path, record)
return record
class TestLockIssueMintsTheLifecycle(_HeartbeatMcpBase):
"""Durable lock creation and read-back through the real tool."""
def test_native_lock_writes_the_marker_and_a_task_session_id(self):
result = self.run_lock_issue()
self.assertTrue(result["success"], result)
written = issue_lock_store.read_lock_file(result["lock_file_path"])
lease = written["work_lease"]
self.assertEqual(
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
)
self.assertTrue(lease["task_session_id"])
self.assertFalse(issue_lock_store.is_legacy_lease(written))
# AC-N1: the ownership key is not the daemon pid, which is recorded
# separately as evidence.
self.assertNotIn(str(written["session_pid"]), lease["task_session_id"])
self.assertEqual(written["session_pid"], os.getpid())
def test_native_lease_uses_the_policy_window_not_four_hours(self):
result = self.run_lock_issue()
lease = result["work_lease"]
created = datetime.fromisoformat(lease["created_at"].replace("Z", "+00:00"))
expires = datetime.fromisoformat(lease["expires_at"].replace("Z", "+00:00"))
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
self.assertEqual(
(expires - created).total_seconds() / 60.0, policy.initial_ttl_minutes
)
def test_freshness_of_a_new_native_lock_is_live(self):
result = self.run_lock_issue()
self.assertEqual(
result["lock_freshness"]["status"], issue_lock_store.STATUS_LIVE
)
self.assertTrue(result["lock_freshness"]["live"])
class TestHeartbeatThroughTheTool(_HeartbeatMcpBase):
def _lock_and_session(self):
result = self.run_lock_issue()
self.assertTrue(result["success"], result)
return result, result["work_lease"]["task_session_id"]
def test_heartbeat_slides_the_lease_and_advances_the_generation(self):
locked, session = self._lock_and_session()
before = issue_lock_store.read_lock_file(locked["lock_file_path"])
beat = self.run_heartbeat(task_session_id=session)
self.assertTrue(beat["success"], beat)
self.assertEqual(beat["operation"], "heartbeat")
after = issue_lock_store.read_lock_file(locked["lock_file_path"])
self.assertGreater(
issue_lock_store.lock_generation(after),
issue_lock_store.lock_generation(before),
)
self.assertGreaterEqual(
after["work_lease"]["expires_at"], before["work_lease"]["expires_at"]
)
self.assertEqual(after["work_lease"]["heartbeat_count"], 2)
def test_heartbeat_evidence_survives_the_downstream_mutation_gate(self):
"""The #499 F2 lesson, applied.
A sanction that is computed and then discarded downstream is worthless.
After a heartbeat the lock must still satisfy the gate every author
mutation runs through.
"""
locked, session = self._lock_and_session()
self.run_heartbeat(task_session_id=session)
written = issue_lock_store.read_lock_file(locked["lock_file_path"])
verdict = issue_lock_store.verify_lock_for_mutation(
written,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
)
self.assertTrue(verdict["proven"], verdict)
self.assertFalse(verdict["block"])
def _duplicate_gate(self, *, open_prs, branches):
env = self._tool_env()
with patch("mcp_server.get_auth_header", return_value="token x"), patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=lambda h, o, r, auth, issue_number: (
list(open_prs),
list(branches),
{"status": "not_claimed"},
),
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_assess_work_issue_duplicate(
issue_number=ISSUE, branch_name=BRANCH, remote="prgs"
)
def test_heartbeat_does_not_change_the_duplicate_gate_verdict(self):
"""The gate must be invariant under heartbeating.
The point is not that the gate passes — with a linked open PR at the
lock phase it correctly blocks (#400), heartbeat or not. The property
that matters is that sliding a lease neither loosens the gate nor
corrupts the lock state it reads: the verdict before and after a
heartbeat must be identical, for both the clear and the blocking shape.
"""
_, session = self._lock_and_session()
linked = [{"number": 4242, "head": {"ref": BRANCH, "sha": self.head_sha}}]
clear_before = self._duplicate_gate(open_prs=[], branches=[])
blocked_before = self._duplicate_gate(open_prs=linked, branches=[BRANCH])
self.assertTrue(self.run_heartbeat(task_session_id=session)["success"])
clear_after = self._duplicate_gate(open_prs=[], branches=[])
blocked_after = self._duplicate_gate(open_prs=linked, branches=[BRANCH])
self.assertEqual(clear_before["outcome"], clear_after["outcome"])
self.assertFalse(clear_after["block"])
self.assertEqual(blocked_before["outcome"], blocked_after["outcome"])
self.assertTrue(blocked_after["block"])
self.assertEqual(blocked_after["linked_open_pr"], 4242)
def test_foreign_session_id_is_refused_through_the_tool(self):
self._lock_and_session()
beat = self.run_heartbeat(task_session_id="author_issue_work-ffffffffffffffff")
self.assertFalse(beat["success"])
self.assertIn("task_session_id does not match", " ".join(beat["reasons"]))
def test_stale_generation_is_refused_through_the_tool(self):
locked, session = self._lock_and_session()
current = issue_lock_store.lock_generation(
issue_lock_store.read_lock_file(locked["lock_file_path"])
)
beat = self.run_heartbeat(
task_session_id=session, expected_generation=current + 5
)
self.assertFalse(beat["success"])
self.assertIn("generation changed", beat["reasons"][0])
def test_foreign_claimant_is_refused_through_the_tool(self):
_, session = self._lock_and_session()
beat = self.run_heartbeat(task_session_id=session, identity="someone-else")
self.assertFalse(beat["success"])
def test_heartbeat_cannot_acquire_a_missing_lock(self):
beat = self.run_heartbeat(task_session_id="author_issue_work-000000000000")
self.assertFalse(beat["success"])
self.assertIn("no durable lock", beat["reasons"][0])
def test_alive_pid_alone_does_not_keep_a_lease_live_through_the_tool(self):
"""PID-only refusal, end to end.
The recorded pid is this live process. The lock is aged past its grace
with no heartbeat, so the tool must refuse to slide it and the durable
record must classify as a missed heartbeat rather than as live.
"""
locked, session = self._lock_and_session()
record = issue_lock_store.read_lock_file(locked["lock_file_path"])
record["work_lease"]["last_heartbeat_at"] = _ts(
datetime.now(timezone.utc) - timedelta(minutes=30)
)
record["work_lease"]["expires_at"] = _ts(
datetime.now(timezone.utc) + timedelta(hours=2)
)
issue_lock_store.save_lock_file(locked["lock_file_path"], record)
self.assertTrue(issue_lock_store.is_process_alive(record["session_pid"]))
fresh = issue_lock_store.assess_lock_freshness(record)
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
self.assertTrue(fresh["pid_alive"])
beat = self.run_heartbeat(task_session_id=session)
self.assertFalse(beat["success"])
self.assertIn("reclaimed", " ".join(beat["reasons"]))
class TestLegacyLocksThroughTheTool(_HeartbeatMcpBase):
"""AC-N8 end to end: protected on deployment, and rebindable."""
def test_legacy_lock_stays_protected_after_deployment(self):
record = self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
fresh = issue_lock_store.assess_lock_freshness(record)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["legacy_lease"])
self.assertTrue(fresh["legacy_expiry_preserved"])
# It had never heartbeated, so under the new grace alone it would be
# long gone; the preserved absolute expiry is what protects it.
self.assertEqual(
record["work_lease"]["created_at"],
record["work_lease"]["last_heartbeat_at"],
)
def test_tool_rebinds_a_legacy_lock_and_mints_a_first_heartbeat(self):
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
result = self.run_heartbeat(task_session_id=None)
self.assertTrue(result["success"], result)
self.assertEqual(result["operation"], "legacy_rebind")
self.assertTrue(result["task_session_id"])
written = issue_lock_store.read_lock_file(self._lock_path())
lease = written["work_lease"]
self.assertEqual(
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
)
self.assertEqual(lease["heartbeat_count"], 1)
self.assertNotEqual(
lease["created_at"],
written["legacy_rebind"]["legacy_origin"]["created_at"],
)
self.assertFalse(issue_lock_store.is_legacy_lease(written))
def test_rebound_lock_then_heartbeats_through_the_tool(self):
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
rebound = self.run_heartbeat(task_session_id=None)
beat = self.run_heartbeat(task_session_id=rebound["task_session_id"])
self.assertTrue(beat["success"], beat)
self.assertEqual(beat["operation"], "heartbeat")
self.assertEqual(beat["heartbeat_count"], 2)
def test_rebind_refuses_a_foreign_owner_through_the_tool(self):
self.write_legacy_lock(hours_old=3.0, ttl_hours=4.0)
result = self.run_heartbeat(task_session_id=None, identity="someone-else")
self.assertFalse(result["success"])
self.assertEqual(result["operation"], "legacy_rebind")
if __name__ == "__main__":
unittest.main()
+594
View File
@@ -0,0 +1,594 @@
"""Central lease policy and load-bearing heartbeat freshness (#790 Slice A).
Before this slice, ``issue_lock_store.assess_lock_freshness`` parsed
``last_heartbeat_at`` and then never consulted it: liveness was decided by an
absolute four-hour ``expires_at`` and by PID liveness. Because the recorded PID
is the long-lived MCP daemon rather than the authoring task, an abandoned claim
stayed "live" for the full four hours, and a claim whose work had already landed
blocked reconciliation for just as long (Issue #787 / PR #789, and again Issue
#760 / PR #791).
These tests pin the corrected semantics, including the two asymmetries that are
easy to lose in a refactor:
* an **alive** PID must never make anything live (AC-N2), while
* a **dead** PID must still mark a lease stale, because #753 dead-session
recovery keys on exactly that classification.
Durable-state helpers here write real lock files through the real flock path;
they are not mocks of the store.
"""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import issue_lock_store # noqa: E402
import lease_policy # noqa: E402
import pr_work_lease # noqa: E402
import reviewer_pr_lease # noqa: E402
ISSUE = 9790
BRANCH = f"fix/issue-{ISSUE}-heartbeat"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Example-Org"
REPO = "Example-Repo"
REMOTE = "prgs"
DEAD_PID = 2**22 # far above any live pid on a test host
def _ts(moment: datetime) -> str:
return (
moment.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
class _LockFixture(unittest.TestCase):
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.now = datetime.now(timezone.utc)
self.worktree = os.path.realpath(tempfile.mkdtemp(prefix="issue790-"))
self.addCleanup(patch.stopall)
def _path(self):
return issue_lock_store.lock_file_path(
remote=REMOTE,
org=ORG,
repo=REPO,
issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def write_lock(
self,
*,
lifecycle: str | None = lease_policy.LIFECYCLE_HEARTBEAT_V1,
created_delta: timedelta = timedelta(minutes=1),
heartbeat_delta: timedelta = timedelta(minutes=1),
expires_delta: timedelta = timedelta(minutes=9),
pid: int | None = None,
task_session_id: str | None = "author_issue_work-aaaabbbbccccdddd",
generation: int = 1,
identity: str = IDENTITY,
profile: str = PROFILE,
branch: str = BRANCH,
worktree: str | None = None,
) -> dict:
"""Write a real durable lock and return the record.
Deltas are relative to ``self.now``; ``expires_delta`` is added, the
others subtracted, so "in the past" reads naturally at each call site.
"""
lease: dict = {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": branch,
"worktree_path": worktree or self.worktree,
"claimant": {"username": identity, "profile": profile},
"created_at": _ts(self.now - created_delta),
"last_heartbeat_at": _ts(self.now - heartbeat_delta),
"expires_at": _ts(self.now + expires_delta),
}
if lifecycle is not None:
lease["lifecycle_version"] = lifecycle
if task_session_id is not None:
lease["task_session_id"] = task_session_id
pid_value = os.getpid() if pid is None else pid
record = {
"issue_number": ISSUE,
"branch_name": branch,
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"worktree_path": worktree or self.worktree,
"session_pid": pid_value,
"pid": pid_value,
"lock_generation": generation,
"work_lease": lease,
}
path = self._path()
record["lock_file_path"] = path
issue_lock_store.save_lock_file(path, record)
return record
class TestPolicyIsTheSingleSource(unittest.TestCase):
"""AC-N7: one authoritative configuration source for every duration."""
def test_author_policy_carries_the_agreed_values(self):
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
self.assertEqual(policy.initial_ttl_minutes, 10.0)
self.assertEqual(policy.heartbeat_cadence_minutes, 2.0)
self.assertEqual(policy.stale_warning_minutes, 5.0)
self.assertEqual(policy.missed_heartbeat_grace_minutes, 10.0)
self.assertEqual(policy.absolute_cap_hours, 8.0)
self.assertEqual(policy.recovery_grace_minutes, 10.0)
self.assertEqual(policy.terminal_race_drain_minutes, 2.0)
self.assertTrue(policy.terminal_retirement_eligible)
self.assertTrue(policy.heartbeat_lifecycle_active)
def test_the_four_hour_author_ttl_literal_is_gone(self):
"""The duplicated literal AC-N7 exists to remove."""
self.assertFalse(hasattr(issue_lock_store, "WORK_LEASE_TTL_HOURS"))
import gitea_mcp_server
self.assertFalse(hasattr(gitea_mcp_server, "WORK_LEASE_TTL_HOURS"))
def test_declared_reviewer_values_match_the_module_still_using_them(self):
"""Slice A declares reviewer/merger numbers without rewiring them.
Recording a value in two places is only safe if drift is detectable, so
this asserts the declaration still equals the constants #747 owns. When
Slice C migrates those call sites, this test becomes the proof the
migration changed nothing.
"""
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_REVIEWER_PR)
self.assertEqual(
policy.initial_ttl_minutes, float(reviewer_pr_lease.LEASE_TTL_MINUTES)
)
self.assertEqual(
policy.stale_warning_minutes,
float(reviewer_pr_lease.STALE_WARNING_MINUTES),
)
self.assertFalse(policy.heartbeat_lifecycle_active)
def test_declared_conflict_fix_value_matches_its_module(self):
policy = lease_policy.policy_for(lease_policy.TASK_CLASS_CONFLICT_FIX)
self.assertEqual(
policy.initial_ttl_minutes,
float(pr_work_lease.DEFAULT_CONFLICT_FIX_TTL_MINUTES),
)
self.assertFalse(policy.heartbeat_lifecycle_active)
def test_environment_override_applies(self):
var = lease_policy.env_var_name(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK, "initial_ttl_minutes"
)
with patch.dict(os.environ, {var: "7"}):
self.assertEqual(
lease_policy.policy_for(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK
).initial_ttl_minutes,
7.0,
)
def test_unusable_override_falls_back_instead_of_minting_a_zero_lease(self):
"""A typo must not make every claim instantly reclaimable."""
var = lease_policy.env_var_name(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK, "initial_ttl_minutes"
)
for bad in ("0", "-5", "not-a-number", " "):
with self.subTest(value=bad), patch.dict(os.environ, {var: bad}):
self.assertEqual(
lease_policy.policy_for(
lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK
).initial_ttl_minutes,
10.0,
)
def test_unknown_task_class_does_not_raise(self):
policy = lease_policy.policy_for("something-new")
self.assertEqual(policy.task_class, lease_policy.TASK_CLASS_AUTHOR_ISSUE_WORK)
class TestLifecycleDiscrimination(_LockFixture):
"""AC-N8: the marker, never a timestamp, decides legacy vs heartbeat."""
def test_missing_marker_reads_as_legacy(self):
record = self.write_lock(lifecycle=None)
self.assertTrue(issue_lock_store.is_legacy_lease(record))
self.assertEqual(
issue_lock_store.lease_lifecycle_version(record),
lease_policy.LIFECYCLE_LEGACY,
)
def test_marker_present_reads_as_heartbeat_lifecycle(self):
record = self.write_lock()
self.assertFalse(issue_lock_store.is_legacy_lease(record))
def test_equal_created_and_heartbeat_never_implies_a_fresh_heartbeat(self):
"""The exact inversion AC-N8 forbids.
A legacy lock has ``last_heartbeat_at == created_at`` forever because
nothing ever advanced it. Reading that equality as "recently
heartbeated" would classify every never-heartbeated lock as fresh.
"""
legacy = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
)
lease = legacy["work_lease"]
self.assertEqual(lease["created_at"], lease["last_heartbeat_at"])
self.assertTrue(issue_lock_store.is_legacy_lease(legacy))
# A brand-new heartbeat lease has them equal too, so the equality
# carries no information in either direction.
fresh = self.write_lock(
created_delta=timedelta(seconds=0), heartbeat_delta=timedelta(seconds=0)
)
self.assertEqual(
fresh["work_lease"]["created_at"],
fresh["work_lease"]["last_heartbeat_at"],
)
self.assertFalse(issue_lock_store.is_legacy_lease(fresh))
def test_minted_session_id_contains_no_pid(self):
"""AC-N1: the ownership key must not be derived from the daemon pid."""
minted = issue_lock_store.mint_task_session_id()
self.assertNotIn(str(os.getpid()), minted)
self.assertNotEqual(minted, issue_lock_store.mint_task_session_id())
class TestFreshnessIsHeartbeatDriven(_LockFixture):
"""AC-N2 and the new bands."""
def test_fresh_heartbeat_is_live(self):
record = self.write_lock(heartbeat_delta=timedelta(minutes=1))
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["live"])
self.assertFalse(fresh["heartbeat_warning"])
def test_heartbeat_past_warning_is_still_live_but_flagged(self):
record = self.write_lock(heartbeat_delta=timedelta(minutes=6))
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["heartbeat_warning"])
def test_missed_heartbeat_past_grace_is_classified_explicitly(self):
record = self.write_lock(
heartbeat_delta=timedelta(minutes=11),
expires_delta=timedelta(minutes=30),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
self.assertFalse(fresh["live"])
self.assertTrue(fresh["stale"])
def test_alive_pid_never_establishes_freshness(self):
"""The defect in one assertion.
The recorded PID is this very process, so it is unambiguously alive —
and the lease is still not live, because the task stopped heartbeating.
"""
record = self.write_lock(
pid=os.getpid(),
heartbeat_delta=timedelta(hours=4),
expires_delta=timedelta(hours=4),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertTrue(fresh["pid_alive"])
self.assertFalse(fresh["live"])
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
def test_dead_pid_still_marks_stale_for_issue_753(self):
"""The opposite asymmetry: dead-PID corroboration is preserved."""
record = self.write_lock(pid=DEAD_PID, heartbeat_delta=timedelta(minutes=1))
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_STALE)
self.assertFalse(fresh["live"])
self.assertIn("not alive", fresh["reason"])
def test_absolute_cap_requires_readoption(self):
record = self.write_lock(
created_delta=timedelta(hours=9), heartbeat_delta=timedelta(minutes=1)
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_STALE_ABSOLUTE_CAP)
self.assertIn("re-adoption", fresh["reason"])
def test_heartbeat_lifecycle_without_a_heartbeat_fails_closed(self):
record = self.write_lock()
del record["work_lease"]["last_heartbeat_at"]
issue_lock_store.save_lock_file(self._path(), record)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(
fresh["status"], issue_lock_store.STATUS_STALE_MISSED_HEARTBEAT
)
self.assertIn("fail closed", fresh["reason"])
def test_absent_lock(self):
fresh = issue_lock_store.assess_lock_freshness(None)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_ABSENT)
self.assertFalse(fresh["stale"])
class TestLegacyLocksStayProtected(_LockFixture):
"""AC-N8: deployment must not retroactively shorten an existing claim."""
def test_legacy_lock_with_a_stale_heartbeat_remains_live(self):
"""The deployment-safety case.
A four-hour legacy lease minted three hours ago has not heartbeated
once. Under the new grace it would be long gone; under its preserved
absolute expiry it is still live, and must stay that way.
"""
record = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_LIVE)
self.assertTrue(fresh["live"])
self.assertTrue(fresh["legacy_lease"])
self.assertTrue(fresh["legacy_expiry_preserved"])
def test_legacy_lock_past_its_absolute_expiry_is_expired_as_before(self):
record = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=5),
heartbeat_delta=timedelta(hours=5),
expires_delta=timedelta(hours=-1),
)
fresh = issue_lock_store.assess_lock_freshness(record, now=self.now)
self.assertEqual(fresh["status"], issue_lock_store.STATUS_EXPIRED)
def test_legacy_lock_is_never_reclaimed_by_the_heartbeat_band(self):
record = self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
)
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertFalse(reclaim["reclaim_allowed"])
class TestReclaimAfterMissedHeartbeat(_LockFixture):
def test_missed_heartbeat_makes_ownership_reclaimable(self):
record = self.write_lock(
pid=os.getpid(),
heartbeat_delta=timedelta(minutes=15),
expires_delta=timedelta(hours=3),
)
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertTrue(reclaim["reclaim_allowed"])
self.assertIn("stale_missed_heartbeat", reclaim["reasons"][0])
def test_live_lease_is_never_reclaimable(self):
record = self.write_lock(heartbeat_delta=timedelta(minutes=1))
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertFalse(reclaim["reclaim_allowed"])
def test_dead_pid_reclaim_path_is_unchanged(self):
"""#753 must keep working through its original conditions."""
record = self.write_lock(pid=DEAD_PID, heartbeat_delta=timedelta(minutes=1))
reclaim = issue_lock_store.assess_expired_lock_reclaim(record, now=self.now)
self.assertTrue(reclaim["reclaim_allowed"])
self.assertTrue(reclaim["owner_pid_dead"])
class TestHeartbeatWriter(_LockFixture):
"""A4: flock + CAS + exact verification, and no revival path."""
def _heartbeat(self, **kwargs):
params = {
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": self.worktree,
"identity": IDENTITY,
"profile": PROFILE,
"task_session_id": "author_issue_work-aaaabbbbccccdddd",
"lock_dir": self.lock_dir.name,
"now": self.now,
}
params.update(kwargs)
return issue_lock_store.heartbeat_session_lock(**params)
def test_heartbeat_slides_expiry_and_advances_generation(self):
self.write_lock(heartbeat_delta=timedelta(minutes=4), generation=5)
result = self._heartbeat()
self.assertTrue(result["success"], result)
self.assertEqual(result["prior_generation"], 5)
self.assertEqual(result["lock_generation"], 6)
self.assertEqual(result["heartbeat_count"], 1)
self.assertEqual(result["last_heartbeat_at"], _ts(self.now))
self.assertEqual(result["expires_at"], _ts(self.now + timedelta(minutes=10)))
self.assertTrue(result["freshness"]["live"])
def test_heartbeat_is_durable_and_repeatable(self):
self.write_lock(heartbeat_delta=timedelta(minutes=4))
self._heartbeat()
second = self._heartbeat(now=self.now + timedelta(minutes=1))
self.assertTrue(second["success"], second)
self.assertEqual(second["heartbeat_count"], 2)
written = issue_lock_store.read_lock_file(self._path())
self.assertEqual(written["work_lease"]["heartbeat_count"], 2)
def test_stale_generation_is_refused(self):
self.write_lock(generation=5)
result = self._heartbeat(expected_generation=4)
self.assertFalse(result["success"])
self.assertIn("generation changed", result["reasons"][0])
def test_foreign_session_is_refused(self):
self.write_lock()
result = self._heartbeat(task_session_id="author_issue_work-ffffffffffffffff")
self.assertFalse(result["success"])
self.assertIn("task_session_id does not match", " ".join(result["reasons"]))
def test_missing_session_id_is_refused(self):
self.write_lock()
result = self._heartbeat(task_session_id="")
self.assertFalse(result["success"])
def test_foreign_claimant_is_refused(self):
self.write_lock()
for field, value in (
("identity", "someone-else"),
("profile", "other-profile"),
):
with self.subTest(field=field):
result = self._heartbeat(**{field: value})
self.assertFalse(result["success"])
def test_branch_and_worktree_mismatch_are_refused(self):
self.write_lock()
wrong_branch = self._heartbeat(branch_name=f"fix/issue-{ISSUE}-other")
self.assertFalse(wrong_branch["success"])
wrong_worktree = self._heartbeat(worktree_path="/tmp/not-the-worktree")
self.assertFalse(wrong_worktree["success"])
def test_lapsed_lease_cannot_be_heartbeated_back_to_life(self):
"""No revival path (A4).
A session that stopped proving liveness must reclaim under a fresh
generation, not restore ownership retroactively.
"""
self.write_lock(
heartbeat_delta=timedelta(minutes=30), expires_delta=timedelta(hours=1)
)
result = self._heartbeat()
self.assertFalse(result["success"])
self.assertIn("reclaimed", " ".join(result["reasons"]))
def test_absent_lock_cannot_be_created_by_heartbeat(self):
result = self._heartbeat()
self.assertFalse(result["success"])
self.assertIn("no durable lock", result["reasons"][0])
def test_legacy_lock_is_refused_until_rebound(self):
self.write_lock(lifecycle=None)
result = self._heartbeat()
self.assertFalse(result["success"])
self.assertTrue(result["legacy_lease"])
self.assertIn("rebound", " ".join(result["reasons"]))
class TestLegacyRebind(_LockFixture):
"""AC-N8 exit route: canonical exact-owner rebinding."""
def _rebind(self, **kwargs):
params = {
"remote": REMOTE,
"org": ORG,
"repo": REPO,
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": self.worktree,
"identity": IDENTITY,
"profile": PROFILE,
"lock_dir": self.lock_dir.name,
"now": self.now,
}
params.update(kwargs)
return issue_lock_store.rebind_legacy_lock(**params)
def test_rebind_mints_a_session_and_a_genuine_first_heartbeat(self):
self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
generation=2,
)
result = self._rebind()
self.assertTrue(result["success"], result)
self.assertTrue(result["task_session_id"])
self.assertEqual(result["lock_generation"], 3)
written = issue_lock_store.read_lock_file(self._path())
lease = written["work_lease"]
self.assertEqual(
lease["lifecycle_version"], lease_policy.LIFECYCLE_HEARTBEAT_V1
)
self.assertEqual(lease["last_heartbeat_at"], _ts(self.now))
self.assertEqual(lease["expires_at"], _ts(self.now + timedelta(minutes=10)))
self.assertFalse(issue_lock_store.is_legacy_lease(written))
# The original claim is preserved for audit rather than overwritten.
origin = written["legacy_rebind"]["legacy_origin"]
self.assertTrue(origin["created_at"])
self.assertEqual(origin["lifecycle"], lease_policy.LIFECYCLE_LEGACY)
def test_rebound_lock_can_then_heartbeat(self):
self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=3),
heartbeat_delta=timedelta(hours=3),
expires_delta=timedelta(hours=1),
)
rebound = self._rebind()
beat = issue_lock_store.heartbeat_session_lock(
remote=REMOTE,
org=ORG,
repo=REPO,
issue_number=ISSUE,
branch_name=BRANCH,
worktree_path=self.worktree,
identity=IDENTITY,
profile=PROFILE,
task_session_id=rebound["task_session_id"],
lock_dir=self.lock_dir.name,
now=self.now + timedelta(minutes=1),
)
self.assertTrue(beat["success"], beat)
def test_rebind_refuses_a_foreign_owner(self):
self.write_lock(lifecycle=None, expires_delta=timedelta(hours=1))
result = self._rebind(identity="someone-else")
self.assertFalse(result["success"])
def test_rebind_refuses_a_lock_already_on_the_lifecycle(self):
self.write_lock()
result = self._rebind()
self.assertFalse(result["success"])
self.assertFalse(result["legacy_lease"])
def test_rebind_is_not_a_recovery_path_for_a_lapsed_legacy_lease(self):
"""An expired legacy lease belongs to #760 renewal or #601 reclaim."""
self.write_lock(
lifecycle=None,
created_delta=timedelta(hours=5),
heartbeat_delta=timedelta(hours=5),
expires_delta=timedelta(hours=-1),
)
result = self._rebind()
self.assertFalse(result["success"])
self.assertIn("not a recovery path", " ".join(result["reasons"]))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,650 @@
"""Publication of an unpublished local commit (#812 AC20).
Entry point B of #812: a registered worktree, clean, on its issue branch,
holding a local commit that has never been published. Exact-owner lease renewal
refuses such a claim for want of an observable remote head, and every existing
publication path is lock-derived, so the two predicates close a cycle around
work that is otherwise complete.
These tests exercise the disposition through its *evidence*, never through any
particular issue number: every case uses an arbitrary issue number against a
synthetic repository, and the same assertions hold for any other. Nothing here
reads, writes, or references the live protected worktree named in #812 AC17 —
that content is preserved evidence for the duration of this work, so the
fixtures below build their own repositories from scratch.
The remote is a local bare repository, so publication and read-after-write
verification are genuinely executed rather than mocked.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import branch_publish # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_renewal # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
from mutation_profile_fixture import shared_mutation_env # noqa: E402
ISSUE = 9812
BRANCH = f"feat/issue-{ISSUE}-publish-fixture"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
GIT_REMOTE = "prgs"
def _ts(hours: int) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
class _PublishBase(unittest.TestCase):
"""Real git repo + real bare remote + durable lock naming the caller.
The recorded owner pid is deliberately **this live process**. That mirrors
the production shape #812 documents, where the pid belongs to a long-running
MCP daemon rather than to a dead author client, and it proves publication
never depends on a dead process (#812 AC24).
"""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.origin = tempfile.mkdtemp(prefix="issue812-origin-")
self.repo = tempfile.mkdtemp(prefix="issue812-work-")
for path in (self.origin, self.repo):
self.addCleanup(
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
)
self._init_repos()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
# ── fixture construction ─────────────────────────────────────────────
def _git(self, *args, cwd=None):
return subprocess.run(
["git", "-C", cwd or self.repo, *args],
capture_output=True,
text=True,
check=True,
)
def _init_repos(self):
subprocess.run(
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
)
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
self._git("remote", "add", GIT_REMOTE, self.origin)
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
fh.write("seed\n")
self._git("add", "seed.txt")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, "master")
self._git("checkout", "-q", "-b", BRANCH)
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
fh.write("unpublished implementation\n")
self._git("add", "work.txt")
self._git("commit", "-q", "-m", "unpublished implementation")
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
self.worktree = os.path.realpath(self.repo)
def lock_path(self):
return issue_lock_store.lock_file_path(
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def write_lock(self, **overrides):
path = self.lock_path()
claimant = overrides.pop(
"claimant", {"username": IDENTITY, "profile": PROFILE}
)
pid = overrides.pop("session_pid", os.getpid())
lease = {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": overrides.get("branch_name", BRANCH),
"worktree_path": overrides.get("worktree_path", self.worktree),
"claimant": claimant,
"created_at": _ts(-2),
"last_heartbeat_at": _ts(-2),
# Expired: entry point B's lease has lapsed, which is precisely why
# renewal — and therefore a published head — is needed.
"expires_at": _ts(-1),
}
lease.update(overrides.pop("work_lease", {}))
data = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": pid,
"pid": pid,
"lock_generation": 1,
"work_lease": lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue", claimant=claimant
),
}
data.update(overrides)
data["lock_file_path"] = path
issue_lock_store.save_lock_file(path, data)
return path
def _tool_env(self):
env = shared_mutation_env(
PROFILE, include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
# These tests repoint PROJECT_ROOT at a synthetic repository so the
# registered-worktree proof runs for real. Pin the parity gate to the
# server's own startup head so that repointing does not read as a stale
# daemon; the gate itself stays live and enforced.
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
return env
# ── tool driver ──────────────────────────────────────────────────────
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
"""Drive the public publication tool against the synthetic fixture."""
env = self._tool_env()
with patch(
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
), patch(
"mcp_server._auth", return_value="token x"
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch.object(
mcp_server, "PROJECT_ROOT", self.repo
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_publish_unpublished_issue_branch(
issue_number=kwargs.pop("issue_number", ISSUE),
branch_name=kwargs.pop("branch_name", BRANCH),
worktree_path=kwargs.pop("worktree_path", self.worktree),
expected_head=expected_head or self.head_sha,
remote="prgs",
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
**kwargs,
)
def remote_head(self, branch=BRANCH):
res = subprocess.run(
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
capture_output=True, text=True, check=False,
)
return (res.stdout or "").strip() or None
class TestSuccessfulPublication(_PublishBase):
"""AC20 — the branch becomes observable and is verified after the write."""
def test_publishes_clean_unpublished_commit(self):
self.write_lock()
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["performed"])
self.assertTrue(result["published"])
self.assertTrue(result["verified"], "read-after-write must be proven")
self.assertEqual(result["remote_head_sha"], self.head_sha)
self.assertEqual(self.remote_head(), self.head_sha)
def test_publication_does_not_rewrite_the_commit(self):
self.write_lock()
self.run_publish()
# The published object is the same commit, not a copy or a rewrite.
self.assertEqual(self.remote_head(), self.head_sha)
self.assertEqual(
self._git("rev-parse", "HEAD").stdout.strip(), self.head_sha
)
def test_exact_next_action_names_the_lock_call(self):
self.write_lock()
result = self.run_publish()
self.assertIn("gitea_lock_issue", result["exact_next_action"])
class TestFailsClosed(_PublishBase):
"""AC20/AC9 — each refusal reason, exercised independently."""
def test_changed_local_head_refuses(self):
self.write_lock()
stale = self.base_sha # a real commit, but not the declared head
result = self.run_publish(expected_head=stale)
self.assertFalse(result["success"])
self.assertTrue(
any("local commit changed" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head(), "refusal must not publish")
def test_abbreviated_sha_refuses(self):
self.write_lock()
result = self.run_publish(expected_head=self.head_sha[:8])
self.assertFalse(result["success"])
self.assertTrue(
any("40-character" in r for r in result["reasons"]), result["reasons"]
)
def test_dirty_tracked_worktree_refuses(self):
self.write_lock()
with open(os.path.join(self.repo, "work.txt"), "a") as fh:
fh.write("uncommitted edit\n")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("dirty tracked files" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIn("work.txt", result["evidence"]["dirty_tracked_files"])
self.assertIsNone(self.remote_head())
def test_untracked_file_refuses(self):
self.write_lock()
with open(os.path.join(self.repo, "stray.txt"), "w") as fh:
fh.write("not committed\n")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("untracked files" in r for r in result["reasons"]), result["reasons"]
)
self.assertIn("stray.txt", result["evidence"]["untracked_files"])
self.assertIsNone(self.remote_head())
def test_unexpected_remote_head_refuses(self):
"""A remote head that is not an ancestor must never be overwritten."""
self.write_lock()
# Publish a divergent commit to the branch from a separate line.
self._git("checkout", "-q", "-b", "divergent", self.base_sha)
with open(os.path.join(self.repo, "other.txt"), "w") as fh:
fh.write("someone else's work\n")
self._git("add", "other.txt")
self._git("commit", "-q", "-m", "divergent")
divergent = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, f"{divergent}:refs/heads/{BRANCH}")
self._git("checkout", "-q", BRANCH)
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("not an ancestor" in r for r in result["reasons"]), result["reasons"]
)
self.assertEqual(
self.remote_head(), divergent, "the other head must survive intact"
)
def test_fast_forward_remote_head_is_allowed(self):
"""An ancestor head is an honest fast-forward, not a conflict."""
self.write_lock()
self._git("push", "-q", GIT_REMOTE, f"{self.base_sha}:refs/heads/{BRANCH}")
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["evidence"]["fast_forward_from_remote"])
self.assertEqual(self.remote_head(), self.head_sha)
def test_content_hash_mismatch_refuses(self):
self.write_lock()
wrong = {"work.txt": "0" * 64}
result = self.run_publish(expected_file_hashes=wrong)
self.assertFalse(result["success"])
self.assertTrue(
any("declared content hashes" in r for r in result["reasons"]),
result["reasons"],
)
self.assertFalse(result["evidence"]["file_hashes_verified"])
self.assertIsNone(self.remote_head())
def test_matching_content_hashes_publish(self):
self.write_lock()
digests = branch_publish.hash_worktree_files(self.worktree, ["work.txt"])
result = self.run_publish(expected_file_hashes=digests)
self.assertTrue(result["success"], result.get("reasons"))
self.assertTrue(result["evidence"]["file_hashes_verified"])
def test_missing_declared_file_refuses(self):
self.write_lock()
result = self.run_publish(expected_file_hashes={"absent.txt": "0" * 64})
self.assertFalse(result["success"])
self.assertTrue(
any("missing or unreadable" in r for r in result["reasons"]),
result["reasons"],
)
def test_foreign_claimant_refuses(self):
"""Ownership comes from the durable record, not from the caller."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("foreign claim" in r for r in result["reasons"]), result["reasons"]
)
self.assertIsNone(self.remote_head())
def test_foreign_profile_refuses(self):
self.write_lock(
claimant={"username": IDENTITY, "profile": "test-reviewer-prgs"}
)
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("claimant profile" in r for r in result["reasons"]), result["reasons"]
)
def test_absent_lock_record_refuses(self):
"""No recorded claim means this cannot be used to bypass the lock."""
result = self.run_publish() # no write_lock()
self.assertFalse(result["success"])
self.assertTrue(
any("no durable issue-lock record" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head())
def test_branch_mismatch_against_lock_refuses(self):
self.write_lock(branch_name=f"feat/issue-{ISSUE}-different")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("records branch" in r for r in result["reasons"]), result["reasons"]
)
def test_worktree_mismatch_against_lock_refuses(self):
self.write_lock(worktree_path="/tmp/some/other/worktree")
result = self.run_publish()
self.assertFalse(result["success"])
self.assertTrue(
any("records worktree" in r for r in result["reasons"]), result["reasons"]
)
def test_competing_open_pr_on_another_branch_refuses(self):
self.write_lock()
competing = [{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
result = self.run_publish(open_prs=competing)
self.assertFalse(result["success"])
self.assertTrue(
any("already claim issue" in r for r in result["reasons"]),
result["reasons"],
)
self.assertIsNone(self.remote_head())
def test_open_pr_on_the_same_branch_is_not_competing(self):
"""This branch's own PR is not a rival claim against itself."""
self.write_lock()
own = [{"number": 77, "head": {"ref": BRANCH}}]
result = self.run_publish(open_prs=own)
self.assertTrue(result["success"], result.get("reasons"))
class TestGuardStrictnessPreserved(_PublishBase):
"""AC15 — publication is an operation, never a weakening of the guards."""
def test_non_issue_branch_refuses(self):
self._git("checkout", "-q", "-b", "scratch/not-issue-linked")
self.write_lock(branch_name="scratch/not-issue-linked")
result = self.run_publish(branch_name="scratch/not-issue-linked")
self.assertFalse(result["success"])
self.assertTrue(
any("issue-linked" in r for r in result["reasons"]), result["reasons"]
)
def test_stable_branch_refuses(self):
self.write_lock(branch_name="master")
result = self.run_publish(branch_name="master")
self.assertFalse(result["success"])
self.assertTrue(
any("issue-linked" in r or "stable branch" in r for r in result["reasons"]),
result["reasons"],
)
def test_branch_number_must_match_the_issue(self):
other = "feat/issue-7777-mismatched"
self._git("checkout", "-q", "-b", other)
self.write_lock(branch_name=other)
result = self.run_publish(branch_name=other)
self.assertFalse(result["success"])
self.assertTrue(
any("does not carry issue number" in r for r in result["reasons"]),
result["reasons"],
)
def test_unregistered_worktree_refuses(self):
"""#713 — an improvised directory is not a registered worktree."""
path = self.write_lock()
assessment = branch_publish.assess_unpublished_commit_publication(
issue_lock_store.read_lock_file(path),
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
expected_head=self.head_sha, remote="prgs", org=ORG, repo=REPO,
identity=IDENTITY, profile=PROFILE,
worktree_state={
"current_branch": BRANCH, "porcelain_status": "",
"head_sha": self.head_sha,
},
worktree_registered=False,
remote_probe={"probe_ok": True, "remote_branch_exists": False},
)
self.assertEqual(assessment["outcome"], branch_publish.REFUSED)
self.assertTrue(
any("not listed in git worktree list" in r
for r in assessment["reasons"]),
assessment["reasons"],
)
def test_unobservable_remote_refuses(self):
"""An unknown remote state must not be mistaken for an absent branch."""
self.write_lock()
result = self.run_publish(git_remote_name="no-such-remote")
self.assertFalse(result["success"])
self.assertTrue(
any("could not be observed" in r for r in result["reasons"]),
result["reasons"],
)
class TestRecordSeparation(_PublishBase):
"""AC23 — the durable issue lock and the workflow lease are distinct."""
def test_publication_leaves_the_issue_lock_byte_identical(self):
path = self.write_lock()
with open(path, "rb") as fh:
before = fh.read()
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
with open(path, "rb") as fh:
after = fh.read()
self.assertEqual(before, after, "publication must not mutate the lock record")
self.assertFalse(result["issue_lock_record_mutated"])
self.assertFalse(result["workflow_lease_touched"])
def test_refusal_also_reports_untouched_records(self):
result = self.run_publish() # refuses: no lock record
self.assertFalse(result["issue_lock_record_mutated"])
self.assertFalse(result["workflow_lease_touched"])
def test_lock_generation_is_not_advanced(self):
path = self.write_lock()
self.run_publish()
lock = issue_lock_store.read_lock_file(path)
self.assertEqual(lock["lock_generation"], 1)
class TestTruthfulProcessEvidence(_PublishBase):
"""AC24 — a live daemon pid is never represented as a dead process."""
def test_live_recorded_pid_does_not_block_publication(self):
# The recorded pid is this live process, standing in for the live MCP
# daemon. Reclaim would refuse here; publication legitimately does not.
path = self.write_lock(session_pid=os.getpid())
lock = issue_lock_store.read_lock_file(path)
self.assertEqual(lock["pid"], os.getpid())
result = self.run_publish()
self.assertTrue(result["success"], result.get("reasons"))
self.assertEqual(self.remote_head(), self.head_sha)
def test_liveness_is_not_consulted_as_evidence(self):
self.write_lock(session_pid=os.getpid())
result = self.run_publish()
self.assertFalse(result["evidence"]["owner_pid_liveness_consulted"])
def test_reclaim_still_refuses_for_the_same_live_pid(self):
"""Publication does not soften the reclaim predicate it routes around."""
path = self.write_lock(session_pid=os.getpid())
lock = issue_lock_store.read_lock_file(path)
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
self.assertFalse(reclaim["reclaim_allowed"])
class TestIdempotentRetry(_PublishBase):
"""AC20 — retry is safe and read-after-write is proven every time."""
def test_second_publication_reports_already_published(self):
self.write_lock()
first = self.run_publish()
self.assertTrue(first["performed"])
second = self.run_publish()
self.assertTrue(second["success"], second.get("reasons"))
self.assertFalse(second["performed"], "no second push is needed")
self.assertTrue(second["published"])
self.assertTrue(second["verified"])
self.assertEqual(second["outcome"], branch_publish.ALREADY_PUBLISHED)
self.assertEqual(self.remote_head(), self.head_sha)
class TestDryRun(_PublishBase):
"""AC12 — dry run reports the decision and mutates nothing."""
def test_dry_run_reports_intent_without_publishing(self):
self.write_lock()
result = self.run_publish(dry_run=True)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
self.assertTrue(result["would_publish"])
self.assertFalse(result["performed"])
self.assertIsNone(self.remote_head(), "dry run must not publish")
def test_dry_run_and_apply_agree_on_a_refusal(self):
"""AC11 — the reported decision does not depend on which mode ran."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
dry = self.run_publish(dry_run=True)
applied = self.run_publish()
self.assertFalse(dry["success"])
self.assertFalse(applied["success"])
self.assertEqual(dry["reasons"], applied["reasons"])
class TestRenewalUnblocked(_PublishBase):
"""AC20/AC21 — renewal is permitted only after verified publication."""
def _renewal(self, remote_head):
return issue_lock_renewal.assess_exact_owner_lease_renewal(
issue_lock_store.read_lock_file(self.lock_path()),
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
remote="prgs", org=ORG, repo=REPO,
identity=IDENTITY, profile=PROFILE,
current_branch=BRANCH, porcelain_status="", worktree_exists=True,
head_sha=self.head_sha, remote_head_sha=remote_head,
)
def test_renewal_refuses_before_publication(self):
self.write_lock()
decision = self._renewal(None)
self.assertFalse(decision["renewal_sanctioned"])
self.assertTrue(
any("unpublished branch" in r for r in decision["reasons"]),
decision["reasons"],
)
def test_renewal_is_sanctioned_after_publication(self):
self.write_lock()
result = self.run_publish()
self.assertTrue(result["verified"], result.get("reasons"))
decision = self._renewal(self.remote_head())
self.assertTrue(decision["renewal_sanctioned"], decision["reasons"])
class TestProtectedAssetUntouched(unittest.TestCase):
"""AC17 — no test or fixture may reference the protected worktree."""
def test_no_reference_to_the_protected_worktree(self):
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.dirname(here)
needle = "issue-635-project-registry" + "-api"
for path in (
os.path.join(here, "test_issue_812_publish_unpublished_commit.py"),
os.path.join(root, "branch_publish.py"),
):
with open(path, "r", encoding="utf-8") as fh:
body = fh.read()
self.assertNotIn(needle, body)
if __name__ == "__main__": # pragma: no cover
unittest.main()
@@ -0,0 +1,622 @@
"""Publication preflight must receive the caller's worktree (#815).
``gitea_publish_unpublished_issue_branch`` takes a **required** ``worktree_path``
but resolved it only *after* ``verify_preflight_purity`` had already run. Every
workspace-resolution layer behind that preflight — canonical root, root checkout,
create-issue bootstrap, the #618 branches-only guard, issue scope, and anti-stomp
— therefore received ``None`` and fell back to the MCP process root. A daemon
rooted at the stable control checkout refused a valid registered issue worktree
that the caller had explicitly supplied, before the publication assessor ever ran.
The #812 suite could not see this. Its fixture sets ``self.worktree =
os.path.realpath(self.repo)`` and patches ``PROJECT_ROOT`` to that same path, so
the fallback resolved to the very worktree the argument named. The production
topology — control checkout on a stable branch, issue worktree somewhere else —
was never constructed, and preflight additionally no-ops under pytest unless
production guards are forced on.
These tests build that topology honestly:
* ``PROJECT_ROOT`` is a control checkout sitting on ``master``;
* the registered issue worktree is a genuinely separate path under ``branches/``;
* ``GITEA_TEST_FORCE_PRODUCTION_GUARDS`` is set so the #618 guard really runs;
* no patch makes the issue worktree appear to be ``PROJECT_ROOT``.
Nothing here reads, writes, or references the protected worktree named in #812
AC17 and #815 AC9. Every fixture is built from scratch against a local bare
remote, so publication and read-after-write verification genuinely execute.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import mcp_server # noqa: E402
from mutation_profile_fixture import shared_mutation_env # noqa: E402
ISSUE = 9815
BRANCH = f"feat/issue-{ISSUE}-forwarding-fixture"
WORKTREE_DIRNAME = BRANCH.replace("/", "-")
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
GIT_REMOTE = "prgs"
AUTHOR_PROFILE = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
"gitea.pr.create", "gitea.repo.commit", "gitea.branch.push",
],
"forbidden_operations": [],
"audit_label": "prgs-author",
}
def _ts(hours: int) -> str:
return (
(datetime.now(timezone.utc) + timedelta(hours=hours))
.isoformat()
.replace("+00:00", "Z")
)
class TestPreflightReceivesTheWorktree(unittest.TestCase):
"""AC1 — the supplied path reaches ``verify_preflight_purity`` itself.
Follows the #735 capture pattern: replace preflight with a recorder that
raises, so the argument can be proven forwarded without performing the
mutation. This is the direct unit-level statement of the defect.
"""
def _capture_preflight(self, **kwargs):
captured: dict = {}
def _capture(*a, **kw):
captured.update(kw)
captured["_args"] = a
raise RuntimeError("capture-only")
with patch.object(
mcp_server, "verify_preflight_purity", side_effect=_capture
), patch.object(
mcp_server, "get_profile", return_value=AUTHOR_PROFILE
), patch.object(
mcp_server, "_resolve",
return_value=("gitea.prgs.cc", ORG, REPO),
), patch.object(
mcp_server, "_auth", return_value="token fake",
), patch.object(
mcp_server.role_session_router,
"check_author_mutation_after_reviewer_stop",
return_value=(True, []),
), patch.object(
mcp_server, "_namespace_mutation_block", return_value=None
), patch.object(
mcp_server, "_profile_permission_block", return_value=None
):
try:
mcp_server.gitea_publish_unpublished_issue_branch(**kwargs)
except RuntimeError as exc:
if "capture-only" not in str(exc) and not captured:
raise
self.assertTrue(
captured,
"gitea_publish_unpublished_issue_branch never called "
"verify_preflight_purity",
)
return captured
def _base_kwargs(self, **overrides):
kwargs = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"worktree_path": "/tmp/issue-815-explicit-worktree",
"expected_head": "a" * 40,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"git_remote_name": GIT_REMOTE,
}
kwargs.update(overrides)
return kwargs
def test_explicit_worktree_path_reaches_preflight(self):
captured = self._capture_preflight(**self._base_kwargs())
self.assertEqual(
captured.get("worktree_path"),
os.path.realpath(os.path.abspath("/tmp/issue-815-explicit-worktree")),
"the authoritative worktree_path must be forwarded into preflight",
)
def test_forwarded_path_is_the_one_publication_uses(self):
"""AC4 — preflight and publication must judge the same resolved path."""
raw = "/tmp/issue-815-explicit-worktree/./"
captured = self._capture_preflight(**self._base_kwargs(worktree_path=raw))
expected = os.path.realpath(os.path.abspath(raw.strip()))
self.assertEqual(captured.get("worktree_path"), expected)
def test_blank_worktree_path_forwards_none(self):
"""AC5/AC8 — nothing usable supplied keeps the fail-closed fallback."""
for blank in ("", " "):
with self.subTest(blank=repr(blank)):
captured = self._capture_preflight(
**self._base_kwargs(worktree_path=blank)
)
self.assertIsNone(
captured.get("worktree_path"),
"a blank worktree must not resolve to the process cwd",
)
def test_org_repo_and_task_forwarding_are_not_regressed(self):
"""AC6 — #735's org/repo forwarding and the task name still hold."""
captured = self._capture_preflight(**self._base_kwargs())
self.assertEqual(captured.get("org"), ORG)
self.assertEqual(captured.get("repo"), REPO)
self.assertEqual(captured.get("task"), "publish_unpublished_branch")
class _ProductionTopologyBase(unittest.TestCase):
"""Control checkout on master + a distinct registered issue worktree.
This is the shape the production daemon runs in and the shape the #812
fixture never built. ``PROJECT_ROOT`` is the control checkout; the issue
worktree is a real registered worktree at a different path; production
guards are forced on so the #618 branches-only guard genuinely evaluates.
"""
def setUp(self):
self.lock_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.lock_dir.cleanup)
self.origin = tempfile.mkdtemp(prefix="issue815-origin-")
self.control = tempfile.mkdtemp(prefix="issue815-control-")
for path in (self.origin, self.control):
self.addCleanup(
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
)
self._init_repos()
self.remotes = patch.dict(
mcp_server.REMOTES,
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
)
self.remotes.start()
self.addCleanup(patch.stopall)
mcp_server._IDENTITY_CACHE.clear()
def _git(self, *args, cwd=None):
return subprocess.run(
["git", "-C", cwd or self.control, *args],
capture_output=True, text=True, check=True,
)
def _init_repos(self):
subprocess.run(
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
)
self._git("init", "-q", "-b", "master")
self._git("config", "user.email", "[email protected]")
self._git("config", "user.name", "Test")
self._git("remote", "add", GIT_REMOTE, self.origin)
with open(os.path.join(self.control, "seed.txt"), "w") as fh:
fh.write("seed\n")
# The real repository gitignores branches/, so a registered worktree
# living there does not dirty the stable control checkout. Mirror that,
# or the #615 dirty-runtime block fires on the worktree we just created.
with open(os.path.join(self.control, ".gitignore"), "w") as fh:
fh.write("branches/\n")
self._git("add", "seed.txt", ".gitignore")
self._git("commit", "-q", "-m", "seed")
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
self._git("push", "-q", GIT_REMOTE, "master")
# The control checkout STAYS on master. This is the whole point: the
# daemon's process root is the stable control checkout, never the
# worktree the publication targets.
self.worktree = os.path.realpath(
os.path.join(self.control, "branches", WORKTREE_DIRNAME)
)
self._git("worktree", "add", "-q", "-b", BRANCH, self.worktree, "master")
with open(os.path.join(self.worktree, "work.txt"), "w") as fh:
fh.write("unpublished implementation\n")
self._git("add", "work.txt", cwd=self.worktree)
self._git("commit", "-q", "-m", "unpublished implementation", cwd=self.worktree)
self.head_sha = self._git("rev-parse", "HEAD", cwd=self.worktree).stdout.strip()
self.control_branch = self._git(
"rev-parse", "--abbrev-ref", "HEAD"
).stdout.strip()
# ── durable lock naming the caller and the issue worktree ────────────
def lock_path(self):
return issue_lock_store.lock_file_path(
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
lock_dir=self.lock_dir.name,
)
def write_lock(self, *, bind_session=True, **overrides):
path = self.lock_path()
claimant = overrides.pop(
"claimant", {"username": IDENTITY, "profile": PROFILE}
)
pid = overrides.pop("session_pid", os.getpid())
lease = {
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
"issue_number": ISSUE,
"pr_number": None,
"branch": overrides.get("branch_name", BRANCH),
"worktree_path": overrides.get("worktree_path", self.worktree),
"claimant": claimant,
"created_at": _ts(-2),
"last_heartbeat_at": _ts(-2),
"expires_at": _ts(-1),
}
lease.update(overrides.pop("work_lease", {}))
data = {
"issue_number": ISSUE,
"branch_name": BRANCH,
"remote": "prgs",
"org": ORG,
"repo": REPO,
"worktree_path": self.worktree,
"session_pid": pid,
"pid": pid,
"lock_generation": 1,
"work_lease": lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue", claimant=claimant
),
}
data.update(overrides)
data["lock_file_path"] = path
issue_lock_store.save_lock_file(path, data)
# Bind the session pointer so the #683 issue-scope guard resolves an
# owning issue for this author session. In real production the publish
# task does not require a session lock — require_author_lock is keyed on
# the test-only production_guards_forced() flag, which this suite must
# set to make preflight run at all — so this pointer is fixture
# scaffolding to clear a guard production would not apply here, never a
# softening of the worktree-forwarding behaviour under test. The
# preflight-negative cases below leave it unbound precisely so the #618
# guard is reached with no session fallback to rescue a bad worktree.
if bind_session:
pointer = {
"pid": os.getpid(),
"lock_file_path": path,
"issue_number": ISSUE,
"branch_name": data["branch_name"],
"remote": "prgs",
"org": ORG,
"repo": REPO,
}
issue_lock_store.save_lock_file(
issue_lock_store.session_pointer_path(self.lock_dir.name), pointer
)
return path
def _tool_env(self):
env = shared_mutation_env(
PROFILE, include_example_repo=True,
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
)
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
# The defect only exists where preflight actually runs. Under pytest the
# production root/branches/scope guards are skipped unless forced on, so
# force them: this test exists to exercise the #618 guard, not to bypass
# it. Parity is pinned to the server's own startup head so repointing
# PROJECT_ROOT does not read as a stale daemon.
env["GITEA_TEST_FORCE_PRODUCTION_GUARDS"] = "1"
# Production is a promoted stable-control runtime. The pytest process
# itself runs from a branches/ worktree, which the #615 runtime-mode
# gate correctly classifies as dev-test; declaring the sanctioned mode
# models the production daemon rather than defeating the gate. Without
# this, forcing production guards on would trip the *runtime-mode* block
# for a reason unrelated to the #815 worktree-forwarding defect.
env["GITEA_MCP_RUNTIME_MODE"] = "stable-control"
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
return env
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
"""Drive the public tool with PROJECT_ROOT pinned to the CONTROL checkout."""
env = self._tool_env()
with patch(
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
), patch(
"mcp_server._auth", return_value="token x"
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch.object(
# NOTE: the control checkout — deliberately NOT self.worktree.
mcp_server, "PROJECT_ROOT", self.control
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
return mcp_server.gitea_publish_unpublished_issue_branch(
issue_number=kwargs.pop("issue_number", ISSUE),
branch_name=kwargs.pop("branch_name", BRANCH),
worktree_path=kwargs.pop("worktree_path", self.worktree),
expected_head=expected_head or self.head_sha,
remote="prgs",
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
**kwargs,
)
def remote_head(self, branch=BRANCH):
res = subprocess.run(
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
capture_output=True, text=True, check=False,
)
return (res.stdout or "").strip() or None
class TestForwardingClearsThe618Guard(_ProductionTopologyBase):
"""AC2 — the faithful production reproduction, and the sharpest fix proof.
The production recovery worker had **no** session issue lock — acquiring one
was the very thing the deadlock prevented — so preflight had nothing but the
explicit ``worktree_path`` argument to resolve the workspace from. This class
reproduces exactly that: no session pointer is bound, so there is no
author-lock fallback to rescue a dropped argument.
With the argument forwarded (fixed source) the #618 branches-only guard
accepts the registered issue worktree and the call advances to the next
guard. With the argument dropped (the buggy source this issue reports)
preflight falls back to ``PROJECT_ROOT`` — the stable control checkout — and
the #618 guard traps the call there. The two outcomes are told apart by the
guard that fired, on its own error text.
This test therefore *fails* against the unpatched source (the call is trapped
at #618 instead of clearing it), which is what makes it a regression rather
than a smoke test.
"""
_CONTROL_CHECKOUT_MARKERS = ("stable control checkout", "#618")
def test_explicit_worktree_clears_618_without_a_session_lock(self):
# No write_lock(): the session is deliberately unbound, as in production.
with self.assertRaises(RuntimeError) as ctx:
self.run_publish()
message = str(ctx.exception)
# The workspace guard is satisfied — the failure is the *later* scope
# guard (no owning issue), never the control-checkout refusal. If the
# argument were dropped, this call would be trapped at #618 instead.
for marker in self._CONTROL_CHECKOUT_MARKERS:
self.assertNotIn(
marker, message,
f"the explicit worktree must clear #618; got a control-checkout "
f"refusal instead: {message}",
)
self.assertIn(
"owning issue", message,
f"expected the downstream scope guard to fire, got: {message}",
)
self.assertIsNone(self.remote_head())
def test_dropped_argument_would_be_trapped_at_618(self):
# Simulate the buggy call shape directly: no session lock, and preflight
# given no worktree, exactly as the unpatched source left it. This pins
# the control-checkout refusal that the fix eliminates, so the pair of
# tests brackets the defect from both sides regardless of which source
# version is loaded.
env = self._tool_env()
with patch(
"mcp_server._list_open_pulls", return_value=[]
), patch(
"mcp_server._auth", return_value="token x"
), patch(
"mcp_server.get_auth_header", return_value="token x"
), patch(
"mcp_server._work_lease_claimant",
return_value={"username": IDENTITY, "profile": PROFILE},
), patch.object(
mcp_server, "PROJECT_ROOT", self.control
), patch.dict(os.environ, env, clear=True):
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
with self.assertRaises(RuntimeError) as ctx:
# Drive verify_preflight_purity the way the buggy body did:
# no worktree_path forwarded at all.
mcp_server.verify_preflight_purity(
"prgs",
task="publish_unpublished_branch",
org=ORG,
repo=REPO,
)
message = str(ctx.exception)
self.assertTrue(
any(m in message for m in self._CONTROL_CHECKOUT_MARKERS),
f"a dropped worktree must trap at the control checkout: {message}",
)
self.assertIsNone(self.remote_head())
class TestProductionTopologyPublishes(_ProductionTopologyBase):
"""AC2/AC4/AC7 — the explicit registered worktree is what preflight validates."""
def test_fixture_is_genuinely_the_production_topology(self):
"""Guard the guard: if this drifts, the regression stops meaning anything."""
self.assertNotEqual(
os.path.realpath(self.control), self.worktree,
"the issue worktree must not be PROJECT_ROOT",
)
self.assertEqual(
self.control_branch, "master",
"the control checkout must sit on a stable branch",
)
self.assertTrue(
os.path.realpath(self.worktree).startswith(
os.path.realpath(os.path.join(self.control, "branches")) + os.sep
),
"the issue worktree must live under branches/",
)
listed = subprocess.run(
["git", "-C", self.control, "worktree", "list"],
capture_output=True, text=True, check=True,
).stdout
self.assertIn(
self.worktree, listed, "the issue worktree must be genuinely registered"
)
def test_publishes_from_a_control_rooted_daemon(self):
"""The exact production failure: this refused with #618 before the fix."""
self.write_lock()
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
res = self.run_publish()
self.assertTrue(res.get("success"), res)
self.assertTrue(res.get("performed"), res)
self.assertEqual(self.remote_head(), self.head_sha)
def test_dry_run_uses_the_explicit_worktree(self):
"""AC4 — dry-run reaches the same decision without publishing."""
self.write_lock()
res = self.run_publish(dry_run=True)
self.assertTrue(res.get("success"), res)
self.assertFalse(res.get("performed"), res)
self.assertTrue(res.get("would_publish"), res)
self.assertIsNone(self.remote_head(), "dry-run must not publish")
def test_dry_run_and_apply_agree_on_the_same_worktree(self):
"""AC4 — both paths resolve the same workspace, so both succeed."""
self.write_lock()
dry = self.run_publish(dry_run=True)
self.assertTrue(dry.get("would_publish"), dry)
applied = self.run_publish()
self.assertTrue(applied.get("performed"), applied)
self.assertEqual(self.remote_head(), self.head_sha)
def test_read_after_write_verification_still_runs(self):
"""AC6 — PR #814's post-publication verification is unchanged."""
self.write_lock()
res = self.run_publish()
self.assertTrue(res.get("verified"), res)
self.assertEqual(res.get("remote_head_sha"), self.head_sha)
class TestProductionTopologyFailsClosed(_ProductionTopologyBase):
"""AC3/AC5/AC8 — the fix does not weaken any refusal.
A refusal reaches the caller by one of two mechanisms, and this class holds
them apart deliberately. A bad *workspace* is caught by the #618 preflight
guard, which raises before the assessor is built. A bad *content/ownership*
fact passes preflight (the worktree itself is fine) and is then refused by
the publication assessor, which returns ``success: False``. Both are
fail-closed; asserting the wrong mechanism would hide a regression.
"""
# ── #618 preflight refusals: no session lock, so nothing rescues a bad
# workspace and the guard fires exactly as it does in production ──────
def _assert_preflight_raises(self, **kwargs):
with self.assertRaises(RuntimeError) as ctx:
self.run_publish(**kwargs)
self.assertIsNone(
self.remote_head(), "a blocked publication must not reach the remote"
)
return str(ctx.exception)
def test_blank_worktree_path_fails_closed_via_618(self):
"""AC5 — a blank path forwards None, so preflight sees the control root."""
for blank in ("", " "):
with self.subTest(blank=repr(blank)):
message = self._assert_preflight_raises(worktree_path=blank)
self.assertIn("618", message)
def test_control_checkout_as_worktree_fails_closed_via_618(self):
"""AC5 — naming the stable control checkout explicitly is still refused."""
message = self._assert_preflight_raises(worktree_path=self.control)
self.assertIn("618", message)
def test_unregistered_directory_fails_closed(self):
"""AC3 — a plain directory under branches/ is not a registered worktree."""
bogus = os.path.join(self.control, "branches", "not-a-worktree")
os.makedirs(bogus, exist_ok=True)
self._assert_preflight_raises(worktree_path=bogus)
def test_missing_worktree_path_fails_closed(self):
"""AC3 — a path that does not exist is refused, not silently replaced."""
missing = os.path.join(self.control, "branches", "absent-worktree")
self._assert_preflight_raises(worktree_path=missing)
# ── assessor refusals: preflight passes on a valid worktree, then the
# publication assessor refuses on content/ownership evidence ──────────
def _assert_assessor_refuses(self, **kwargs):
res = self.run_publish(**kwargs)
self.assertFalse(res.get("success"), res)
self.assertFalse(res.get("performed"), res)
self.assertIsNone(self.remote_head())
return res
def test_changed_local_head_still_refuses(self):
"""AC6 — the declared expected_head remains authoritative."""
self.write_lock()
self._assert_assessor_refuses(expected_head="b" * 40)
def test_foreign_claimant_still_refuses(self):
"""AC6 — ownership still comes from the durable lock record."""
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
self._assert_assessor_refuses()
def test_dirty_worktree_still_refuses(self):
"""AC6 — cleanliness enforcement survives the forwarding change."""
self.write_lock()
with open(os.path.join(self.worktree, "work.txt"), "a") as fh:
fh.write("uncommitted drift\n")
self._assert_assessor_refuses()
def test_competing_open_pr_still_refuses(self):
"""AC6 — a rival claim on another branch still blocks."""
self.write_lock()
self._assert_assessor_refuses(
open_prs=[{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
)
def test_issue_lock_record_is_not_mutated_by_a_refusal(self):
"""AC6 — record separation (#812 AC23) is unaffected by this change."""
path = self.write_lock()
with open(path, "rb") as fh:
before = fh.read()
self._assert_assessor_refuses(expected_head="c" * 40)
with open(path, "rb") as fh:
self.assertEqual(before, fh.read())
class TestProtectedFixtureNotReferenced(unittest.TestCase):
"""AC9 — this regression never names the protected #635 fixture.
The forbidden tokens are reconstructed from fragments so this assertion
file does not itself contain them and produce a false positive.
"""
def test_no_reference_to_the_protected_worktree(self):
forbidden = [
"issue-635-" + "project-registry-api",
"b2f6e9a6dc40e9651ef8" + "76f322dd0a68bddebfd8",
]
here = os.path.abspath(__file__)
with open(here, "r", encoding="utf-8") as fh:
text = fh.read()
for token in forbidden:
self.assertNotIn(
token, text,
f"the protected #635 fixture must not be referenced: {token}",
)
if __name__ == "__main__":
unittest.main()
@@ -139,6 +139,9 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
"gitea_release_merger_pr_lease", "gitea_release_merger_pr_lease",
"create_branch", "create_branch",
"push_branch", "push_branch",
# #812 AC20: publishing an unpublished local head is author-only for the
# same reason every other push is — it writes a branch to the remote.
"publish_unpublished_branch",
"create_pr", "create_pr",
"commit_files", "commit_files",
"gitea_commit_files", "gitea_commit_files",
+703
View File
@@ -0,0 +1,703 @@
"""Console authorization, redaction, and audit model tests (#633).
Covers each acceptance criterion and each required test named in the issue:
* AC1 — RBAC matrix and privileged-action list.
* AC2 — redaction rules, unit-tested against sample payloads.
* AC3 — audit event schema with required fields and retention defaults.
* AC4 — Phase 2 integration points.
* AC5 — local-dev mode with explicit insecurity warnings.
Required tests: redaction units (token, keychain, password patterns),
default-deny for unauthenticated write stubs, and audit record creation for a
simulated privileged preview.
"""
from __future__ import annotations
import datetime
import json
import os
import pathlib
import sys
import tempfile
import unittest
from starlette.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
from task_capability_map import TASK_CAPABILITY_MAP # noqa: E402
from webui import console_audit, console_authz # noqa: E402
from webui.app import create_app # noqa: E402
from webui.console_redaction import ( # noqa: E402
REDACTED,
redact_payload,
redact_text,
redaction_policy,
scan_for_secrets,
)
DOCS = pathlib.Path(__file__).resolve().parents[1] / "docs"
AUTHZ_DOC = DOCS / "webui-authz-audit.md"
def _principal(role: str) -> console_authz.Principal:
return console_authz.Principal(
subject=f"{role}@example.com",
role=role,
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
authenticated=True,
)
class TestRoleMatrix(unittest.TestCase):
"""AC1 — the written RBAC matrix and privileged-action list."""
def test_roles_are_ordered_least_to_most_authority(self):
self.assertEqual(
console_authz.ROLE_ORDER,
("viewer", "operator", "controller", "admin"),
)
def test_every_role_has_a_description(self):
for role in console_authz.ROLE_ORDER:
with self.subTest(role=role):
self.assertTrue(console_authz.ROLE_DESCRIPTIONS[role].strip())
def test_higher_roles_inherit_lower_role_actions(self):
matrix = {
entry["role"]: set(entry["permitted_actions"])
for entry in console_authz.rbac_matrix()["roles"]
}
for lower, higher in zip(
console_authz.ROLE_ORDER, console_authz.ROLE_ORDER[1:]
):
with self.subTest(lower=lower, higher=higher):
self.assertTrue(matrix[lower].issubset(matrix[higher]))
def test_viewer_holds_no_write_action(self):
matrix = {
entry["role"]: set(entry["permitted_actions"])
for entry in console_authz.rbac_matrix()["roles"]
}
self.assertEqual(matrix["viewer"], set())
def test_privileged_action_list_is_non_empty_and_classified(self):
privileged = console_authz.privileged_actions()
self.assertTrue(privileged)
ids = {action.action_id for action in privileged}
# Merge and branch deletion are the canonical privileged pair.
self.assertIn("merge_pr", ids)
self.assertIn("delete_branch", ids)
def test_merge_and_delete_require_dual_control_and_break_glass(self):
for action_id in ("merge_pr", "delete_branch"):
with self.subTest(action=action_id):
action = console_authz.get_action(action_id)
self.assertTrue(action.dual_control)
self.assertTrue(action.break_glass)
self.assertTrue(action.requires_confirmation)
def test_every_write_action_requires_confirmation(self):
for action in console_authz.ACTIONS.values():
with self.subTest(action=action.action_id):
self.assertTrue(action.requires_confirmation)
def test_delete_branch_is_admin_only(self):
self.assertEqual(
console_authz.get_action("delete_branch").minimum_role,
console_authz.ADMIN,
)
def test_actions_map_to_real_mcp_capability_vocabulary(self):
"""The console must not invent an authority the MCP layer lacks."""
for action in console_authz.ACTIONS.values():
with self.subTest(action=action.action_id):
self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
self.assertEqual(
action.mcp_permission,
TASK_CAPABILITY_MAP[action.task_key]["permission"],
)
self.assertEqual(
action.mcp_role,
TASK_CAPABILITY_MAP[action.task_key]["role"],
)
def test_matrix_declares_deny_by_default_and_execution_disabled(self):
matrix = console_authz.rbac_matrix()
self.assertEqual(matrix["default_decision"], "deny")
self.assertFalse(matrix["execution_enabled"])
class TestAuthorizeDefaultDeny(unittest.TestCase):
"""Fail-closed behaviour of the authorization decision."""
def test_anonymous_is_denied_every_action(self):
for action_id in console_authz.ACTIONS:
with self.subTest(action=action_id):
decision = console_authz.authorize(action_id)
self.assertFalse(decision.allowed)
self.assertEqual(
decision.reason_code, console_authz.DENY_UNAUTHENTICATED
)
def test_unknown_action_is_denied(self):
decision = console_authz.authorize(
"not_a_real_action", _principal("admin")
)
self.assertFalse(decision.allowed)
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ACTION)
def test_unknown_role_is_denied(self):
rogue = console_authz.Principal(
subject="[email protected]",
role="superuser",
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
authenticated=True,
)
decision = console_authz.authorize("comment_issue", rogue)
self.assertFalse(decision.allowed)
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ROLE)
def test_insufficient_role_is_denied(self):
decision = console_authz.authorize("merge_pr", _principal("operator"))
self.assertFalse(decision.allowed)
self.assertEqual(
decision.reason_code, console_authz.DENY_INSUFFICIENT_ROLE
)
def test_sufficient_role_allows_preview_only(self):
decision = console_authz.authorize("merge_pr", _principal("controller"))
self.assertTrue(decision.allowed)
self.assertFalse(decision.execution_enabled)
def test_execution_is_refused_while_phase_is_not_active(self):
decision = console_authz.authorize(
"merge_pr", _principal("controller"), for_execution=True
)
self.assertFalse(decision.allowed)
self.assertEqual(
decision.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE
)
def test_allowed_decision_never_reports_execution_enabled(self):
for action_id in console_authz.ACTIONS:
with self.subTest(action=action_id):
decision = console_authz.authorize(
action_id, _principal("admin")
)
self.assertFalse(decision.execution_enabled)
class TestIdentityResolution(unittest.TestCase):
"""AC5 — identity sources, including the insecure local-dev mode."""
def test_no_auth_mode_yields_anonymous_viewer(self):
principal = console_authz.resolve_principal(env={})
self.assertFalse(principal.authenticated)
self.assertEqual(principal.role, console_authz.VIEWER)
self.assertEqual(principal.identity_source, console_authz.IDENTITY_NONE)
def test_local_dev_mode_warns_that_identity_is_unverified(self):
principal = console_authz.resolve_principal(
env={
console_authz.AUTH_MODE_ENV: "local-dev",
console_authz.DEV_SUBJECT_ENV: "[email protected]",
console_authz.DEV_ROLE_ENV: "admin",
}
)
self.assertTrue(principal.authenticated)
self.assertEqual(principal.role, "admin")
self.assertTrue(principal.warnings)
self.assertIn("asserted", " ".join(principal.warnings).lower())
def test_local_dev_without_subject_falls_back_to_anonymous(self):
principal = console_authz.resolve_principal(
env={console_authz.AUTH_MODE_ENV: "local-dev"}
)
self.assertFalse(principal.authenticated)
def test_local_dev_unknown_role_degrades_to_viewer(self):
principal = console_authz.resolve_principal(
env={
console_authz.AUTH_MODE_ENV: "local_dev",
console_authz.DEV_SUBJECT_ENV: "[email protected]",
console_authz.DEV_ROLE_ENV: "root",
}
)
self.assertEqual(principal.role, console_authz.VIEWER)
def test_access_proxy_without_header_fails_closed(self):
"""A proxy-mode request that did not traverse the proxy is anonymous."""
principal = console_authz.resolve_principal(
headers={},
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
)
self.assertFalse(principal.authenticated)
def test_access_proxy_role_comes_from_server_config_not_client(self):
env = {
console_authz.AUTH_MODE_ENV: "access_proxy",
console_authz.ROLE_MAP_ENV: json.dumps(
{"[email protected]": "controller"}
),
}
principal = console_authz.resolve_principal(
headers={
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]",
"x-role": "admin", # client-supplied role must be ignored
},
env=env,
)
self.assertEqual(principal.role, "controller")
def test_access_proxy_unmapped_subject_defaults_to_viewer(self):
principal = console_authz.resolve_principal(
headers={
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"
},
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
)
self.assertEqual(principal.role, console_authz.VIEWER)
def test_malformed_role_map_does_not_raise_and_denies(self):
principal = console_authz.resolve_principal(
headers={console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"},
env={
console_authz.AUTH_MODE_ENV: "access_proxy",
console_authz.ROLE_MAP_ENV: "{not json",
},
)
self.assertEqual(principal.role, console_authz.VIEWER)
def test_probe_auth_is_opt_in(self):
self.assertFalse(console_authz.probe_auth_required(env={}))
self.assertTrue(
console_authz.probe_auth_required(
env={console_authz.REQUIRE_PROBE_AUTH_ENV: "1"}
)
)
def test_probe_auth_is_declared_but_not_yet_enforced(self):
"""Phase 1 declares the probe-auth policy; no route enforces it yet.
The flag exists so the Phase 2 action framework has a declared policy
to honour instead of inventing a second one. Pinning the current
not-enforced status here means wiring it later is a deliberate change
that updates this test and the documentation together, rather than a
silent behaviour shift. The documentation must say so plainly, because
an operator who sets the variable believing it protects a probe is
worse off than one who knows it does not.
"""
import inspect
from webui import app as webui_app
source = inspect.getsource(webui_app)
self.assertNotIn(
"probe_auth_required",
source,
msg=(
"webui.app now consults probe_auth_required, so probe auth is "
"no longer merely declared. Update the 'Probe authentication' "
"section of docs/webui-authz-audit.md, which states it "
"enforces nothing, and replace this test with real "
"enforcement coverage."
),
)
self.assertIn(
"enforces nothing today",
AUTHZ_DOC.read_text(encoding="utf-8"),
)
class TestRedaction(unittest.TestCase):
"""AC2 — required redaction units: token, keychain, password patterns."""
def test_token_assignment_is_redacted(self):
out = redact_text("GITEA_TOKEN=abcd1234efgh5678ijkl")
self.assertIn(REDACTED, out)
self.assertNotIn("abcd1234efgh5678ijkl", out)
def test_password_assignment_is_redacted(self):
out = redact_text("password: hunter2supersecret")
self.assertIn(REDACTED, out)
self.assertNotIn("hunter2supersecret", out)
def test_keychain_reference_is_redacted(self):
out = redact_text("keychain:gitea-prgs-token")
self.assertIn(REDACTED, out)
self.assertNotIn("gitea-prgs-token", out)
def test_keychain_command_is_redacted(self):
out = redact_text("security find-generic-password -s gitea -w")
self.assertIn(REDACTED, out)
self.assertNotIn("find-generic-password -s gitea", out)
def test_bearer_credential_is_redacted(self):
out = redact_text("Authorization: Bearer abcdef1234567890abcdef")
self.assertNotIn("abcdef1234567890abcdef", out)
def test_jwt_is_redacted(self):
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop"
out = redact_text(f"session={token}")
self.assertNotIn(token, out)
def test_private_key_block_is_redacted(self):
pem = (
"-----BEGIN RSA PRIVATE KEY-----\n"
"MIIEowIBAAKCAQEAsecretmaterial\n"
"-----END RSA PRIVATE KEY-----"
)
out = redact_text(pem)
self.assertNotIn("MIIEowIBAAKCAQEAsecretmaterial", out)
def test_api_key_assignment_is_redacted(self):
out = redact_text('api_key = "sk-live-9f8e7d6c5b4a3210"')
self.assertNotIn("sk-live-9f8e7d6c5b4a3210", out)
def test_nested_payload_is_redacted_recursively(self):
payload = {
"token": "abc123456789",
"nested": {"note": "password=letmein12345"},
"list": ["keychain:some-entry"],
"safe": "plain text",
}
out = redact_payload(payload)
self.assertEqual(out["token"], REDACTED)
self.assertNotIn("letmein12345", json.dumps(out))
self.assertNotIn("some-entry", json.dumps(out))
self.assertEqual(out["safe"], "plain text")
def test_scan_reports_findings_before_and_none_after(self):
dirty = "password: hunter2supersecret"
self.assertTrue(scan_for_secrets(dirty))
self.assertEqual(scan_for_secrets(redact_text(dirty)), [])
def test_non_strings_pass_through_untouched(self):
self.assertEqual(redact_text(42), 42)
self.assertEqual(
redact_payload({"n": 1, "b": True}), {"n": 1, "b": True}
)
def test_policy_is_documented_and_declares_redact_before_persist(self):
policy = redaction_policy()
self.assertTrue(policy["redact_before_persist"])
self.assertIn("audit_records", policy["applies_to"])
self.assertTrue(policy["console_rules"])
def test_policy_statement_contains_no_secret_material(self):
self.assertEqual(scan_for_secrets(redaction_policy()), [])
class TestAuditSchema(unittest.TestCase):
"""AC3 — audit event schema, required fields, and retention defaults."""
def _event(self, action_id="merge_pr", **kwargs):
return console_audit.build_event(
action_id=action_id,
result=console_audit.RESULT_DENIED,
decision=console_authz.authorize(action_id, _principal("operator")),
target={"kind": "pr", "ref": "#123"},
request_id="req-test",
**kwargs,
)
def test_every_required_field_is_present(self):
event = self._event()
for field in console_audit.REQUIRED_FIELDS:
with self.subTest(field=field):
self.assertIn(field, event)
def test_actor_carries_who_and_how_they_were_identified(self):
event = self._event()
for field in console_audit.REQUIRED_ACTOR_FIELDS:
with self.subTest(field=field):
self.assertIn(field, event["actor"])
def test_correlation_ids_are_present(self):
event = self._event()
for field in console_audit.REQUIRED_CORRELATION_FIELDS:
with self.subTest(field=field):
self.assertIn(field, event["correlation"])
self.assertEqual(event["correlation"]["request_id"], "req-test")
self.assertEqual(event["correlation"]["mcp_task"], "merge_pr")
def test_timestamp_is_timezone_aware_utc_iso8601(self):
now = datetime.datetime(
2026, 7, 22, 10, 16, 42, tzinfo=datetime.timezone.utc
)
event = self._event(now=now)
self.assertEqual(event["timestamp"], "2026-07-22T10:16:42+00:00")
parsed = datetime.datetime.fromisoformat(event["timestamp"])
self.assertIsNotNone(parsed.tzinfo)
def test_retention_defaults_by_class(self):
self.assertEqual(
console_audit.RETENTION_DAYS[console_audit.RETENTION_STANDARD], 90
)
self.assertEqual(
console_audit.RETENTION_DAYS[console_audit.RETENTION_PRIVILEGED],
365,
)
self.assertEqual(
console_audit.RETENTION_DAYS[console_audit.RETENTION_BREAK_GLASS],
730,
)
def test_break_glass_action_retains_longest(self):
event = self._event("merge_pr")
self.assertEqual(
event["retention"]["class"], console_audit.RETENTION_BREAK_GLASS
)
def test_routine_write_uses_standard_retention(self):
event = self._event("comment_issue")
self.assertEqual(
event["retention"]["class"], console_audit.RETENTION_STANDARD
)
def test_unknown_action_retains_as_privileged_not_standard(self):
"""Conservative direction: keep an unclassifiable record longer."""
self.assertEqual(
console_audit.retention_class_for(None),
console_audit.RETENTION_PRIVILEGED,
)
def test_retention_expiry_matches_declared_days(self):
now = datetime.datetime(2026, 7, 22, tzinfo=datetime.timezone.utc)
event = self._event("comment_issue", now=now)
expires = datetime.datetime.fromisoformat(
event["retention"]["expires_at"]
)
self.assertEqual((expires - now).days, 90)
def test_invalid_result_degrades_to_failed(self):
event = console_audit.build_event(action_id="merge_pr", result="banana")
self.assertEqual(event["result"], console_audit.RESULT_FAILED)
def test_denied_result_is_representable(self):
"""An authorization denial has no MCP-side mutation record."""
self.assertIn(console_audit.RESULT_DENIED, console_audit.RESULTS)
def test_event_is_redacted_before_it_is_returned(self):
event = console_audit.build_event(
action_id="merge_pr",
result=console_audit.RESULT_DENIED,
detail="failed with token=abcdef1234567890",
metadata={"password": "hunter2supersecret"},
)
serialized = json.dumps(event)
self.assertNotIn("abcdef1234567890", serialized)
self.assertNotIn("hunter2supersecret", serialized)
self.assertTrue(event["redacted"])
def test_audit_policy_reports_schema_and_retention(self):
policy = console_audit.audit_policy()
self.assertTrue(policy["append_only"])
self.assertTrue(policy["redact_before_persist"])
self.assertEqual(
policy["retention_defaults_days"], console_audit.RETENTION_DAYS
)
class TestAuditSink(unittest.TestCase):
"""Append-only persistence behaviour."""
def test_write_is_a_noop_when_sink_is_unconfigured(self):
saved = os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
try:
self.assertFalse(console_audit.audit_enabled())
self.assertFalse(console_audit.write_event({"schema_version": 1}))
finally:
if saved is not None:
os.environ[console_audit.AUDIT_LOG_ENV] = saved
def test_records_append_one_json_line_each(self):
with tempfile.TemporaryDirectory() as tmp:
sink = os.path.join(tmp, "console-audit.jsonl")
for _ in range(3):
event = console_audit.build_event(
action_id="merge_pr", result=console_audit.RESULT_DENIED
)
self.assertTrue(console_audit.write_event(event, path=sink))
with open(sink, encoding="utf-8") as handle:
lines = [json.loads(line) for line in handle if line.strip()]
self.assertEqual(len(lines), 3)
self.assertEqual(len({line["event_id"] for line in lines}), 3)
def test_a_record_that_still_carries_a_secret_is_not_persisted(self):
with tempfile.TemporaryDirectory() as tmp:
sink = os.path.join(tmp, "console-audit.jsonl")
leaky = {
"schema_version": 1,
"detail": "password: hunter2supersecret",
}
self.assertFalse(console_audit.write_event(leaky, path=sink))
self.assertFalse(os.path.exists(sink))
def test_write_never_raises_on_a_bad_path(self):
self.assertFalse(
console_audit.write_event(
{"schema_version": 1}, path="/nonexistent-dir/audit.jsonl"
)
)
def test_simulated_privileged_preview_creates_an_audit_record(self):
"""Required test: audit record creation for a privileged preview."""
with tempfile.TemporaryDirectory() as tmp:
sink = os.path.join(tmp, "console-audit.jsonl")
os.environ[console_audit.AUDIT_LOG_ENV] = sink
try:
decision = console_authz.authorize(
"merge_pr", _principal("controller")
)
outcome = console_audit.record_event(
action_id="merge_pr",
result=console_audit.RESULT_PREVIEWED,
decision=decision,
target={"kind": "pr", "ref": "#123"},
request_id="req-preview",
)
finally:
os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
self.assertTrue(outcome["written"])
with open(sink, encoding="utf-8") as handle:
record = json.loads(handle.read().strip())
self.assertEqual(record["action"], "merge_pr")
self.assertEqual(record["result"], console_audit.RESULT_PREVIEWED)
self.assertEqual(record["action_class"], "privileged")
self.assertTrue(record["decision"]["allowed"])
self.assertFalse(record["decision"]["execution_enabled"])
self.assertEqual(record["actor"]["role"], "controller")
def test_decision_block_survives_redaction(self):
"""Regression: naming it 'authorization' collided with a secret hint.
``gitea_audit._SECRET_KEY_HINTS`` contains "authorization" (for the
HTTP header), so a block under that key was replaced wholesale by the
placeholder and the record lost its decision entirely.
"""
event = console_audit.build_event(
action_id="merge_pr",
result=console_audit.RESULT_DENIED,
decision=console_authz.authorize("merge_pr", _principal("admin")),
)
self.assertIsInstance(event["decision"], dict)
self.assertIn("allowed", event["decision"])
class TestConsoleRoutes(unittest.TestCase):
"""AC4 — the wired Phase 2 integration points, still fail-closed."""
def setUp(self):
self.client = TestClient(create_app(bind_host="127.0.0.1"))
def test_unauthenticated_write_stub_is_denied(self):
"""Required test: default-deny for unauthenticated write stubs."""
response = self.client.post(
"/api/actions/merge_pr/attempt", json={"pr_number": 99}
)
self.assertEqual(response.status_code, 403)
body = response.json()
self.assertFalse(body["success"])
authorization = body["authorization"]
self.assertFalse(authorization["allowed"])
self.assertEqual(
authorization["reason_code"], console_authz.DENY_UNAUTHENTICATED
)
self.assertFalse(authorization["execution_enabled"])
def test_preview_reports_an_authorization_decision(self):
response = self.client.get("/api/actions/merge_pr/preview?pr_number=7")
self.assertEqual(response.status_code, 200)
authorization = response.json()["authorization"]
self.assertFalse(authorization["allowed"])
self.assertTrue(authorization["dual_control"])
self.assertEqual(authorization["required_role"], "controller")
def test_unknown_action_preview_still_404s(self):
response = self.client.get("/api/actions/no_such_action/preview")
self.assertEqual(response.status_code, 404)
def test_security_model_endpoint_publishes_all_three_policies(self):
response = self.client.get("/api/console/security-model")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertIn("rbac", body)
self.assertIn("redaction", body)
self.assertIn("audit", body)
self.assertEqual(body["rbac"]["default_decision"], "deny")
def test_security_model_endpoint_leaks_no_secrets(self):
response = self.client.get("/api/console/security-model")
self.assertEqual(scan_for_secrets(response.json()), [])
def test_security_model_rejects_writes(self):
response = self.client.post("/api/console/security-model", json={})
self.assertEqual(response.status_code, 405)
def test_existing_read_routes_are_unaffected(self):
for path in ("/", "/health", "/actions", "/api/actions"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 200)
class TestAuthzAuditDoc(unittest.TestCase):
"""The model must be written down, not only coded."""
@classmethod
def setUpClass(cls):
cls.text = (
AUTHZ_DOC.read_text(encoding="utf-8") if AUTHZ_DOC.exists() else ""
)
def test_doc_exists(self):
self.assertTrue(AUTHZ_DOC.exists(), f"missing {AUTHZ_DOC}")
def test_doc_covers_each_required_section(self):
for heading in (
"Identity sources",
"Role matrix",
"Privileged actions",
"Secret redaction",
"Audit event schema",
"Retention",
"Phase 2 integration",
"Local-dev mode",
):
with self.subTest(heading=heading):
self.assertIn(heading, self.text)
def test_doc_names_every_role(self):
for role in console_authz.ROLE_ORDER:
with self.subTest(role=role):
self.assertIn(role, self.text)
def test_doc_names_every_console_action(self):
for action_id in console_authz.ACTIONS:
with self.subTest(action=action_id):
self.assertIn(action_id, self.text)
def test_doc_states_retention_defaults(self):
for days in console_audit.RETENTION_DAYS.values():
with self.subTest(days=days):
self.assertIn(str(days), self.text)
def test_doc_warns_local_dev_is_insecure(self):
self.assertIn("INSECURE", self.text.upper())
def test_doc_states_default_deny(self):
self.assertIn("deny", self.text.lower())
def test_doc_contains_no_secret_material(self):
self.assertEqual(scan_for_secrets(self.text), [])
def test_deployment_doc_links_to_the_model(self):
deployment = (DOCS / "webui-deployment.md").read_text(encoding="utf-8")
self.assertIn("webui-authz-audit", deployment)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+80
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from starlette.applications import Starlette from starlette.applications import Starlette
@@ -31,6 +32,9 @@ from final_report_validator import FINAL_REPORT_TASK_KINDS
from webui.gated_actions import attempt_action, load_action_registry, preview_action from webui.gated_actions import attempt_action, load_action_registry, preview_action
from webui.gated_action_views import render_actions_page from webui.gated_action_views import render_actions_page
from webui import console_audit
from webui.console_authz import authorize, rbac_matrix, resolve_principal
from webui.console_redaction import redaction_policy
from webui.audit_validator import audit_report, audit_to_dict from webui.audit_validator import audit_report, audit_to_dict
from webui.audit_views import render_audit_page from webui.audit_views import render_audit_page
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
@@ -276,6 +280,49 @@ async def api_actions(_request: Request) -> JSONResponse:
return JSONResponse(load_action_registry().to_dict()) return JSONResponse(load_action_registry().to_dict())
def _request_id() -> str:
return f"req-{uuid.uuid4().hex}"
def _audit_target(action_id: str, params: dict[str, object]) -> dict[str, object]:
"""Describe the action target for the audit record (never secrets)."""
if "pr_number" in params:
return {"kind": "pr", "ref": f"#{params['pr_number']}"}
if "issue_number" in params:
return {"kind": "issue", "ref": f"#{params['issue_number']}"}
if "branch_name" in params:
return {"kind": "branch", "ref": str(params["branch_name"])}
return {"kind": "unspecified", "ref": action_id}
def _authorize_request(
request: Request,
action_id: str,
params: dict[str, object],
*,
for_execution: bool,
result: str,
) -> dict[str, object]:
"""Resolve principal, decide, and audit. Returns the decision payload.
Phase 1 records the decision rather than enforcing it as the terminal
outcome: ``webui.gated_actions`` already fails closed for every action, so
this layer cannot loosen anything. Phase 2 enforces on this same decision.
"""
principal = resolve_principal(headers=dict(request.headers))
decision = authorize(action_id, principal, for_execution=for_execution)
console_audit.record_event(
action_id=action_id,
result=result,
decision=decision,
principal=principal,
target=_audit_target(action_id, params),
request_id=_request_id(),
detail=decision.detail,
)
return decision.to_dict()
async def api_action_preview(request: Request) -> JSONResponse: async def api_action_preview(request: Request) -> JSONResponse:
action_id = request.path_params["action_id"] action_id = request.path_params["action_id"]
params = dict(request.query_params) params = dict(request.query_params)
@@ -285,6 +332,13 @@ async def api_action_preview(request: Request) -> JSONResponse:
result = preview_action(action_id, **params) result = preview_action(action_id, **params)
if "error" in result: if "error" in result:
return JSONResponse(result, status_code=404) return JSONResponse(result, status_code=404)
result["authorization"] = _authorize_request(
request,
action_id,
params,
for_execution=False,
result=console_audit.RESULT_PREVIEWED,
)
return JSONResponse(result) return JSONResponse(result)
@@ -298,10 +352,31 @@ async def api_action_attempt(request: Request) -> JSONResponse:
if not isinstance(body, dict): if not isinstance(body, dict):
body = {} body = {}
result = attempt_action(action_id, **body) result = attempt_action(action_id, **body)
authorization = _authorize_request(
request,
action_id,
body,
for_execution=True,
result=(
console_audit.RESULT_DENIED
if not result.get("success")
else console_audit.RESULT_ALLOWED
),
)
result["authorization"] = authorization
status = 403 if not result.get("success") else 200 status = 403 if not result.get("success") else 200
return JSONResponse(result, status_code=status) return JSONResponse(result, status_code=status)
async def api_console_security_model(_request: Request) -> JSONResponse:
"""Read-only publication of the #633 authorization/redaction/audit model."""
return JSONResponse({
"rbac": rbac_matrix(),
"redaction": redaction_policy(),
"audit": console_audit.audit_policy(),
})
async def method_not_allowed(request: Request, _exc: Exception) -> Response: async def method_not_allowed(request: Request, _exc: Exception) -> Response:
path = request.url.path path = request.url.path
if path in _AUDIT_MUTATION_PATHS and request.method == "POST": if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
@@ -358,6 +433,11 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
methods=["POST"], methods=["POST"],
), ),
Route("/api/leases", api_leases, methods=["GET"]), Route("/api/leases", api_leases, methods=["GET"]),
Route(
"/api/console/security-model",
api_console_security_model,
methods=["GET"],
),
], ],
exception_handlers={405: method_not_allowed}, exception_handlers={405: method_not_allowed},
) )
+281
View File
@@ -0,0 +1,281 @@
"""Console audit event schema, retention, and append-only sink (#633).
``gitea_audit`` records MCP-side *mutations*: which profile and Gitea user
performed which tool call. It carries no console actor, no identity source, no
correlation identifier, and no retention class, so it cannot answer the
question #633 exists to answer — *who sat at the console, what did they
attempt, and was it authorized?* An authorization denial is not a mutation and
would never appear there at all.
This module adds the console-side record. It does not replace ``gitea_audit``:
when a Phase 2 action eventually reaches MCP, both fire, correlated by
``correlation.request_id``.
Design constraints:
- **Redact before persist.** Every record passes through
``webui.console_redaction.redact_payload`` before serialization, so an
unredacted field is never durable.
- **Append-only.** Records are appended as JSON lines. Nothing here updates or
deletes; retention is metadata on each record, enforced by an operator-run
policy, never by silent rewriting.
- **Never raises.** Auditing must not break the request it describes. A failed
write returns ``False``.
- **Off by default.** With ``WEBUI_CONSOLE_AUDIT_LOG`` unset, events are still
*built* (so callers and tests see the schema) but nothing is written.
A record looks like this (synthetic values):
{"schema_version": 1, "event_id": "evt-0001",
"timestamp": "2026-07-22T10:16:42+00:00",
"actor": {"subject": "[email protected]", "role": "operator",
"identity_source": "access_proxy", "authenticated": true},
"action": "merge_pr", "action_class": "privileged",
"target": {"kind": "pr", "ref": "#123"},
"result": "denied", "reason_code": "insufficient_role",
"correlation": {"request_id": "req-abc", "session_id": null,
"mcp_task": "merge_pr", "mcp_permission": "gitea.pr.merge"},
"retention": {"class": "privileged", "days": 365,
"expires_at": "2027-07-22T10:16:42+00:00"},
"redacted": true}
Timestamps are timezone-aware ISO-8601 in UTC.
"""
from __future__ import annotations
import datetime
import json
import os
import uuid
from typing import Any
from webui import console_authz
from webui.console_redaction import redact_payload, scan_for_secrets
SCHEMA_VERSION = 1
AUDIT_LOG_ENV = "WEBUI_CONSOLE_AUDIT_LOG"
# Result vocabulary. ``denied`` is the one ``gitea_audit`` has no equivalent
# for: an authorization refusal never reaches the MCP layer.
RESULT_ALLOWED = "allowed"
RESULT_DENIED = "denied"
RESULT_PREVIEWED = "previewed"
RESULT_FAILED = "failed"
RESULT_SUCCEEDED = "succeeded"
RESULTS = frozenset(
{
RESULT_ALLOWED,
RESULT_DENIED,
RESULT_PREVIEWED,
RESULT_FAILED,
RESULT_SUCCEEDED,
}
)
# Retention classes and default lifetimes in days. Privileged and break-glass
# records outlive routine ones because they are what an incident review needs.
RETENTION_STANDARD = "standard"
RETENTION_PRIVILEGED = "privileged"
RETENTION_BREAK_GLASS = "break_glass"
RETENTION_DAYS: dict[str, int] = {
RETENTION_STANDARD: 90,
RETENTION_PRIVILEGED: 365,
RETENTION_BREAK_GLASS: 730,
}
# Fields every record must carry. Asserted by the test suite so a future edit
# cannot quietly drop one.
REQUIRED_FIELDS: tuple[str, ...] = (
"schema_version",
"event_id",
"timestamp",
"actor",
"action",
"action_class",
"target",
"result",
"reason_code",
"correlation",
"retention",
"redacted",
)
REQUIRED_ACTOR_FIELDS: tuple[str, ...] = (
"subject",
"role",
"identity_source",
"authenticated",
)
REQUIRED_CORRELATION_FIELDS: tuple[str, ...] = (
"request_id",
"session_id",
"mcp_task",
"mcp_permission",
)
def audit_log_path() -> str | None:
"""Configured sink path, or ``None`` when console auditing is off."""
return (os.environ.get(AUDIT_LOG_ENV) or "").strip() or None
def audit_enabled() -> bool:
return audit_log_path() is not None
def retention_class_for(action: console_authz.ConsoleAction | None) -> str:
"""Classify retention from the action, defaulting to the longest-lived.
An unknown action is treated as privileged rather than standard: for a
safety control the conservative direction is to keep the record longer.
"""
if action is None:
return RETENTION_PRIVILEGED
if action.break_glass:
return RETENTION_BREAK_GLASS
if action.privileged:
return RETENTION_PRIVILEGED
return RETENTION_STANDARD
def _retention_block(
retention_class: str, now: datetime.datetime
) -> dict[str, Any]:
days = RETENTION_DAYS.get(
retention_class, RETENTION_DAYS[RETENTION_PRIVILEGED]
)
return {
"class": retention_class,
"days": days,
"expires_at": (now + datetime.timedelta(days=days)).isoformat(),
}
def build_event(
*,
action_id: str,
result: str,
decision: console_authz.AuthorizationDecision | None = None,
principal: console_authz.Principal | None = None,
target: dict[str, Any] | None = None,
reason_code: str | None = None,
request_id: str | None = None,
session_id: str | None = None,
detail: str | None = None,
metadata: dict[str, Any] | None = None,
now: datetime.datetime | None = None,
event_id: str | None = None,
) -> dict[str, Any]:
"""Build one redacted, JSON-able console audit record.
Redaction runs here rather than at write time so an in-memory record handed
to a template or an API response is already clean.
"""
ts = now or datetime.datetime.now(datetime.timezone.utc)
action = console_authz.get_action(action_id)
who = principal or (
decision.principal if decision else console_authz.ANONYMOUS
)
resolved_result = result if result in RESULTS else RESULT_FAILED
resolved_reason = reason_code or (
decision.reason_code if decision else "unspecified"
)
retention_class = retention_class_for(action)
event: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"event_id": event_id or f"evt-{uuid.uuid4().hex}",
"timestamp": ts.isoformat(),
"actor": who.to_dict(),
"action": action_id,
"action_class": action.action_class if action else "unknown",
"target": dict(target or {}),
"result": resolved_result,
"reason_code": resolved_reason,
"correlation": {
"request_id": request_id,
"session_id": session_id,
"mcp_task": action.task_key if action else None,
"mcp_permission": action.mcp_permission if action else None,
},
"retention": _retention_block(retention_class, ts),
"redacted": True,
"detail": detail,
"metadata": dict(metadata or {}),
}
if decision is not None:
# Deliberately *not* named "authorization": ``gitea_audit`` treats that
# substring as a secret key hint (it matches the HTTP Authorization
# header) and would replace this whole block with the placeholder.
event["decision"] = {
"allowed": decision.allowed,
"required_role": decision.required_role,
"requires_confirmation": decision.requires_confirmation,
"dual_control": decision.dual_control,
"break_glass": decision.break_glass,
"execution_enabled": decision.execution_enabled,
}
redacted = redact_payload(event)
if not isinstance(redacted, dict): # pragma: no cover - defensive
return {"schema_version": SCHEMA_VERSION, "redacted": True}
return redacted
def write_event(event: dict[str, Any], path: str | None = None) -> bool:
"""Append *event* as one JSON line. Never raises.
Returns ``True`` when a line was written, ``False`` when auditing is off or
the write failed. A record that still trips a secret detector is dropped
rather than persisted.
"""
sink = path or audit_log_path()
if not sink:
return False
try:
if scan_for_secrets(event):
return False
line = json.dumps(event, default=str, sort_keys=True)
with open(sink, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
return True
except Exception:
return False
def record_event(**kwargs: Any) -> dict[str, Any]:
"""Build and persist one record; return the record either way.
Callers get the record back so it can be surfaced in a response or a test
regardless of whether a sink is configured.
"""
event = build_event(**kwargs)
written = write_event(event)
return {"event": event, "written": written}
def audit_policy() -> dict[str, Any]:
"""Machine-readable audit schema and retention defaults (never secrets)."""
return {
"schema_version": SCHEMA_VERSION,
"required_fields": list(REQUIRED_FIELDS),
"required_actor_fields": list(REQUIRED_ACTOR_FIELDS),
"required_correlation_fields": list(REQUIRED_CORRELATION_FIELDS),
"results": sorted(RESULTS),
"retention_defaults_days": dict(RETENTION_DAYS),
"sink_env": AUDIT_LOG_ENV,
"enabled": audit_enabled(),
"append_only": True,
"redact_before_persist": True,
"timestamp_format": "ISO-8601, timezone-aware, UTC",
"relationship_to_mcp_audit": (
"webui.console_audit records console intent and authorization "
"outcomes; gitea_audit records MCP mutations. A Phase 2 action "
"emits both, correlated by correlation.request_id."
),
}
+537
View File
@@ -0,0 +1,537 @@
"""Console authorization and RBAC model (#633, Phase 1).
The read-only MVP (#426#436) ships with no authentication: protection comes
from network placement alone (#435). That is adequate while every route is a
GET, and inadequate the moment Phase 2 wires a gated write. This module is the
authorization model those writes must go through, landed *before* any of them
exists so no write can be added without an authority to check against.
Phase 1 scope is the model itself: identity resolution, the role matrix, the
privileged-action list, and a fail-closed :func:`authorize`. It deliberately
does **not** enable any write. ``webui.gated_actions`` stays globally disabled,
so an allow decision here is necessary but never sufficient.
Two invariants hold for every caller:
- **Default deny.** An unrecognised action, an unknown role, or an absent
principal denies. There is no implicit allow branch and no "unless" clause.
- **Authorization is not execution.** :func:`authorize` returns a decision
record. It never calls MCP, never mutates, and never consults credentials.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from typing import Any
from task_capability_map import required_permission, required_role
# --- Roles ------------------------------------------------------------------
# Ordered least to most authority. Higher ranks inherit every lower rank's
# permitted actions; the matrix below is expressed as a minimum required rank.
VIEWER = "viewer"
OPERATOR = "operator"
CONTROLLER = "controller"
ADMIN = "admin"
ROLE_ORDER: tuple[str, ...] = (VIEWER, OPERATOR, CONTROLLER, ADMIN)
_ROLE_RANK: dict[str, int] = {role: idx for idx, role in enumerate(ROLE_ORDER)}
ROLE_DESCRIPTIONS: dict[str, str] = {
VIEWER: "Read every console view. No write, ever, in any phase.",
OPERATOR: "Viewer, plus author-class work: claim, comment, open a PR.",
CONTROLLER: "Operator, plus reviewer/merger-class decisions on a PR.",
ADMIN: "Controller, plus destructive and policy-editing actions.",
}
# --- Identity sources -------------------------------------------------------
IDENTITY_NONE = "none"
IDENTITY_LOCAL_DEV = "local_dev"
IDENTITY_ACCESS_PROXY = "access_proxy"
IDENTITY_SOURCES: dict[str, dict[str, Any]] = {
IDENTITY_NONE: {
"description": (
"No authentication configured. Every request is anonymous and "
"capped at viewer. This is the MVP default and the only mode "
"whose safety rests entirely on network placement (#435)."
),
"authenticated": False,
"safe_for_shared_host": False,
"phase_available": 1,
},
IDENTITY_LOCAL_DEV: {
"description": (
"Developer-supplied principal read from the environment. INSECURE: "
"the subject and role are asserted, never verified. Loopback only."
),
"authenticated": True,
"safe_for_shared_host": False,
"phase_available": 1,
},
IDENTITY_ACCESS_PROXY: {
"description": (
"Subject asserted by a trusted access proxy (Cloudflare Access, "
"WARP, or an org VPN portal) via a verified request header. The "
"proxy performs authentication; the console performs authorization."
),
"authenticated": True,
"safe_for_shared_host": True,
"phase_available": 2,
},
}
# Environment configuration. All are read server-side and never rendered.
AUTH_MODE_ENV = "WEBUI_AUTH_MODE"
DEV_SUBJECT_ENV = "WEBUI_DEV_SUBJECT"
DEV_ROLE_ENV = "WEBUI_DEV_ROLE"
ROLE_MAP_ENV = "WEBUI_ROLE_MAP"
REQUIRE_PROBE_AUTH_ENV = "WEBUI_REQUIRE_PROBE_AUTH"
ACCESS_SUBJECT_HEADER = "cf-access-authenticated-user-email"
# --- Action classes ---------------------------------------------------------
CLASS_READ = "read"
CLASS_WRITE = "gated_write"
CLASS_PRIVILEGED = "privileged"
CLASS_DESTRUCTIVE = "destructive"
# --- Privileged action list -------------------------------------------------
# ``task_key`` ties each console action back to ``task_capability_map``, so the
# console cannot invent an authority the MCP layer does not already define.
@dataclass(frozen=True)
class ConsoleAction:
"""One console action and the authority required to invoke it."""
action_id: str
task_key: str
action_class: str
minimum_role: str
requires_confirmation: bool
dual_control: bool
break_glass: bool
phase: int
summary: str
@property
def mcp_permission(self) -> str:
return required_permission(self.task_key)
@property
def mcp_role(self) -> str:
return required_role(self.task_key)
@property
def privileged(self) -> bool:
return self.action_class in {CLASS_PRIVILEGED, CLASS_DESTRUCTIVE}
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["mcp_permission"] = self.mcp_permission
data["mcp_role"] = self.mcp_role
data["privileged"] = self.privileged
return data
_ACTION_SPECS: tuple[ConsoleAction, ...] = (
ConsoleAction(
action_id="claim_issue",
task_key="claim_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Apply status:in-progress to an issue.",
),
ConsoleAction(
action_id="comment_issue",
task_key="comment_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Post an issue comment.",
),
ConsoleAction(
action_id="create_issue",
task_key="create_issue",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Open a new tracking issue.",
),
ConsoleAction(
action_id="comment_pr",
task_key="comment_pr",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Post a PR thread comment.",
),
ConsoleAction(
action_id="create_pr",
task_key="create_pr",
action_class=CLASS_WRITE,
minimum_role=OPERATOR,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=2,
summary="Open a PR from a locked feature branch.",
),
ConsoleAction(
action_id="review_pr",
task_key="review_pr",
action_class=CLASS_PRIVILEGED,
minimum_role=CONTROLLER,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=3,
summary="Submit an approve / request-changes verdict.",
),
ConsoleAction(
action_id="close_pr",
task_key="close_pr",
action_class=CLASS_PRIVILEGED,
minimum_role=CONTROLLER,
requires_confirmation=True,
dual_control=False,
break_glass=False,
phase=3,
summary="Close a pull request without merging.",
),
ConsoleAction(
action_id="merge_pr",
task_key="merge_pr",
action_class=CLASS_PRIVILEGED,
minimum_role=CONTROLLER,
requires_confirmation=True,
dual_control=True,
break_glass=True,
phase=3,
summary="Merge an approved pull request.",
),
ConsoleAction(
action_id="delete_branch",
task_key="delete_branch",
action_class=CLASS_DESTRUCTIVE,
minimum_role=ADMIN,
requires_confirmation=True,
dual_control=True,
break_glass=True,
phase=3,
summary="Remove a remote feature branch.",
),
)
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
def privileged_actions() -> tuple[ConsoleAction, ...]:
"""Actions requiring dual control, break-glass, or controller+ authority."""
return tuple(a for a in _ACTION_SPECS if a.privileged)
def get_action(action_id: str) -> ConsoleAction | None:
return ACTIONS.get(action_id)
# --- Principals -------------------------------------------------------------
@dataclass(frozen=True)
class Principal:
"""Who is making a request, and how strongly that is known."""
subject: str
role: str
identity_source: str
authenticated: bool
warnings: tuple[str, ...] = field(default_factory=tuple)
@property
def rank(self) -> int:
return _ROLE_RANK.get(self.role, -1)
def to_dict(self) -> dict[str, Any]:
return {
"subject": self.subject,
"role": self.role,
"identity_source": self.identity_source,
"authenticated": self.authenticated,
"warnings": list(self.warnings),
}
ANONYMOUS = Principal(
subject="anonymous",
role=VIEWER,
identity_source=IDENTITY_NONE,
authenticated=False,
warnings=("No authentication configured; capped at viewer.",),
)
def auth_mode(env: dict[str, str] | None = None) -> str:
"""Resolve the configured identity source, defaulting to ``none``."""
source = env if env is not None else os.environ
raw = (source.get(AUTH_MODE_ENV) or "").strip().lower().replace("-", "_")
if raw in IDENTITY_SOURCES:
return raw
return IDENTITY_NONE
def _role_map(env: dict[str, str]) -> dict[str, str]:
"""Parse ``WEBUI_ROLE_MAP`` (JSON subject→role). Invalid config yields {}."""
raw = (env.get(ROLE_MAP_ENV) or "").strip()
if not raw:
return {}
try:
parsed = json.loads(raw)
except Exception:
return {}
if not isinstance(parsed, dict):
return {}
return {
str(k): str(v).strip().lower()
for k, v in parsed.items()
if str(v).strip().lower() in _ROLE_RANK
}
def resolve_principal(
headers: dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> Principal:
"""Resolve the requesting principal. Unknown or unconfigured → anonymous.
Never raises and never trusts a client-supplied role: the role always comes
from server-side configuration keyed by the resolved subject.
"""
source_env = dict(env) if env is not None else dict(os.environ)
lowered = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
mode = auth_mode(source_env)
if mode == IDENTITY_LOCAL_DEV:
subject = (source_env.get(DEV_SUBJECT_ENV) or "").strip()
if not subject:
return ANONYMOUS
role = (source_env.get(DEV_ROLE_ENV) or VIEWER).strip().lower()
if role not in _ROLE_RANK:
role = VIEWER
return Principal(
subject=subject,
role=role,
identity_source=IDENTITY_LOCAL_DEV,
authenticated=True,
warnings=(
"local-dev identity is asserted, not verified; never use "
"outside loopback.",
),
)
if mode == IDENTITY_ACCESS_PROXY:
subject = (lowered.get(ACCESS_SUBJECT_HEADER) or "").strip()
if not subject:
# Proxy mode with no proxy header means the request did not
# traverse the proxy. Fail closed rather than trust it.
return ANONYMOUS
role = _role_map(source_env).get(subject, VIEWER)
return Principal(
subject=subject,
role=role,
identity_source=IDENTITY_ACCESS_PROXY,
authenticated=True,
)
return ANONYMOUS
def probe_auth_required(env: dict[str, str] | None = None) -> bool:
"""Whether non-public probes must be authenticated. Default False.
#633 requires the console to *fail closed on missing auth for non-public
health probes if configured*. The default stays off so the MVP ``/health``
contract is unchanged; an operator opts in explicitly.
"""
source = env if env is not None else os.environ
return (source.get(REQUIRE_PROBE_AUTH_ENV) or "").strip().lower() in {
"1",
"true",
"yes",
}
# --- Authorization ----------------------------------------------------------
DENY_UNKNOWN_ACTION = "unknown_action"
DENY_UNAUTHENTICATED = "unauthenticated"
DENY_INSUFFICIENT_ROLE = "insufficient_role"
DENY_UNKNOWN_ROLE = "unknown_role"
DENY_PHASE_NOT_ACTIVE = "phase_not_active"
ALLOW_PREVIEW = "allowed_preview_only"
# Phase 1 is the only active console phase. Phase 2 opens gated writes and is
# gated on this model landing; nothing here enables it.
ACTIVE_PHASE = 1
@dataclass(frozen=True)
class AuthorizationDecision:
"""Result of an authorization check. Never an execution grant."""
allowed: bool
reason_code: str
detail: str
action_id: str
principal: Principal
required_role: str | None = None
action_class: str | None = None
requires_confirmation: bool = False
dual_control: bool = False
break_glass: bool = False
execution_enabled: bool = False
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason_code": self.reason_code,
"detail": self.detail,
"action_id": self.action_id,
"principal": self.principal.to_dict(),
"required_role": self.required_role,
"action_class": self.action_class,
"requires_confirmation": self.requires_confirmation,
"dual_control": self.dual_control,
"break_glass": self.break_glass,
"execution_enabled": self.execution_enabled,
"active_phase": ACTIVE_PHASE,
}
def authorize(
action_id: str,
principal: Principal | None = None,
*,
for_execution: bool = False,
) -> AuthorizationDecision:
"""Decide whether *principal* may invoke *action_id*. Deny by default.
``for_execution`` distinguishes a read-only preview from a real invocation.
Even an allowed decision reports ``execution_enabled=False`` while the
console is in Phase 1, so no caller can read an allow as permission to
mutate.
"""
who = principal if principal is not None else ANONYMOUS
action = get_action(action_id)
if action is None:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_UNKNOWN_ACTION,
detail=f"No console action registered as {action_id!r}.",
action_id=action_id,
principal=who,
)
base: dict[str, Any] = {
"action_id": action_id,
"principal": who,
"required_role": action.minimum_role,
"action_class": action.action_class,
"requires_confirmation": action.requires_confirmation,
"dual_control": action.dual_control,
"break_glass": action.break_glass,
"execution_enabled": False,
}
if not who.authenticated:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_UNAUTHENTICATED,
detail=(
"Write actions require an authenticated principal; this "
"request is anonymous."
),
**base,
)
if who.rank < 0:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_UNKNOWN_ROLE,
detail=f"Role {who.role!r} is not in the console role matrix.",
**base,
)
if who.rank < _ROLE_RANK[action.minimum_role]:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_INSUFFICIENT_ROLE,
detail=(
f"Action {action_id!r} requires {action.minimum_role!r}; "
f"principal holds {who.role!r}."
),
**base,
)
if for_execution and action.phase > ACTIVE_PHASE:
return AuthorizationDecision(
allowed=False,
reason_code=DENY_PHASE_NOT_ACTIVE,
detail=(
f"Action {action_id!r} belongs to phase {action.phase}; the "
f"console is in phase {ACTIVE_PHASE}. Execution is not wired."
),
**base,
)
return AuthorizationDecision(
allowed=True,
reason_code=ALLOW_PREVIEW,
detail=(
"Principal holds the required role. Preview only — execution "
"remains disabled until the Phase 2 action framework ships."
),
**base,
)
def rbac_matrix() -> dict[str, Any]:
"""Machine-readable RBAC matrix and privileged-action list."""
return {
"model_version": 1,
"active_phase": ACTIVE_PHASE,
"roles": [
{
"role": role,
"rank": _ROLE_RANK[role],
"description": ROLE_DESCRIPTIONS[role],
"permitted_actions": sorted(
a.action_id
for a in _ACTION_SPECS
if _ROLE_RANK[role] >= _ROLE_RANK[a.minimum_role]
),
}
for role in ROLE_ORDER
],
"identity_sources": IDENTITY_SOURCES,
"actions": [a.to_dict() for a in _ACTION_SPECS],
"privileged_actions": [a.action_id for a in privileged_actions()],
"default_decision": "deny",
"execution_enabled": False,
}
+169
View File
@@ -0,0 +1,169 @@
"""Secret redaction policy for every console surface (#633).
The MVP already redacts MCP-side mutation records through ``gitea_audit``.
This module is the console-facing policy: one redaction pass applied to API
payloads, rendered HTML, log lines, and audit records *before* they leave the
server or reach persistent storage.
Design constraints:
- **Reuse, never fork.** ``gitea_audit.redact`` remains the authority for
secret-looking dict keys, ``Authorization`` material, and raw URLs. This
module runs that pass first and then applies console-specific patterns for
keychain references, key/value assignments, private-key blocks, and JWTs.
- **Never raises.** Redaction is a safety control; a malformed payload must
degrade to a redacted placeholder rather than propagate an exception.
- **Redact before persist.** ``webui.console_audit`` calls this module before
writing, so an unredacted record is never durable.
"""
from __future__ import annotations
import json
import re
from typing import Any
import gitea_audit
REDACTED = gitea_audit.REDACTED
# Console-specific patterns applied after the shared ``gitea_audit`` pass.
# Each keeps the identifying key so an operator can still tell *what* was
# removed, and replaces only the secret run itself.
_KEYCHAIN_REF = re.compile(r"(?i)\bkeychain:[\w.\-/@]+")
_KEYCHAIN_CMD = re.compile(
r"(?i)\bsecurity\s+find-(?:generic|internet)-password\b[^\n]*"
)
_ASSIGNMENT = re.compile(
r"(?i)\b(token|password|passwd|secret|api[_-]?key|access[_-]?key|"
r"client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)"
r"(\"[^\"]*\"|'[^']*'|\S+)"
)
_ENV_ASSIGNMENT = re.compile(
r"(?i)\b(GITEA_(?:TOKEN|PASS|PASSWORD)[A-Z0-9_]*)(\s*=\s*)"
r"(\"[^\"]*\"|'[^']*'|\S+)"
)
_PRIVATE_KEY_BLOCK = re.compile(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
re.S,
)
_JWT = re.compile(
r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b"
)
# Shapes that mean a payload still carries a secret. ``scan_for_secrets`` uses
# these to assert a surface is clean.
_DETECTORS: tuple[tuple[str, re.Pattern[str]], ...] = (
("keychain_reference", _KEYCHAIN_REF),
("keychain_command", _KEYCHAIN_CMD),
("credential_assignment", _ASSIGNMENT),
("credential_env_assignment", _ENV_ASSIGNMENT),
("private_key_block", _PRIVATE_KEY_BLOCK),
("json_web_token", _JWT),
("bearer_credential", re.compile(r"(?i)\b(?:bearer|basic)\s+\S{8,}")),
)
def _mask_assignment(match: re.Match[str]) -> str:
"""Keep the key and separator, replace the value."""
return f"{match.group(1)}{match.group(2)}{REDACTED}"
def redact_text(text: Any) -> Any:
"""Redact secret material from a single string.
Non-strings are returned unchanged so this is safe to map over mixed
payloads. Runs the shared ``gitea_audit`` pass first, then the
console-specific patterns.
"""
if not isinstance(text, str) or not text:
return text
try:
out = gitea_audit.redact(text)
if not isinstance(out, str): # defensive; redact() returns str for str
return REDACTED
out = _PRIVATE_KEY_BLOCK.sub(f"{REDACTED}_PRIVATE_KEY", out)
out = _ENV_ASSIGNMENT.sub(_mask_assignment, out)
out = _ASSIGNMENT.sub(_mask_assignment, out)
out = _KEYCHAIN_CMD.sub(f"{REDACTED}_KEYCHAIN_COMMAND", out)
out = _KEYCHAIN_REF.sub(f"{REDACTED}_KEYCHAIN_REF", out)
out = _JWT.sub(f"{REDACTED}_JWT", out)
return out
except Exception:
# Fail closed: an unredactable string is dropped rather than emitted raw.
return REDACTED
def redact_payload(value: Any) -> Any:
"""Recursively redact a JSON-able payload for any console surface.
Secret-looking dict keys are replaced wholesale by the shared
``gitea_audit`` policy; every remaining string is run through
:func:`redact_text`.
"""
try:
shared = gitea_audit.redact(value)
except Exception:
return REDACTED
return _walk(shared)
def _walk(value: Any) -> Any:
if isinstance(value, dict):
return {k: _walk(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_walk(v) for v in value]
if isinstance(value, str):
return redact_text(value)
return value
def scan_for_secrets(value: Any) -> list[str]:
"""Return detector names that still match *value* after serialization.
Used to assert an outbound payload or rendered page is clean. An empty
list means no known secret shape was found. Already-redacted hits are not
findings.
"""
if isinstance(value, str):
text = value
else:
try:
text = json.dumps(value, default=str)
except Exception:
text = str(value)
findings: list[str] = []
for name, pattern in _DETECTORS:
for match in pattern.finditer(text):
if REDACTED in match.group(0):
continue
findings.append(name)
break
return findings
def redaction_policy() -> dict[str, Any]:
"""Machine-readable statement of the redaction rules (never secrets)."""
return {
"policy_version": 1,
"applies_to": [
"json_api_responses",
"rendered_html",
"server_logs",
"audit_records",
],
"ordering": "shared gitea_audit pass, then console patterns",
"redact_before_persist": True,
"shared_rules": {
"source": "gitea_audit.redact",
"secret_key_hints": list(gitea_audit._SECRET_KEY_HINTS),
"secret_value_prefixes": list(gitea_audit._SECRET_VALUE_PREFIXES),
"urls": "credentials, secret query parameters, and real hosts redacted",
},
"console_rules": [
{"name": name, "pattern": pattern.pattern}
for name, pattern in _DETECTORS
],
"placeholder": REDACTED,
"failure_mode": "fail closed — unredactable values become the placeholder",
}
+5 -3
View File
@@ -65,9 +65,11 @@ PROMPT_RECONCILER = (
"reconciliation (already-landed / post-merge cleanup). Do not approve or merge." "reconciliation (already-landed / post-merge cleanup). Do not approve or merge."
) )
PROMPT_CONTROLLER = ( PROMPT_CONTROLLER = (
"CONTROLLER session: inspect gitea_workflow_dashboard + control-plane leases, " "CONTROLLER session: call gitea_route_task_session(task_type='process_work_queue') "
"diagnose blocked/terminal-locked items for {remote}/{org}/{repo}, and schedule " "then gitea_allocate_next_work (cross_role default) for {remote}/{org}/{repo}; "
"exactly one fresh role-scoped cycle. Do not implement, review, or merge in-band." "use the returned required_role/profile/action to schedule exactly one downstream "
"role cycle. Dashboard is explanatory only and never replaces allocator selection. "
"Do not implement, review, approve, or merge in-band."
) )
PROMPT_IDLE = ( PROMPT_IDLE = (
"IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. " "IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. "