Commit Graph
957 Commits
Author SHA1 Message Date
jcwalker3andClaude Opus 4.8 a143cd065b fix(transport): gate transport execution behind commissioning (review 635)
Addresses B1 and B2 from formal review 635 on PR #966. Both are one defect:
the seam conflated two questions that must stay separate.

  recognition:  "is this an identifier this system knows, and may it be
                 bound, pinned and recorded?"
  execution:    "is this entrypoint commissioned to actually serve on it?"

SUPPORTED_TRANSPORTS answered the first and was then used to answer the
second, so registering streamable-http was the same act as executing it.
With mcp.run fed the bound value, GITEA_MCP_TRANSPORT=streamable-http
reached FastMCP.run -> run_streamable_http_async and started a uvicorn
HTTP server over the whole tool surface with auth=None, no TLS and no
per-request principal. That endpoint is owned by #938 and is an explicit
non-goal of #931. B2 is why B1 was reachable: bound_transport was read
only by reporting sites, so no decision anywhere depended on it.

The fix adds an execution-authorization layer rather than removing the
identifier or mapping it to stdio, both of which would have destroyed the
seam #931 exists to create.

mcp_transport_config gains EXECUTABLE_TRANSPORTS, a strict subset of
SUPPORTED_TRANSPORTS containing only the local transport, plus
TRANSPORT_EXECUTION_OWNER recording that #938 commissions the remote
listener. assess_transport_execution returns a structured verdict:
transport, recognized, executable, allowed, blocker_kind, owner_issue,
reasons and exact_next_action.

mcp_daemon_guard gains assess_serve_authorization, which is the decision
that consumes bound_transport and is what stops it being reporting-only
metadata, and authorize_transport_execution, which enforces two ordered
boundaries: assert_transport_bound first, so an unbound runtime keeps its
pre-existing #695 failure and reason code; then execution authorization,
so a registered but uncommissioned transport is refused before any
listener exists and before any tool can dispatch. TransportExecutionError
subclasses UnsanctionedRuntimeError, so every existing fail-closed handler
still catches it while a caller that cares can distinguish the two cases.
native_runtime_status now reports executable_transports, serve_authorized
and the full serve_authorization verdict.

The entrypoint serves through authorize_transport_execution.

Net effect. stdio: unchanged, still binds and still reaches the runner.
streamable-http: still recognized, still validated, still pinned, still
recorded in decision-lock and audit provenance -- and refused at the serve
boundary with blocker transport_listener_not_commissioned naming #938.
Unregistered identifiers still fail earlier, at bind validation. Unbound
execution keeps the #695 contract. Rebind idempotence and conflict
rejection, session isolation, provenance and secret handling are untouched.

Tests: 63 in tests/test_issue_931_transport_bind_seam.py, up from 42. New
coverage proves stdio reaches the real runner; the remote identifier binds
and is recorded yet never reaches run(); no socket is bound during the
refusal, asserted with a socket.bind tripwire; the refusal names the
transport, the unmet requirement and #938; the decision genuinely consumes
bound_transport, proven by flipping only that value; no mutation is
authorized after denial; verdicts do not leak across runtimes; and the
refusal carries no credential material. Per review 635 the two file-list
scans are now globs over the production surface, and a new test asserts
there is exactly one mcp.run site and that it routes through the guard.

#956 anchors re-anchored for the two lines this change shifted,
mcp_daemon_guard.py 178->195 and 519->583. No other anchor moved.

Full suite from the branches/ worktree: 28 failed, 5864 passed, 6 skipped,
1047 subtests. Base 9b80e75c: 28 failed, 5801 passed, 6 skipped, 1047
subtests. Failing test IDs are identical sets in both directions; the +63
passed are this module.

No listener, socket, authentication, TLS or principal code is added. #938,
#957, #958 and #959 remain unimplemented.

Refs #931, #938. Addresses review 635.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 11:15:00 -05:00
jcwalker3andClaude Opus 4.8 c162608175 feat(transport): add transport-neutral MCP bind seam
Closes #931.

The transport was a literal passed once at the bottom of the module,
bind_native_mcp_transport(transport="stdio"), and every guard that asks
"is this a trusted native session" resolves that question through the
value bound there. The string was a constant in the authorization chain
rather than configuration, so a remote transport had no way to be
expressed and no way to be told apart from an untrusted offline import.

mcp_transport_config becomes the single source of truth: it owns the
permitted set, the default, and the resolution of deployment
configuration (GITEA_MCP_TRANSPORT) into a validated identifier. Unset
configuration still yields stdio, so existing deployments are unchanged.
streamable-http is accepted so the bind is pluggable; standing up its
listener remains #938. sse is deliberately not registered.

bind_native_mcp_transport now resolves from configuration when no
transport argument is given, validates against that one permitted set
before writing the runtime record, and pins the result. bound_transport
is the shared accessor every transport-aware guard reads; it reports the
pinned value and never the environment, so a post-bind GITEA_MCP_TRANSPORT
change cannot move what a guard observes -- the rule already applied to
the session-state root under #695 AC2. Rebinding the same identifier is
idempotent; rebinding a different one is refused, so two guards can never
disagree within one process. assert_transport_bound fails closed before
mcp.run, so an absent or invalid bind stops the server instead of serving
tools over a transport no guard can name.

The bound identifier is now recorded in durable provenance:
mutation_provenance_fields gains bound_transport, which reaches the
decision lock through mcp_session_state.save_state and the audit records
that already spread those fields. The pre-existing transport field keeps
its trust-class values, so no durable record changes shape.
assess_transport_for_auth_mint reports the bound transport through the
same accessor; its verdict is unchanged.

#956's anchor fixture and threat model are re-anchored for the lines this
change shifts, and the two boundary claims that #931 makes false -- the
"literal stdio bind" in B1 and the stdio contract at mcp_server.py:4 --
are restated. Without this the #956 guard fails, which is what it is for.

