Root-cause follow-up to #733. `_resolve` (the actual API-targeting resolver)
already prefers the workspace-aligned git remote over the `REMOTES` default
(bare `prgs` -> Scaled-Tech-Consulting/Timesheet). The shared #604 anti-stomp
preflight (`_run_anti_stomp_preflight`) did not mirror that: for every mutation
task whose call site omitted org/repo it filled the remote-wide `REMOTES`
default, so the #530 repo guard false-positived `wrong_repo` against a
Gitea-Tools checkout and blocked native issue/PR/review/merge/lock/cleanup
mutations at preflight — even though the resolve stage would have targeted the
workspace repo correctly.
Fix: for omitted coordinates, `_run_anti_stomp_preflight` now prefers the
trusted git-remote-derived workspace identity (marking those filled sides
explicit, exactly like `_resolve`) for session-bound mutation tasks. This fixes
every remaining preflight call site at the shared chokepoint without changing
the global `REMOTES` prgs default (still the best-effort last resort when no
workspace identity can be derived). `delete_branch` keeps its explicit
cross-repository target contract (#733) via `_EXPLICIT_TARGET_MUTATION_TASKS`
and still fails closed on omitted coordinates.
Tests: tests/test_preflight_workspace_repo_forwarding.py (session-bound
resolution across 20 tasks, #530-guard pass, explicit-coords passthrough,
no-workspace REMOTES fallback, delete_branch contract preserved). Full suite
3228 passed / 6 skipped / 240 subtests (1 pre-existing env-leak failure in
test_issue_702_review_findings_f1_f6, fails identically on baseline master).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
gitea_delete_branch accepted explicit org/repo but did not propagate them
through the anti-stomp preflight. During a deletion targeting
Scaled-Tech-Consulting/Gitea-Tools, preflight resolved the remote-wide prgs
default (Scaled-Tech-Consulting/Timesheet) and failed closed with wrong_repo
before any deletion — because verify_preflight_purity was called without
org/repo and the shared #604 anti-stomp resolution fell back to the REMOTES
default.
Fix (delete_branch only; REMOTES untouched):
1. Forward the explicit org/repo into verify_preflight_purity so the shared
#604 anti-stomp resolution validates the *targeted* repository instead of
the remote-wide default. Explicit Scaled-Tech-Consulting/Gitea-Tools now
propagates through role/workspace verification, verify_preflight_purity,
anti-stomp repository resolution, and the final native deletion request.
2. Add a workspace-derived repository-binding gate
(_delete_branch_repository_binding_block) that validates explicit
coordinates against the immutable, workspace-aligned repository identity.
Wrong, substituted, or unverified coordinates fail closed — independent of
the anti-stomp remote/repo guard, which by the #530 contract trusts explicit
caller intent. REMOTES defaults are never consulted as an authorization
scope.
Preserved: reconciler-only ownership of gitea.branch.delete; author/reviewer/
merger denial; protected and preservation/evidence gates; missing coordinates
still fail closed via the anti-stomp default resolution.
Regression matrix (tests/test_issue_733_delete_branch_repo_forwarding.py):
Timesheet-default + explicit Gitea-Tools permits an eligible deletion and
forwards the coordinates; wrong/substituted/unverified coordinates fail closed;
author/reviewer/merger remain denied; protected and preservation branches
remain blocked; anti-stomp resolution marks explicit coordinates authoritative
and fails closed on the remote-wide default for omitted coordinates; the
task-capability and role-routing maps stay consistent.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UVDxVKANhuGYzZgayDU3QS
Mutation tools (create_issue, create_pr, delete_branch, issue-lock) returned
reason_code=internal_error / retryable=false with no class, message, HTTP
status, or actionable detail. The #699/#701 tool-error boundary intentionally
collapses every non-typed exception to a fixed "Internal tool error" and drops
the class and message, so a mutation failure is undiagnosable — and the #695
runtime guard blocks standalone reproduction. The true failing stage was
therefore invisible by two independent mechanisms.
Make the failure observable and actionable without weakening the secret-free
contract:
- Boundary (internal_error path ONLY): capture a safe exception class (a Python
type identifier, never instance text) plus a strictly-redacted `detail`
(token/Authorization/Bearer credentials, JSON/kv secret values incl. short
passwords, raw URLs/hostnames, and absolute filesystem paths all stripped;
whitespace collapsed; length-bounded) and an optional `mutation_stage`. Typed
auth/authz/network/config/http paths are unchanged and still emit no detail.
- Convert raw exceptions into typed structured errors via a safe
`gitea_reason_code` attribute contract: server raisers may self-declare a
known reason and the boundary emits it (fixed message) instead of an opaque
internal_error. Pre-flight order violations now raise `_PreflightOrderError`
(a RuntimeError subclass → preflight_order_violation).
- Add a `_mutation_stage` context manager tagging escaping exceptions with the
stage name (preflight_purity / resolve_auth / api_*), applied to
delete_branch, create_issue, create_pr.
Fail-closed identity/repo/worktree/role/permission/lock/lease/ancestry gates
are untouched; only error *reporting* changed. New suite
test_mutation_error_diagnostics.py (18 tests) pins the redaction, class capture,
stage tagging, typed conversion, and adversarial no-leak contract. Existing
boundary suite (25), core server (209), and preflight (92) suites stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Remote post-merge branch deletion was blocked because the capability
resolver classified delete_branch as an author-role task while
gitea.branch.delete is granted only to prgs-reconciler. No configured
profile could satisfy both the permission gate and the role gate, so
every deletion failed closed with performed=false.
Re-home delete_branch from the author role to the reconciler role in
both single-source-of-truth maps:
- task_capability_map.TASK_CAPABILITY_MAP["delete_branch"].role
- role_session_router: TASK_REQUIRED_ROLE + AUTHOR_TASKS/RECONCILER_TASKS
resolve_task_capability("delete_branch") now resolves to prgs-reconciler.
In gitea_delete_branch, the reconciler is now the delete-capable role and
performs raw deletion through the existing reconciliation-cleanup-phase
authorization gate (operator approval + safe_to_delete proof) plus the
preservation/protected guards; the prior reconciler->cleanup redirect is
removed as it would orphan that guarded flow. Author, reviewer, and
merger stay denied by the permission gate and the required-role gate.
gitea_cleanup_merged_pr_branch remains a separate reconciler-owned path
with independent merged/ancestry/ownership verification.
Tests: reconciler resolves + performs eligible deletion; author/reviewer/
merger denied even when holding the permission; protected/preservation
branches remain fail-closed; both role maps asserted in agreement.
Updated pre-existing tests that encoded the superseded author-delete /
#687 reconciler-redirect contract. Full suite: 3190 passed, 6 skipped,
1 pre-existing unrelated failure (test_issue_702 create_pr stale-binding).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UracxyRHQw49rZBaexHdft
_verify_role_mutation_workspace dropped org/repo, so anti-stomp resolved bare
remote=prgs to Timesheet and failed closed wrong_repo on Gitea-Tools. Forward
explicit org/repo for merger/reviewer lease acquire, adopt, review, merge, and
lease release. Add regression for merger acquire forwarding.
Fixes#718
Refs #723
gitea_acquire_merger_pr_lease previously continued when GET /pulls returned
an empty head SHA. Fail closed so exact-head scoping never proceeds with an
unresolvable live head. Add regression coverage for empty head payloads.
Fixes#718
Refs #723
Prevent approved PRs from stalling when master advances. Add
gitea_assess_pr_sync_status and gitea_update_pr_branch_by_merge with
head/base pinning, author-only updates, conflict handoff, and approval
invalidation after head changes. Wire task capability map, sequential
controller routing in review-merge workflow, and hermetic AC tests.
Harden gitea_acquire_merger_pr_lease with required candidate_head, live-head
match, and fail-closed POST handling. Add capability-map aliases for reviewer
and merger lease acquire/adopt. Make unknown tasks return structured
unknown_task (no ValueError escape). Expand tests for roles, head scoping,
aliases, and unknown_task.
Closes#718. Addresses #723 resolver unknown_task and lease task mapping.
This implements the native merger lease acquisition tool to allow a merger session to acquire its own lease when a reviewer lease does not exist or has expired, unblocking #679 adoption.
Stale-runtime detection remains fail-closed, but gitea_resolve_task_capability
must never touch mcp_config.json, spawn recovery threads, or call os._exit.
- Remove _trigger_mcp_auto_restart from the read-only diagnostics path
- Return blocker_kind=runtime_reconnect_required with mutation_performed=false
- Keep exact_safe_next_action pointing at IDE/client reconnect when stale
- Add regression suite across author/reviewer/merger/reconciler profiles
Terminal review-decision ledgers are recovery-critical provenance, not
disposable session cache. A generic four-hour TTL previously made
fresh_review_on_current_head_allowed unreachable after age expiry on open
PRs (PR #616 / review 443 reproduction).
- Classify KIND_DECISION_LOCK as RECOVERY_CRITICAL so load/mark_final work
after >4h without hand-editing session-state files
- Stamp kind + recovery_critical on save for compatibility
- Add inspect_state_envelope so assessment reports on-disk evidence instead
of silent "no lock" when TTL would hide non-critical kinds
- Surface disk_inspect on stale decision-lock cleanup assessment
Preserves same-head #332 hard-stop, #620 head-scoped fresh review, #594
moot cleanup, and #709 irrecoverable authorization (no ordinary-profile
permission grant).
Require branch-scoped post-delete not-found (not generic/repo/host 404),
emit consistent top-level cleanup fields, fail closed on ownership inventory
errors, never auto-reclaim expired control-plane leases, include active
comment-backed reviewer leases, apply the same gates to reconcile_merged_cleanups,
and match ownership with normalized host identity.
Require authoritative not-found readback after merged-PR branch DELETE, and
block cleanup when active author/reviewer/merger/controller/reconciler
session, lease, or worktree-binding ownership still uses the target branch.
Close the #714 remediation gap left at 943d402 where env-only mutation
profiles, REMOTES Timesheet defaults treated as explicit caller input, and
machine-dependent Git remotes produced false greens.
- Fail closed when mutations lack a config-backed profile with non-empty
allowed_repositories; env ops cannot invent mutation authority.
- Preserve omitted-vs-explicit org/repo provenance through the mutation
gate; omitted targets bind to the verified workspace repository.
- Prefer workspace-aligned Git remotes over historical Timesheet defaults
in MCP _resolve and CLI resolve_remote.
- Centralize deterministic config-backed mutation fixtures; migrate
mutation tests off env-only authority without weakening security
assertions.
Full suite: 2744 passed, 6 skipped.
The independent review reproduced a real hole: the production first-bind path
never pinned repository/org, so the drift checks it feeds were skipped.
Root cause
----------
gitea_whoami, gitea_get_runtime_context, gitea_resolve_task_capability and
gitea_activate_profile all seeded the session context without repository or
org. First-bind-wins then made the mutation gate's later seed a no-op, and
assess_session_context only compares repository/org when the bound fields are
non-null. Result, reproduced in production order with the real REMOTES:
after whoami: repository=None org=None
same-host repo=Other-Tools -> NOT BLOCKED
same-host org=Other-Org -> NOT BLOCKED
cross-host -> blocked (this part always worked)
Binding REMOTES defaults would not fix it: REMOTES['prgs'].repo is 'Timesheet'
(a default *target*, not an authorization scope, see #530), so pinning it fails
closed on every legitimate Gitea-Tools mutation. There was no trusted source
for repository scope, so this adds one.
Design
------
- New optional profile field `allowed_repositories`: canonical owner/repository
slugs. An authorization boundary, never the binding itself. Config-only —
no env var can widen or forge it.
- The session repository is derived from the verified, workspace-aligned git
remote, never from caller-supplied org/repo, and validated against that
allowlist. The session binds immutably to exactly one canonical slug; org is
derived from it. A profile authorizing several repositories still binds to
the single verified one.
- Every first-bind entry point now pins the same complete context. Activation
rejects an unauthorized/unverifiable workspace. Mutations fail closed when
repository/org is unverified, and a tool-level org/repo override that
disagrees with the binding is rejected before the write.
- A mutation request can no longer establish, complete, or replace the binding
(it previously seeded from `org or REMOTES[...]`, i.e. caller-controlled).
- Enforcement is opt-in per profile: a profile without the field keeps prior
behaviour, so existing static-profile namespaces stay functional.
First-bind-wins, immutability, the RLock, profile isolation, host/identity
validation and the private pytest-only reset are unchanged.
gitea_auth.get_profile() built an explicit whitelist dict and silently dropped
`allowed_repositories`; the new integration tests caught that the scope was
never enforced through the real path.
Tests
-----
New tests/test_issue_714_production_first_bind.py drives the real entry points
in production order rather than constructing _SessionContext directly. Covers
complete first bind via each entry point, same-host repo/org drift, Timesheet
and other-repo/other-org rejection, unverified and unauthorized workspaces,
caller values unable to establish the binding, concurrent init selecting one
repository, and the PR #715 author path staying functional. Mutation-verified:
disabling trusted derivation, override validation, the scope check, or the
get_profile passthrough each fails these tests (9/8/4/5 failures).
No security assertion was weakened, removed, skipped, or rewritten.
Focused: 26 passed. Issue #714 total: 46 passed. Prior failing modules: 240
passed. Config/profile/remote modules: 130 passed. Full suite: 2739 passed,
6 skipped, 1 pre-existing warning, 161 subtests in 25.80s.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Address formal review 438 REQUEST_CHANGES on PR #710 (F6/F7/F8).
F6 — HMAC key-version validation fails closed:
- verify_authorization_artifact validates key_version BEFORE any MAC work, so an
attacker-chosen version can never select the signing key.
- Require exactly one nonempty, well-formed key-version field; missing, empty,
unknown, malformed, duplicated (including identical-valued and nested
aliases), and mismatched versions all fail. No versionless legacy fallback.
- Artifact version must equal the configured active version; production now
requires GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION explicitly (an implicit
default made rotation ambiguous). Version stays inside the signed material.
F7 — strictly canonical incident evidence:
- Replace substring/first-match parsing with an exact schema: marker on line 1,
every field once, fixed order, no duplicate/unknown/empty/conflicting fields
in or outside the signed block. The parsed body is re-rendered and compared
for exact equality before acceptance.
- content_digest now binds the full recovery scope: repository identity, PR,
decision-lock identity, destroyed subject, recovery action, recorded and
expected head, incident issue, evidence author, minting actor, key version,
nonce and issued_at.
- Actor identity is the immutable user id with login consistency; conflicting
ids/logins and display-name-only identities fail closed. Edited comments are
rejected. The independent-author rule is preserved and enforced by stable id.
- build_canonical_incident_body is the single source of the accepted format and
refuses to emit ambiguous evidence.
F8 — archival is a prerequisite for clearing terminal evidence:
- _clear_decision_lock_for_profile no longer swallows archive failures. It
requires a successful write plus a durable read-back matching the PR/head,
and otherwise returns a structured, retry-safe failure that retains the lock
and records actionable recovery evidence.
- Fixes a latent bug the read-back exposed: the archive payload inherited the
source lock's session_profile_lock, so save_state keyed the archive under the
reviewer profile instead of the archive identity and it never read back.
Adversarial regressions added for every listed case: key-version missing/empty/
unknown/malformed/duplicate/rotation/wrong-key-after-restart, reordered fields,
duplicate identical and conflicting fields, conflicting actor ids/names,
digest-preserving substitution, decision-lock and recovery-action substitution,
cross-PR/repo/org/remote/head replay, archive exception/timeout/false/empty/
partial-readback with proof the lock survives, exactly-one permitted clear, and
retry after archive failure. No existing assertion was weakened.
Validation: focused 150 passed in three module orders; full tests/ 2809 passed,
6 skipped, 1 warning (pre-existing StarletteDeprecationWarning in
tests/test_webui_audit.py:8), 161 subtests passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Capability resolution and mutation gates no longer auto-switch profiles.
Session profile/remote/host/identity are bound after pin or first seed,
and drift or cross-host resolution fails closed before writes.
Co-Authored-By: Grok <[email protected]>
Prevent merger-local empty decision locks from standing in for reviewer
terminal cleanup, refuse silent re-init overwrite of unresolved terminal
evidence, record post-merge recovery-required state when audit fails, and
add a truthful irrecoverable-provenance path that never claims applied=true.
Closes#709
Remove the public allow_test_bootstrap production seam so caller-controlled
flags cannot forge native mutation provenance. Bind provenance to the resolved
canonical entrypoint path plus a live stdio transport bind; basename-only
mcp_server.py stack frames and import-only launch no longer authorize
mutations. Test-mode install_test_native_runtime is pytest-only and cannot
reach production Gitea mutation endpoints. Add AC9 regressions for both
reviewer-found bypasses and related spoof vectors.
Refs: PR #696 REQUEST_CHANGES at 253269c; issue #695 comments 11002/11005.
Bind mutation/credential paths to a process-local native MCP runtime so env
spoofing, direct imports, and offline helpers cannot reconstruct session gates
after native transport failure. Quarantine contaminated formal reviews under
controller authority and honor quarantine in review feedback, merge eligibility,
merge mutation, and canonical handoff validation. Add regression coverage for
the second (PR #694 / review 427) incident class.
Fixes the six defects from the formal REQUEST_CHANGES review at
889931d553:
- F1: run sanctioned stale-binding recovery in
gitea_resolve_task_capability BEFORE the terminal launcher probe, so a
worktree removed mid-session can no longer wedge mutation resolution on
'missing cwd' before recovery runs. The fail-closed probe block now also
carries the binding classification evidence.
- F2: _resolve_preflight_workspace_path resolves with the same
verify_paths existence checks as the canonical mutation context, and
_get_workspace_porcelain returns a synthetic tracked-dirty sentinel when
the workspace is missing or git fails — an uninspectable workspace can
never read as clean/empty porcelain.
- F3: the orphaned_expired_superseded_head cleanup matrix now requires
affirmative safe worktree evidence (worktree_clean=true or
worktree_exists=false), aligned with the sibling orphaned_owner_missing
gate and the diagnosis path; unknown evidence fails closed.
- F4: reviewer-session-lease shadows and stale-binding audit records are
recovery-critical kinds exempt from the 4h session-state TTL; they
persist until a sanctioned clear terminally reconciles them.
- F5: session-lease shadows are keyed by lease session id (collision-safe
identity) with a list_states enumeration API; concurrent same-profile
sessions can no longer overwrite or misattribute each other's crash
evidence. Legacy single-slot records remain readable.
- F6: the durable audit record is persisted (status=pending) BEFORE the
environment binding is cleared; if audit persistence fails the clear
does not happen and the failure is reported explicitly.
30 new regression tests cover deleted-worktree recovery ordering, missing/
deleted/symlinked/mid-evaluation path changes, unknown-evidence cleanup,
TTL boundary (before/at/after), interleaved and reconnected concurrent
sessions, and audit-write failure/retry/idempotence/ordering.
Issue #704 dotenv load-path prevention is intentionally NOT included.
Full suite: 2695 passed, 6 skipped, 161 subtests passed.
Co-Authored-By: Claude Fable 5 <[email protected]>
An unhandled daemon crash skips teardown: the durable reviewer lease
comment survives, the in-memory session lease dies, and the
auto-reconnected daemon inherits a stale GITEA_ACTIVE_WORKTREE from its
parent environment (PR #701 / branches/review-pr-654 incident).
AC2 — safe clear/re-bind of stale bindings:
- new stale_binding_recovery.py: classifies the active env binding
(corroborated / unverified_inherited / provably_stale_missing_path /
superseded_by_session_lease) and produces a fail-closed recovery plan;
only provably stale bindings are clear-eligible
- gitea_resolve_task_capability and daemon boot run the sanctioned
recovery (durable audit via session state); runtime context surfaces
the classification read-only
- namespace_workspace_binding.resolve_namespace_workspace(verify_paths=…)
demotes env-bound worktrees whose path no longer exists; mutation and
runtime-context resolution always verify
AC3 — recoverable lease cleanup after unexpected exit:
- durable session-lease shadow (KIND_REVIEWER_SESSION_LEASE) written on
sanctioned record/heartbeat, removed on sanctioned clear; a crash
leaves provable orphan evidence (owner pid + session id)
- assess_crashed_session_lease_orphan derives tri-state
owner_process_alive: only a dead recorded owner pid proves exit; PID
liveness is never ownership proof
- diagnose/cleanup wrappers consume the shadow instead of passing
owner_process_alive=None
- new classification orphaned_expired_superseded_head: an expired
superseded-head lease with no terminal review stops being a permanent
ambiguous/wait; post-expiry it unlocks fresh acquisition, and guarded
cleanup becomes eligible only with owner-exit evidence + clean/absent
worktree + controller authorization + confirmation. Pre-expiry
behavior is unchanged (fail-closed wait, no steal).
Validation: tests/test_issue_702_stale_binding_lease_recovery.py (29
tests covering lost in-session leases, old-head leases, runtime/worktree
mismatch, managed reconnect, safe expiry); full suite 2665 passed,
6 skipped, 161 subtests.
Co-Authored-By: Claude Fable 5 <[email protected]>
Prevent foreign open-PR terminals on the durable review-decision lock from
blocking fresh formal reviews. Scope correction authorization to the named
prior review's PR/head, add read-only diagnosis with exact next_action and
durable handoff payloads, require thread-visible correction audits, and
cover the PR #688 → #692 recovery sequence in regression tests.
Add fail-closed assessment and MCP tool for expired/superseded-head
comment-backed reviewer leases that lack a control-plane lease_id.
- assess_obsolete_reviewer_comment_lease_cleanup eligibility matrix
- diagnose classifications: foreign_active_current_head,
foreign_expired_current_head, foreign_completed_superseded_head,
foreign_expired_superseded_head, orphaned_owner_missing,
ambiguous_conflicting_evidence
- gitea_cleanup_obsolete_reviewer_comment_lease with confirmation
CLEANUP OBSOLETE REVIEWER LEASE <n> and controller_recovery_authorized
- preserve audit history via append-only phase=released marker
- never repoint, transfer validation, use PID ownership, or steal
active current-head foreign leases
- regression for PR #688 lease comment 10749 after recorded expiry