Non-goals, untouched: the remote listener (#938), per-request principal
resolution (#932), client-managed provenance policy (#934), TLS and
remote client authentication, role capability sets, repository-binding
semantics.

Tests: tests/test_issue_931_transport_bind_seam.py, 42 tests, covering
default bind, explicit stdio, the accepted non-stdio identifier, an
unregistered identifier, no bind at all, one-value agreement across
guards, post-bind environment tampering, the durable decision-lock
record, the rebind contract, and the #695 protections that must not
regress.

Full suite from the branches/ worktree: 28 failed, 5843 passed, 6
skipped, 1047 subtests. The pinned base 9b80e75c reports 28 failed, 5801
passed, 6 skipped, 1047 subtests. The failing test IDs are identical sets
-- no failure added, none fixed; the +42 passed are this PR's new module.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 09:59:01 -05:00
sysadminandClaude Opus 4.8 b3de9c941c docs(remote-mcp): threat model, trust boundaries, and decomposition ruling (Closes #956)
#930 inventoried stdio coupling; nothing stated what the adversary is, what
each boundary protects, or why one process may hold credentials for several
services. This adds that document as child 2 of epic #929.

Adds docs/remote-mcp/threat-model.md covering 10 assets, the 5 adversaries
#956 names, 9 trust boundaries, the data flows between them, a 14-entry
per-boundary credential inventory, and an explicit decomposition ruling.

Findings established from live native evidence at this commit:

- Four prgs roles (reviewer, merger, reconciler, controller) resolve to one
  Gitea account, so separation of duty between approving and landing is
  enforced only by which process a call reaches. The mdcps tenant has no role
  separation at all: author, reviewer, and merger share one account.
- Any one role process can resolve every other role's credential.
  gitea_list_profiles reports "credentials present" for other roles because it
  calls resolve_token on each one.
- The Gitea server reads Jenkins and GlitchTip secrets out of the keychain to
  produce the "authenticated" word in gitea_audit_config's service summaries.
- Jenkins and GlitchTip were already decomposed into separate MCP servers; the
  credential references were left behind in the Gitea configuration.

Ruling D1 forbids a single integration process from holding credentials for
unrelated services, with one time-boxed dual-run exception for the local fleet
that expires with #939. D2 requires separation of duty to be credential-backed,
D3 scopes credential resolution to the request principal, and D4 gives
coordination state its own authority. Every #929 child from 2 through 10 is
mapped to the boundary it implements.

Anchors are enforced rather than asserted. #930's inventory anchors into
gitea_mcp_server.py had already drifted between 7bf4f125 and aad5c8b4 with
nothing detecting it, so this change ships the guard that was missing:
docs/remote-mcp/threat-model-anchors.json declares all 58 anchors with the
substring each must contain, and tests/test_issue_956_threat_model.py fails if
any anchor does not resolve, if the document cites an anchor the fixture does
not cover, or if the structural obligations regress.

Documentation only. No server behavior changes.

Tests:
- tests/test_issue_956_threat_model.py: 17 passed.
- Four sabotage probes confirm the validator is not passing vacuously
  (shifted anchor, undeclared citation, broken count tally, and a blanked
  boundary owner). The last two probes exposed real weaknesses in the checks
  themselves, which were fixed: the section slice now stops at the next
  heading, and boundary ownership is read only from mapping table rows.
- Docs-sensitive sweep (17 modules referencing docs/): 559 passed.
- Full suite: 30 failed, 5799 passed, 6 skipped. All 30 reproduce on a clean
  base worktree at aad5c8b4; branch failures are a strict subset of base
  failures. No production file is modified by this change.

Closes #956

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 04:41:26 -04:00
sysadminandClaude Opus 4.8 b4c9f55890 test(author): execute the native #953 tool functions and compensation paths (#953)
Review 632 F4. Every prior reference to `gitea_recover_incomplete_bootstrap_lock`
and `gitea_inspect_issue_lock_contract` under `tests/` was a string literal — a
`tool=` argument, an assertion on returned prose, or a docs substring check —
and the suite reconstructed the recovery sequence by hand from
`assess_bootstrap_lock_recovery`, `build_canonical_issue_lock`,
`build_recovery_record`, and `bind_session_lock`. A hand-written sequence
validates the decision layer but cannot see a divergence between itself and
the tool body, which is exactly how F1 and F2 — both call-site defects —
survived 61 passing cases.

The new cases drive the registered functions against a real `git init`
repository, a real durable lock file, and config-backed profiles:

* `NamespaceMutationWallOnRecovery` — the gate is reached with this task and
  `author_role_exclusive=True`; its return value aborts the tool rather than
  being computed and discarded; the author namespace succeeds and produces a
  canonical lock; a reviewer namespace is refused with `namespace_block` even
  when the claimant data would otherwise match, and emits the standard BLOCKED
  audit record; merger is refused; a mismatched claimant profile is refused by
  the exact-owner layer with the namespace wall explicitly clear; a mismatched
  head is refused; every refusal leaves the lock bytes, generation, branch,
  worktree, and an unrelated lock untouched. A subtest matrix asserts the
  role-kind wall admits `author` and refuses reviewer, merger, limited, and
  mixed.
* `InspectionToolExecutes` — the registered read-only tool reports the contract
  and the recovery preview while leaving lock bytes, mtime, HEAD, and porcelain
  status unchanged, and reports an absent lock without creating one.
* `Ac7PostCompensationGuidance` — drives the real bootstrap to its AC7 refusal
  with a forced partial lock and asserts the returned action against the state
  the rollback actually left: complete cleanup directs to a bootstrap retry and
  that retry is then executed and succeeds, leaving exactly one canonical lock
  and one branch; partial cleanup with a surviving lock, and with a surviving
  branch and worktree, each get their own executable action; a rollback that
  never completed is distinguished from both; no recommendation names a deleted
  artifact; unrelated locks are byte-identical afterwards. Two cases cover
  `release_session_lock` directly — that the rollback now really removes the
  lock, and that it refuses a lock owned by another session.
* `NativeEndToEndBootstrapToCreatePr` — bootstrap, inspect, heartbeat,
  legitimate divergence (commit and push), pre-mutation ownership re-check, and
  the unchanged #447 create-PR provenance guard, in one sequence against a real
  origin. `gitea_lock_issue` is patched to fail the test if anything reaches for
  it, so the bootstrap lock is proved to carry the whole cycle unrepaired.
* `DeadProvenanceConstantRemoved` — the removed constant stays removed and the
  sanctioned source set stays unwidened.

`_NativeToolBase` clears `role_session_router` route state per test: the sticky
reviewer-stop marker is process-global and, now that this task is registered in
`AUTHOR_TASKS`, an earlier suite leaving it set would make the first gate refuse
before the namespace gate under test is reached.

Also documents both new tools in `docs/mcp-tool-inventory.md`, so this branch
adds no drift to `test_documented_inventory_equals_registered_tools`; the
failure reason there is now identical to the pinned base's.

Suite: 61 -> 92 cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 02:08:21 -04:00
sysadminandClaude Opus 4.8 cdf0daefa9 fix(author): unify the bootstrap and lock_issue issue-lock contract (Closes #953)
gitea_bootstrap_author_issue_worktree wrote a lock no downstream author
operation accepts, then directed the author straight to implementation. Once
the branch carried commits, heartbeat, re-lock, exact-owner renewal, and the
#447 create-PR guard all refused simultaneously and no sanctioned recovery
path remained eligible.

Each of those gates is individually correct. The defect was that two writers
disagreed about what a lock is.

- Add author_lock_contract as the single canonical definition: claimant,
  work_lease, lock_provenance, generation, and an explicit expiration state.
  Both gitea_lock_issue and bootstrap now build through it.
- Promote issue_lock_store.lock_claimant to the one shared claimant reader and
  use it in the ownership check, so a claimant recorded at the lock top level
  is read rather than refused. The values are still compared against
  server-resolved identity and profile, so no legacy placement grants anything
  the canonical placement would not.
- Represent missing expiration explicitly. An absent expires_at previously read
  as "not yet expired", leaving a malformed lock permanently non-expiring and
  permanently ineligible for #760 renewal.
- Bootstrap reads its lock back and verifies it structurally before reporting
  success. A partial lock fails closed while the worktree is still
  base-equivalent, names the missing fields, and never reports
  implementation_allowed. Its exact_next_action now matches the state returned.
- Add gitea_recover_incomplete_bootstrap_lock for locks already written by the
  old bootstrap, including those whose branches carry pushed commits. It never
  moves, resets, or rewinds a branch, never requires base-equivalence, never
  pushes or opens a PR, and touches only the target lock. It proves repository,
  issue, claimant username and profile, branch, worktree, registration, and
  head before writing, refuses healthy foreign-owned locks, and mints
  provenance and authorization server-side.
- Add gitea_inspect_issue_lock_contract, a strictly read-only surface.
- Document the required ordering and the recovery path.

The #447 provenance guard is unchanged and the sanctioned source set was not
widened: bootstrap now satisfies the guard rather than the guard being relaxed
to admit bootstrap.

Tests: 61 new cases covering the canonical schema, immediate heartbeat,
renewal before and after commits, the create_pr guard, executable next actions,
partial and malformed and missing-expiration and expired and same-owner and
foreign-owner and legacy locks, recovery isolation, read-only inspection, and
the full bootstrap-implement-commit-push-create_pr regression against isolated
fixtures.

Full suite at head: 28 failed, 5753 passed, 6 skipped, 1042 subtests.
Full suite at base 82d71b77: 28 failed, 5692 passed, 6 skipped, 1042 subtests.
Failing test-ID sets are identical, so there are zero regressions; the +61
passes are this issue's new suite.

Issue #949 was preserved and not recovered: its branch remains at
92615f474b and its worktree, lock, and PR state
were not touched. The #949-shaped regression uses isolated fixtures only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 00:35:12 -04:00
sysadmin 82d71b7702 Merge pull request 'fix(author bootstrap): restore missing runtime identity and session helpers (Closes #943)' (#944) from fix/issue-943-runtime-context-helpers into master 2026-07-27 20:03:08 -05:00
sysadmin 47bfae07d2 fix(author bootstrap): resolve allocator session ownership, authority alignment, and test coverage (#943) 2026-07-27 19:52:27 -04:00
sysadmin 35ed8a2fcb Merge pull request 'fix(gate): preserve exact-owner renewal evidence across duplicate rechecks (Closes #945)' (#946) from fix/issue-945-owning-pr-renewal-evidence into master 2026-07-27 18:27:35 -05:00
sysadminandClaude Opus 5 b1fcf15937 fix(gate): complete owning-PR renewal enforcement coverage (#945)
Addresses review 623 on PR #946 (B1 blocker, F2 medium, F3 minor).

B1 - the wiring this branch exists to install had no regression coverage.
The existing suite exercised owning_pr_renewal_from_lock,
_owning_pr_continuation_from_lock and the duplicate gate directly, but never
drove an enforcement path, so reverting any of the three call sites left the
whole repository green. Add tests/test_issue_945_enforcement_path_wiring.py,
which drives the real mcp_server._enforce_locked_issue_duplicate_recheck (the
shared recheck behind gitea_commit_files and gitea_create_pr),
mcp_server.gitea_assess_work_issue_duplicate and
mcp_server._prove_author_ownership_for_pr against a renewal-bearing lock, and
asserts each grants the exemption. Reverting the commit/create-PR recheck to
the recovery-only rebuild now fails 8 tests and 4 subtests; reverting the
assessor or the push prover fails 2 each. The suite also keeps the fail-closed
matrix on the real paths: an open PR alone, a second PR, a different PR,
branch, issue or head, identity and profile mismatch, ungranted and malformed
renewal blocks, and sequential-task non-inheritance are all still refused.

F2 - the claimant check compares lease_renewal.identity/profile against the
claimant recorded on the same lock file. Both sides are server-written fields
of one document, so it is an internal-consistency check, not verification of
the live authenticated caller. Correct the docstring and the inline comment to
say so, and document the binding that actually prevents cross-session reuse:
the enforcement paths load the lock through _load_existing_issue_lock() with no
issue coordinates, which resolves issue_lock_store.read_session_issue_lock() to
the session pointer at session-{os.getpid()}.json, so lock selection is scoped
to the operating-system process. Its limits are stated too - per-process rather
than per-authenticated-user, silent on locks reached by explicit coordinates,
and silent on two roles sharing one process. Live identity and profile stay
enforced by the mutation-authority and profile gates, not by this rebuild.

F3 - _owning_pr_continuation_from_lock previously fell through to renewal when
a dead_session_recovery block was present but failed to rebuild, so a recovery
record naming one PR could be bypassed by renewal evidence naming another.
Present-but-unusable recovery evidence is now ambiguous rather than absent and
fails closed. Because an expired lease whose recorded owner has also died
satisfies both dispositions in one gitea_lock_issue call, a sanctioned pair is
a reachable state; when both rebuild, they must agree on issue, PR, branch and
every head, or no continuation authority is returned. Recovery-only and
renewal-only locks keep their existing behaviour exactly.

Validation of the resulting token against live PR state remains untouched and
solely owned by issue_work_duplicate_gate._assess_owning_pr_exemption. No
public signature, MCP tool schema, refusal shape or reason code changes.

Tests: focused #945/#755/#760 suites 137 passed, 19 subtests. Full suite in a
branches/ worktree: 30F/5619P/6S/1013 subtests at this head vs 30F/5572P/6S/1002
at a clean checkout of 79334d48, with byte-identical failing test id sets - the
+47 passes and +11 subtests are exactly the new coverage.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q8RUznLXEA4JoK48sTZiSK
2026-07-27 18:22:07 -04:00
sysadmin 5fea326988 Merge pull request 'feat(mcp): expose sanctioned Codex MCP reconnect request (Closes #678)' (#918) from fix/issue-678-codex-mcp-reconnect into master 2026-07-27 15:32:10 -05:00
sysadmin dc5d2c8caa Merge pull request 'feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649)' (#917) from feat/issue-649-sentry-console-correlation into master 2026-07-27 15:23:36 -05:00
sysadmin c30b381eb2 Merge pull request 'fix(mcp): add active IDE vs global config-drift diagnostic (Closes #672)' (#920) from fix/issue-672-mcp-config-drift into master 2026-07-27 05:40:04 -05:00
jcwalker3andClaude Opus 4.8 79334d4840 fix(gate): preserve exact-owner renewal evidence across duplicate rechecks (Closes #945)
An exact-owner lease renewal (#760) is granted the owning-PR duplicate-work
waiver inside gitea_lock_issue, but every later enforcement path rebuilt that
proof from the durable lock through
issue_lock_recovery.recovered_owning_pr_from_lock, which reads only the
dead_session_recovery block. A plain renewal persists its proof under
lease_renewal instead, so the waiver expired with the lock call and the next
author mutation was refused duplicate_commit_prevented with
owning_pr_recovery_exempted: false on the very PR the renewal had just proved.

Add issue_lock_renewal.owning_pr_renewal_from_lock as the renewal mirror of the
recovery rebuild, and resolve both halves through one helper,
_owning_pr_continuation_from_lock, in the same precedence gitea_lock_issue
applies. The three enforcement paths that previously saw only recovery evidence
now share that resolver: the commit/create-PR duplicate recheck, the read-only
duplicate assessor, and the push-ownership prover.

The rebuild re-applies the equality the renewal assessor required (PR head ==
local head == remote head) and additionally binds the record to the claimant the
lock names, so a renewal block cannot be reused under another identity, profile,
or workflow session. Validation of the resulting token against live PR state is
unchanged and still owned solely by
issue_work_duplicate_gate._assess_owning_pr_exemption, so repository, issue, PR,
branch and head binding continue to be enforced in exactly one place.

No public signature, MCP tool schema, refusal shape, or reason code changes.
Dead-session recovery behaviour is untouched.

Tests: tests/test_issue_945_owning_pr_renewal_continuation.py - 49 tests,
8 subtests, all in-memory (no durable branch, worktree, lock, lease or PR).
Against unmodified aab54d48 the suite is 47 failed / 2 passed; the 2 that pass
are the defect-pinning reproductions. Full suite 28F/5574P/6S/1002 subtests at
head vs 28F/5525P/6S/994 at base, with identical failing test id sets.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-26 10:42:44 -05:00
jcwalker3andClaude Opus 5 f49e781102 fix(author bootstrap): restore missing runtime identity and session helpers (Closes #943)
gitea_bootstrap_author_issue_worktree referenced four globals that commit
a942afe (#850) introduced without ever defining:

    _active_username        1 reference, 0 definitions
    _active_profile_name    1 reference, 0 definitions
    _current_session_id     1 reference, 0 definitions
    _author_mutation_block  1 reference, 0 definitions

Evaluating the call arguments therefore raised

    NameError: name '_active_username' is not defined

before author_issue_bootstrap.bootstrap_author_issue_worktree was entered, so
the capability was unusable for every caller including dry_run=true. The fourth
name, _author_mutation_block, sits on the reviewer-stop refusal path and was
found by the generalised regression test rather than by the original report.

The defect was unreachable until PR #942 (#941) wired the bootstrap scope into
workflow_scope_guard: before that, verify_preflight_purity refused first with
missing_issue_worktree, masking it.

Changes:

* _active_username reads the immutable #714 session context that gitea_whoami
  seeds — the identity pin every other mutation gate already consults. An
  unbound context returns None so callers fail closed rather than acting as an
  unverified actor; a profile's expected_username is never substituted.
* _active_profile_name prefers the live get_profile() and falls back to the
  bound session context only when the profile cannot be read.
* _current_session_id mints the same "<profile>-<pid>-<hex>" shape as the three
  pre-existing lease call sites, bound once per process so repeated calls
  describe one session instead of a fresh owner per call, which would make
  lease-ownership comparisons unsatisfiable. None is never memoised.
* _author_mutation_block returns the uniform refusal shape the other author
  mutations already return for the same check_author_mutation_after_reviewer_stop
  block.

No guard, signature, or permission changes. Wrong-role, wrong-profile,
wrong-identity, stale-runtime, expected-base and workflow-scope enforcement all
still gate the call; the helpers only supply values the service then validates
fail-closed (missing_active_identity / missing_active_profile /
missing_owner_session / stale_concurrency_pin).

Regression: tests/test_issue_943_runtime_context_helpers.py (27 tests). The
generalised test resolves every global the wrapper's body references against
module globals and builtins, so the next missing reference fails too rather
than only the three named here — that test is what found
_author_mutation_block. Coverage also coversdry-run reaching and completing the
service with helper-produced bindings, dry-run leaving no branch, worktree,
assignment or lease, apply reaching its intended transition, each fail-closed
mismatch, expected-base mismatch, and the #941/PR #942 scope wiring.

Pre-fix 20 failed / 13 passed against unmodified aab54d48; post-fix 27 passed.
Targeted bootstrap and guard suites: 245 passed, 59 subtests.
Full suite: 28 failed, 5552 passed, 6 skipped, 1006 subtests — the 28 are the
standing baseline, every one of which also fails on the unmodified base.

Closes #943

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013ygVZQLbbhJTChuVuLaJWb
2026-07-26 07:56:14 -05:00
jcwalker3andClaude Opus 4.8 (1M context) &lt;[email protected]&gt; a09c485fc0 fix(scope): wire author bootstrap scope into workflow scope guard (Closes #941)
PR #926 (#892) taught create_issue_bootstrap.bootstrap_permits_control_checkout
to accept task_scope=author_issue_bootstrap and wired that canonical decision
into the #274 branches-only enforcer and the #604 anti-stomp preflight. A third
enforcement path was left unwired.

workflow_scope_guard kept its own copy of the clean-root author decision, gated
on create_issue_bootstrap.is_create_issue_task -- a task-name allowlist that
never contained bootstrap_author_issue_worktree. The real call path

    gitea_bootstrap_author_issue_worktree
      -> verify_preflight_purity
        -> _enforce_issue_scope_guard
          -> workflow_scope_guard.assess_production_mutation_guards

therefore raised ProductionGuardError(missing_issue_worktree) before
assess_author_issue_bootstrap was ever consulted, leaving the #892 deadlock
partially present and blocking issue #931.

Changes:

* workflow_scope_guard.assess_root_source_mutation accepts the server-derived
  bootstrap_assessment and, for the clean-root author case, consults the
  canonical bootstrap_permits_control_checkout decision instead of a local
  task-name allowlist. The create_issue arm is unchanged.
* workflow_scope_guard.assess_production_mutation_guards forwards the evidence.
* _enforce_issue_scope_guard accepts and threads the same assessment the #274
  and #604 guards already consume, so all three judge identical evidence, and
  waives the pre-ownership issue-lock requirement for the bootstrap task via
  that same canonical decision rather than a second task-name allowlist.
* verify_preflight_purity passes the once-computed assessment at both sites.

The waiver cannot widen: the predicate fails closed on missing, malformed,
cross-scope, dirty, drifted, or wrongly bound evidence, so ordinary author
source and test mutation from the control checkout stays forbidden and the
#274/#604/#618/#683 protections are unchanged.

Regression: tests/test_issue_941_scope_guard_bootstrap_wiring.py drives the
real enforcer rather than the authorization helper in isolation, which is why
#892's own predicate tests passed while the live bootstrap stayed blocked.

Closes #941

Co-Authored-By: Claude Opus 4.8 (1M context) &lt;[email protected]&gt;
2026-07-26 05:00:09 -05:00
sysadmin 8c1d22a658 Merge pull request 'fix(bootstrap): allow author worktree bootstrap from clean control checkout (Closes #892)' (#926) from fix/issue-892-author-bootstrap-deadlock into master 2026-07-26 03:25:34 -05:00
jcwalker3andClaude Opus 4.8 2066623986 fix(bootstrap): allow author worktree bootstrap from clean control checkout (Closes #892)
Align assess_author_issue_bootstrap with bootstrap_permits_control_checkout
so gitea_bootstrap_author_issue_worktree can create the first branches/
worktree without the lock↔worktree deadlock.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 18:27:18 -05:00
sysadmin dc0bff764d test(runtime): patch _trusted_session_repository in test_activate_profile_succeeds_when_enabled 2026-07-25 19:27:12 -04:00
sysadmin dfe8d7c28d fix(mcp): detect and reject manually launched duplicate MCP role servers (Closes #686) 2026-07-25 19:25:23 -04:00
sysadmin fad44669d9 fix(mcp): add active IDE vs global config-drift diagnostic (Closes #672) 2026-07-25 19:07:10 -04:00
jcwalker3andGrok 4.5 29ad93d145 feat(mcp): expose sanctioned Codex MCP reconnect request (Closes #678)
Add gitea_request_mcp_reconnect as a report-only callable surface so Codex
and other agent hosts can request host/IDE reconnect with typed blockers
and exact UI steps. Never kills processes or edits config.

Co-Authored-By: Grok 4.5 <[email protected]>
2026-07-25 17:52:08 -05:00
sysadmin f2dbf30e81 feat(webui): Sentry/GlitchTip observability console & correlation view (Closes #649) 2026-07-25 18:48:36 -04:00
sysadmin 0f9390aab4 Merge remote-tracking branch 'prgs/master' into feat/issue-666-concurrent-mcp-restart-tests 2026-07-25 18:43:28 -04:00
sysadmin c83a10d7c2 Merge remote-tracking branch 'prgs/master' into feat/issue-648-notifications-console 2026-07-25 18:37:17 -04:00
sysadmin 3bbe6df6c7 Merge pull request 'feat(webui): add read-only console restart status and impact controls (Closes #667)' (#911) from feat/issue-667-console-restart-controls into master 2026-07-25 17:34:12 -05:00
jcwalker3 71031c812e Merge branch 'master' into feat/issue-648-notifications-console 2026-07-25 17:29:57 -05:00
sysadmin a64ba08e27 fix(webui): address #905 REQUEST_CHANGES on notifications classifier
B1: classify_attention_event uses structured flags/category only — never
substring-match human-authored title/summary for escalation.

B2: make notification ids unique across probe_errors and collisions
(include loop index / kind).

B3: do not assign probe_errors to fetch_error (avoids false Fetch Warning
and double-reporting).

Regression tests cover all three blockers.

Refs #648
2026-07-25 18:26:46 -04:00
sysadmin 26f54851d1 Merge pull request 'feat(mcp): enforce scoped recovery playbook before full restart (Closes #669)' (#914) from feat/issue-669-scoped-component-recovery into master 2026-07-25 17:14:21 -05:00
sysadminandClaude Opus 4.8 bb8c3a537b merge(master): resolve PR #905 conflicts with requests/linkage
Keep notifications (#648) routes and nav alongside master requests (#643)
and other base updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 18:11:06 -04:00
sysadminandClaude Opus 4.8 04ae3532cc merge(master): resolve #903 conflicts with #643 request initiation
Keep #644 recovery console actions and #643 initiate_workflow side by side
in console_authz and authz audit docs after merging latest master.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 18:03:04 -04:00
sysadmin 7bb5ff4719 Merge pull request 'feat(webui): request preview, authorization, and workflow initiation (Closes #643)' (#902) from feat/issue-643-request-preview-initiate into master 2026-07-25 17:02:17 -05:00
sysadmin 5b7ceefa9a Merge remote-tracking branch 'prgs/master' into feat/issue-644-console-recovery 2026-07-25 18:00:31 -04:00
sysadminandClaude Opus 4.8 4a2fae8495 fix(webui): arm the recovery gates and make the playbooks reach the process (#644)
Reviewer REQUEST_CHANGES on PR #903 at head 1c88b87 raised five blockers, all
reproduced by executing that head. The shared shape: a write path that declared
itself gated, audited, and verified, but never armed the gate, mutated a copy of
the state it claimed to fix, and then verified against that same copy.

B1 - the apply path never asked the execution gate.
execute_recovery_playbook called console_authz.authorize with the default
for_execution=False, and the phase branch only fires when it is True. ACTIVE_PHASE
is 1 and every new action is phase 2, so an operator executed a phase-2 write
through POST /api/v1/system/recovery/apply while build_recovery_preview reported
execution_enabled false. The call now passes for_execution=True and surfaces the
phase_not_active refusal. Preview reports the same decision under
execution_authorization / execution_blocked_reason instead of a hardcoded False it
could not explain.

B2 - both env playbooks mutated a discarded copy and verified against it.
source_env = dict(os.environ) meant clear_stale_binding and rebind_session_worktree
never touched the running process, and verify_post_recovery(env=source_env)
re-diagnosed the same copy, confirming a change that had not happened. Mutations
now target the live mapping (apply_recovery's sanctioned env=None -> os.environ
path, #702 AC2) and verification re-reads state rather than the mutated input.
binding_before / binding_after / binding_changed are returned, and a playbook that
changed nothing reports performed: false. verify_post_recovery no longer reads an
unverified_inherited binding as clean, because unproven is not clean.

B3 - the reconcile playbook called a function that does not exist.
merged_cleanup_reconcile.reconcile_merged_cleanups is absent from that module and a
bare except turned the AttributeError into a generic failure, so the playbook could
never succeed. It now calls gitea_mcp_server.gitea_reconcile_merged_cleanups, the
real orchestrator, imported lazily; failures carry error_type. task_capability_map
declared gitea.pr.close for reconcile_cleanups while the entry point gates on
gitea.read; the two authority statements are reconciled to the one that is enforced.

B4 - the #630 contamination integration could not block.
assess_contamination_gate was fed marker=None, which short-circuits to block: False
on its first statement; the task passed was a console action id outside
CONTAMINATION_GATED_TASKS; and the result was read through a "contaminated" key the
gate never returns, making STATUS_BLOCKED_CONTAMINATION unreachable. The live marker
now comes from the #641 session inventory reader, the gated task key
console_recovery_apply is added to CONTAMINATION_GATED_TASKS, every read uses the
"block" key the gate actually returns, and the marker is forwarded to
sanctioned_restart.execute_restart so a restart cannot launder a contaminated
runtime. The reconciler cleanup playbook stays exempt as the designated remedy.

B5 - the parity baseline was captured from the head it was compared against.
capture_startup_parity(root, head=checkout_head) stores the head verbatim, so
in_parity was structurally incapable of being false, and live_remote_head was never
passed. The baseline is now the daemon start head that assess_stale_runtime already
returns, and the #610 live-remote dimension is restored.

Also: _recovery_card was the one renderer in system_health_views.py interpolating
without _esc(), and it is where a marker's operator-supplied command_summary lands
once B4 is wired; it now escapes, including the except branch. Docs no longer claim
apply enforces master parity or that verify asserts clean: true, and the absolute
file:///Users/... links are relative.

Tests: the two that asserted the defects as intended are inverted -
test_api_recovery_apply_with_dev_auth asserted the phase-gate bypass, and the rebind
test asserted the input echoed back. Added coverage per blocker, including a no-op
detection test that fails when a playbook reports success without changing anything,
the previously untested reconcile playbook, contamination block and remedy-exemption
tests, and parity baseline/live-remote tests. Both new guards were mutation-verified:
disarming for_execution fails 2 tests, restoring the env copy fails 2 tests.

Validation: WEBUI_TEST_OFFLINE=1 ../../venv/bin/python -m pytest tests/ -q from
branches/feat-issue-644 gives 27 failed / 5291 passed / 6 skipped / 953 subtests;
the same command from branches/baseline-master-76f293e at 76f293eb28 gives
28 failed / 5262 passed / 6 skipped / 926 subtests. Suites run one at a time.
comm of the sorted FAILED lines shows no new signature at the head. The single
absent signature, test_workspace_guard_alignment.py::
TestRuntimeContextGuardAlignment::test_declared_branches_worktree_passes_when_mcp_root_differs,
is suite-order dependent: that file passes 9/9 in isolation at both revisions.

Closes #644

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 17:42:49 -04:00
sysadminandGrok 4.5 461e1dac78 feat(mcp): enforce scoped recovery playbook before full restart (Closes #669)
Add recovery_playbook.py with the narrow-to-broad recovery ladder, symptom
routing, attempt-log helpers, and escalation metrics. Wire the attempt-log
gate into restart_coordinator so rolling/full/host restarts require prior
insufficient narrower attempts (or break-glass). Document the ladder and
update gitea_request_mcp_restart for prior_recovery_attempts_json.

Co-Authored-By: Grok 4.5 <[email protected]>
2026-07-25 17:35:11 -04:00
sysadmin 9a01543477 feat(webui): add read-only console restart status and impact controls (Closes #667) 2026-07-25 17:23:52 -04:00
sysadmin 59aab06fe1 feat(tests): add concurrent-session MCP restart safety tests (Closes #666) 2026-07-25 17:14:14 -04:00
sysadmin 4f06d30e07 feat(webui): implement notifications and human-attention routing (#648) 2026-07-25 16:41:16 -04:00
sysadminandClaude Opus 4.8 211890f361 feat(webui): Gitea issue and PR linkage console (Closes #645)
Add a Phase 3 read-only console that resolves issue↔PR linkage with
evidence (closes keyword, branch marker, body mention), surfaces the
latest canonical handoff for a focused thread, and deep-links to Gitea
only under the admin reveal opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 16:40:07 -04:00
sysadmin 1c88b87ec5 feat(webui): implement Phase 2 recovery controls & playbooks (#644) 2026-07-25 08:19:06 -04:00
jcwalker3 b993ad1c64 Merge branch 'master' into feat/issue-643-request-preview-initiate 2026-07-25 07:13:21 -05:00
jcwalker3 d0006e9f71 Merge branch 'master' into feat/issue-643-request-preview-initiate 2026-07-25 06:42:41 -05:00
jcwalker3 54559aebc3 Merge branch 'master' into fix/issue-897-permission-stale-runtime-classification 2026-07-25 06:42:27 -05:00
jcwalker3 6da68fffb8 Merge branch 'master' into feat/issue-643-request-preview-initiate 2026-07-25 02:44:58 -05:00
jcwalker3 daf7ed4c2b Merge branch 'master' into feat/issue-641-runtime-session-view 2026-07-25 02:41:46 -05:00
sysadminandClaude Opus 5 53ce1b1a5e fix(webui): compensate stray allocations and keep preview side-effect free
Addresses both blockers from the PR #902 review (review 589) for issue #643.

B1 — an allocator-created assignment could be orphaned and reported as no
mutation.

apply_request re-previews, then re-runs the allocator with apply=True. The CAS
fingerprint hashes only {kind, number} plus exclusions, so a competing lease
taken on the requested unit inside the window leaves the fingerprint identical:
the pin passes, the selection loop skips the now-claimed unit and commits an
assignment on the *next* one, and _selection_matches then fails on egress. The
old code returned mutation_performed False with that lease still committed and
owned by a synthetic session nothing heartbeats. The window contains a second
full load_queue_snapshot(), so it is seconds wide, and foreign sessions acting
on this repo concurrently are an observed condition.

The egress mismatch now releases the assignment the allocator created before
refusing. When the release succeeds the refusal reports mutation_performed
False and a compensation record; when it fails the response carries
mutation_performed True, an explicit orphaned_assignment, and a
gitea_release_workflow_lease reclaim action, because claiming nothing changed
while a lease is live is the defect rather than a report of it.

The same state was reachable through _run_allocator's bare except Exception:
allocate_next_work only catches InvalidWorkKindError, LeaseRequiredError and
ControlPlaneError, so anything raised after assign_and_lease committed arrived
as "no result" with a durable lease. A None result on the apply path now sweeps
and releases whatever this flow's session owns. That sweep is only possible
because of the B2 fix below — the session id is now stable across the flow, so
the lease is findable.

B2 — the "read-only" preview wrote to the control-plane DB.

allocate_next_work called db.upsert_session and db.expire_stale_leases
unconditionally, before the apply branch was consulted, and default_allocator
minted a fresh webui-request-<hex> per call. Every preview therefore appended a
never-reused session row and mutated global lease state while the payload said
dry_run True / mutation_performed False, driven by an operator refreshing a
form. One apply wrote two rows and bound the lease to the second, which is why
B1's orphan had no reclaimable owner.

allocate_next_work gains a keyword-only side_effect_free flag, default False so
every existing caller is byte-for-byte unchanged. Under the flag both writes are
suppressed and expired leases are instead filtered out of the claim map in
memory, which reaches the same selection the sweep would have produced without
persisting anything; a claim whose expiry cannot be parsed is kept, since an
unreadable expiry is not evidence that work is free. side_effect_free with
apply=True fails closed rather than silently reserving. request_service mints
one session id per request flow and threads it through both the dry-run and the
apply, and the dry-run now routes through the side-effect-free path.

Coverage.

test_allocator_drift_on_apply_is_not_read_as_an_assignment asserted the defect —
it built the orphan state and then required mutation_performed to be False, which
a leak satisfies. It now requires the compensating release. Added: release-failure
surfacing a reclaim action, an assignment with no lease id, the post-commit
exception route, and session-id identity across the flow. The two areas the review
named as having zero coverage now have it: default_allocator past its two
fail-closed early returns (side_effect_free routing, session-id pass-through and
minting, scope and fingerprint propagation) and default_claims_source (scoped
read, and an unreadable substrate denying rather than reading as "nothing
claimed"). allocator_service gains side-effect-free tests against a real
temp-file DB plus the expiry-filter unit tests.

Every new guard was mutation-tested by reverting it one at a time: B1
compensation removed → 3 failures; post-commit sweep removed → 1; session id
re-minted per call → 2; side_effect_free ignored → 3; in-memory expiry filter
removed → 1.

Verification: WEBUI_TEST_OFFLINE=1 python -m pytest tests/ -q from this
branches/ worktree gives 23 failed, 5263 passed, 6 skipped, 899 subtests. The
sorted FAILED set is identical to the reviewer's clean-master baseline at
2f4dec83 (23 failed, 5190 passed) — no new, changed, or disappeared failure —
and 21 tests were added over the reviewed head's 5242. Zero conflict markers;
py_compile passes; git diff --check clean.

Refs #643, PR #902

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01V6xFqovhbArPv61j9KCGkL
2026-07-25 03:32:24 -04:00
sysadminandClaude Opus 5 220361ad94 fix(restart): require both authorizations for apply, correct coordinator doc
Addresses the two blockers raised in the PR #886 review (comment 16559) for
issue #663.

B1 — apply_authorized ignored restart-class authorization.

The #663 restart-class matrix and the #661 drain-proof hard gate are
independent authorizations that first coexisted when PR #882 landed on
master and this branch merged it. The union preserved both, but the apply
decision consulted only the drain gate:

    payload["apply_authorized"] = gate.allow

so a clean drain proof — or an authorized break-glass, which needs no proof
at all — reported apply_authorized: True for a class the least-privilege
matrix had just denied, in the same payload carrying allow_restart: False
and "role 'author' may not request full_mcp_restart". One environment
variable therefore collapsed the whole nine-class matrix for the apply
decision, including host_restart.

The apply decision is now the conjunction of both authorizations, and
apply_gate carries drain_gate_allow and restart_class_authorized so a denial
is attributable to the authorization that produced it. Break-glass keeps its
purpose — bypassing the drain proof — and never bypasses the class matrix.
No existing fail-closed behaviour is weakened: allow_restart, drain-proof
verification, fingerprint binding, and requester authorization are untouched.

B2 — docs/mcp-restart-coordinator.md described pre-#661 behaviour.

The document still called the drain proof "a separate child" and omitted
drain_proof_json and request_break_glass from the published signature, so a
safety document asserted there was no gate where a gate now exists. It now
documents both parameters, states that the gate executes inside this tool,
and records dry-run versus apply behaviour, authorization ordering, the
break-glass scope, and fail-closed conditions as implemented.

Regression coverage.

tests/test_issue_886_apply_authorization_conjunction.py exercises the MCP
tool itself, which previously had no test at all — that absence is why the
defect shipped. It pins both conjunction directions, proves a clean proof
cannot override a role, approval, unknown-class, or missing-target denial,
proves break-glass does not collapse the matrix for any worker role or
restricted class, and proves the existing scoped and unscoped paths and the
#661 denials still hold. Against the pre-fix tree 24 of these fail; against
this commit all 19 pass with 45 subtests.

tests/test_mcp_restart_governance_docs.py now binds the published signature
to inspect.signature() of the real tool and forbids the stale pre-#661
phrasing, so the drift that produced B2 cannot return unnoticed.

Verification: targeted restart/drain/governance/webui suites 194 passed,
113 subtests. Full suite 23 failed, 5230 passed, 6 skipped, 912 subtests —
the failure set is identical to the reviewed baseline at 9bc021e
(23 failed, 5201 passed), with +29 passing from the added tests and no new
or changed failure. Zero conflict markers; py_compile passes; the #882
union remains intact in both directions.

Refs #663, PR #886

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01V6xFqovhbArPv61j9KCGkL
2026-07-25 02:36:41 -04:00
sysadminandClaude Opus 4.8 433f66add8 feat(webui): request preview, authorization, and workflow initiation (Closes #643)
Operators had to paste a role prompt into a terminal to start work, and
nothing enforced that the allocator had been consulted first, so two sessions
could reach for the same issue and each believe it was theirs. This adds a
request surface: a desired role, an issue or PR, and a stated intent, answered
by an authorization decision and - on confirmation - an exclusive assignment
from the allocator.

Preview (POST /api/v1/requests/preview, and the /requests form) runs five
checks and reports authorize/deny with a reason for each: console
authorization, capability resolution for the desired role, lease availability,
whether the allocator would independently select this work unit, and head
pinning for PR work. It is read-only - it calls the allocator with apply=false
and writes only an audit line. An unauthorized principal never reaches the
allocator or the control-plane DB, so a denial cannot enumerate the queue.

Initiation (POST /api/v1/requests/apply) never assigns the requested item
directly. It runs a dry-run first and proceeds only when the allocator would
independently pick that exact work unit, carrying the dry-run's
candidate_set_fingerprint as a CAS pin; otherwise it returns wait or blocked
and mutates nothing. An active claim on the work unit rejects a duplicate
assign before one is attempted. A returned assignment carries a handoff block
naming the required profile, namespace, and the actions that stay forbidden.

Authorization reuses the #633 model rather than adding a second one. The new
initiate_workflow action is operator-class because its outcome is a claim, not
a Gitea verdict: requesting reviewer or merger work reserves that work but
grants no right to approve or merge. Execution is gated by a new per-action
execution_env_flag (WEBUI_REQUESTS_EXECUTION), deliberately in place of raising
ACTIVE_PHASE - a phase bump would enable execution for every phase-2 action at
once, including ones whose execution path is not implemented. Actions that
declare no flag are unchanged and still report execution_enabled false.

Every preview and apply emits a console audit record correlated to the
resulting assignment by correlation.request_id.

Fail-closed throughout: an unreadable control-plane DB, an incomplete queue
inventory (#758), an allocator that raises, an unpinned PR head, a moved PR
head, and an unconfirmed apply all deny without mutating.

Files:
- webui/request_service.py (new) - request model, preview, initiation
- webui/request_views.py (new) - form and preview rendering, escaped
- tests/test_webui_request_initiation.py (new) - 52 tests
- webui/console_authz.py - initiate_workflow action, execution_wired()
- webui/app.py - /requests, /api/v1/requests/preview, /api/v1/requests/apply
- webui/nav.py - Requests nav entry
- webui/traffic_loader.py - public candidates_from_queue_snapshot alias
- docs/webui-requests.md (new), docs/webui-authz-audit.md

Validation: full suite on this branch 5242 passed, 6 skipped, 899 subtests, 23
failed. Clean master baseline at 2f4dec83 in an equivalent branches/ worktree:
5190 passed, 6 skipped, 867 subtests, the same 23 tests failed. The branch adds
52 passing tests and introduces no new full-suite failure signature.

Closes #643

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 01:47:38 -04:00
sysadmin 6e6ca94338 fix(gate): stop classifying stale-runtime blocks as permission denials (Closes #897)
Stale-runtime and runtime-mode mutation refusals previously shared the
permission-denial channel, so permission_report claimed a missing op the
active profile already held and recommended gitea_activate_profile.
Typed blocker_kind payloads report reconnect-only recovery for staleness,
omit permission_report for non-permission gates, and fail closed when a
permission_report would invent a missing permission the profile holds.
2026-07-25 01:47:35 -04:00
jcwalker3 d5d121a21b Merge branch 'master' into feat/issue-641-runtime-session-view 2026-07-25 00:27:10 -05:00
sysadminandClaude Opus 4.8 1ca2b50406 fix(webui): authority-aware ownership + redacted contamination text (#641)
Addresses the two blockers from the PR #898 review at a81db754.

B1 - degraded ownership inventory was rendered as affirmative absence.
_build_session_rows read the sessions/leases/locks sections without
consulting their status, so a session row emitted lease_ids=() and
worktree_paths=() whether the session genuinely held nothing or the
lease store simply could not be read. The renderer printed both as
"none" and "unbound", contradicting the ownership_authority_complete
invariant documented on InventorySnapshot.

SessionRow now carries lease_authority and worktree_authority. A
worktree binding is correlated through lease work numbers, so it is
unproven when either the leases or the locks section fails to read --
this covers the narrow variant where locks hold real worktree paths but
a degraded leases section leaves work_numbers empty. The renderer emits
"unknown (inventory <status>)" with an authority-unproven badge instead
of none/unbound, the card names the unreadable sections, and an empty
session list from an unreadable sessions section no longer reads as
"no sessions recorded". snapshot_to_dict exports
ownership_authority_complete, ownership_section_status, and per-row
lease_authority / worktree_authority so /api/sessions consumers can
distinguish the two cases.

B2 - contamination payload strings bypassed redaction.
_inspect_contamination copied command_summary, session_id, role and
reason_class out of the marker payload with only str(), while every
inventory-sourced field on the same page arrives through
webui.inventory.scrub(). The write-time redactor
stable_branch_push_guard.redact_command is a narrow denylist that leaves
absolute $HOME paths, -H 'X-Api-Key: <value>', --password <value>, and
PRIVATE_KEY=<value> intact, and this is the first web surface to render
command_summary at all.

Adds webui.inventory.scrub_text(), which collapses $HOME and redacts
credential-shaped tokens and URL userinfo anywhere inside a string rather
than only at its start, and routes the marker payload through it. scrub()
and every existing caller are untouched. The command_summary field is
kept: it is the #630 evidence naming which daemon was killed. The module
docstring claiming absolute paths were already collapsed is corrected.

Also: removes the locks_by_session_hint dead loop and its discard (N1),
adds the missing trailing newline to webui/runtime_views.py (N4), drops
an unused dataclasses.field import, and documents both honesty rules in
docs/webui-local-dev.md.

Tests: tests/test_webui_sessions_view.py grows from 12 to 26 cases,
covering degraded and unavailable ownership sections in both the HTML and
JSON paths, the locks-readable/leases-degraded variant, a guard against
over-correcting clean inventory into "unknown", the previously untested
expired-lease flag, HTML escaping of hostile values in clean and degraded
renders, and each secret class the write-time denylist misses. The
STATUS_UNAVAILABLE import that was present but unused is now exercised.

Validation, from the issue worktree with venv/bin/python (Python 3.14.5,
pytest 9.1.1):

  pytest tests/test_webui_sessions_view.py tests/test_webui_*.py -q
    -> 512 passed, 376 subtests (was 498 / 372; +14 new tests)
  pytest tests/test_issue_854_semantic_container_exclusion.py -q
    -> 13 passed, 8 subtests
  13-file runtime/health/inventory/restart set
    -> 1 failed, 223 passed; the single failure is
       test_runtime_clarity.py::TestRuntimeClarity::
       test_activate_profile_succeeds_when_enabled, the identical test and
       assertion the reviewer recorded on master at 7af40fb5, so it is
       baseline-equivalent and not introduced here.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-25 01:25:28 -04:00