Compare commits

...
Author SHA1 Message Date
sysadminandClaude Opus 4.8 78cc37a977 feat(observability): optional self-hosted Sentry instrumentation for MCP workflow failures (Closes #606)
Add an env-var-gated, off-by-default Sentry SDK integration so MCP runtime
errors, fail-closed workflow blockers, lease/terminal-lock/stale-runtime
collisions, and recurring watchdog check-ins are visible in a self-hosted
Sentry at https://sentry.prgs.cc/. Gitea stays the source of truth; Sentry is
observe-only.

New module `sentry_observability.py` mirrors the `gitea_audit` conventions
(env-gated, best-effort, redacting):

- Config from MCP_SENTRY_ENABLED / SENTRY_DSN / SENTRY_ENVIRONMENT /
  SENTRY_RELEASE / MCP_SENTRY_TRACES_SAMPLE_RATE / MCP_SENTRY_ENABLE_LOGS.
  Active only when enabled AND a DSN is present; otherwise a hard no-op.
- Fail OPEN for observability (never blocks a tool success path) and fail
  CLOSED for redaction (drop a field rather than risk leaking it).
- `scrub_event` before_send/before_send_log hook + allowlisted tags: no
  tokens/passwords/keychain ids/DSNs/cookies, no raw session-state or full
  prompt bodies (session_id -> 12-char hash), no full filesystem paths
  (worktree path -> coarse category). Reuses incident_bridge + gitea_audit
  scrubbers.
- capture_exception, capture_workflow_blocker (with canonical next action),
  and monitor_checkin with six stable cron slugs (stale lease scan, terminal
  lock scan, allocator health, namespace health, dashboard freshness,
  reconciler cleanup).
- `sentry_sdk` is a lazily-imported optional dependency; the module imports
  and no-ops cleanly when the package is absent.

Wiring in gitea_mcp_server.py (additive, guarded, best-effort):
- init_sentry() in __main__ before mcp.run.
- capture_exception in the `_audited` failure path; capture_workflow_blocker
  in `_audit_pr_result` BLOCKED/FAILED path.
- allocator + namespace-health watchdog check-ins at their MCP tool sites
  (domain modules left pure).

Also: pin `sentry-sdk==2.20.0` (optional), document the six env vars in
`.env.example`, and add `docs/observability/sentry-integration.md` covering
project creation in https://sentry.prgs.cc/, DSN handling, local/dev/prod
config, redaction guarantees, and coexistence with the #612 incident bridge.

Tests: tests/test_sentry_observability.py (36 cases) cover disabled / enabled /
missing-DSN / missing-SDK, redaction, exception capture, workflow-blocker
capture, and cron check-in behaviour. Full suite: 2632 passed, 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-12 02:35:47 -04:00
sysadmin 22698c1b5f Merge pull request 'feat(guard): block direct stable-branch pushes from MCP workflow sessions (Closes #671)' (#677) from fix/issue-671-block-stable-branch-push into master 2026-07-11 20:05:45 -05:00
sysadmin de27053f74 Merge pull request 'fix: residual preflight/worktree/test-isolation remediations after #673' (#676) from fix/issue-675-residual-preflight-remediation into master 2026-07-11 19:15:56 -05:00
sysadminandClaude Opus 4.8 5933d87647 feat(guard): block direct stable-branch pushes from MCP workflow sessions (Closes #671)
Prevention hardening for the #670 incident (bare direct-to-master commit
2fa97c26 and the PR #654 merger stable-branch push attempt). Worker sessions
must never publish stable branches directly; stable updates land only through
sanctioned Gitea merge tooling or an authorized reconciler path.

New pure module stable_branch_push_guard.py:
- classify_push_command: detects git push <remote> <stable-ref> equivalents
  including refspecs (HEAD:<ref>, +refs/heads/x:refs/heads/<ref>), --force,
  --delete/:<ref>, and --dry-run/-n no-op probes (dry-run still proves intent).
  Fetch/pull and feature-branch pushes are never flagged; sanctioned
  gitea_merge_pr / API merge is not a push.
- assess_root_checkout_local_commit: detects control-checkout commits not on an
  issue feature branch (branches/ worktrees exempt).
- redact_command: strips URL userinfo/token assignments before logging.
- build_contamination_record + assess_contamination_gate: durable marker shape
  and fail-closed gate (reconciler-exempt; comment/lock stay allowed for handoff).

Server wiring (gitea_mcp_server.py, mcp_session_state.py):
- KIND_STABLE_BRANCH_CONTAMINATION durable session marker (per profile identity).
- _enforce_stable_branch_contamination_gate wired into verify_preflight_purity
  so review/merge/close/completion mutations fail closed while contaminated.
- gitea_record_stable_branch_push_attempt: classify + mark on detection.
- gitea_audit_stable_branch_contamination: reconciler-only inspect/clear; a
  worker session can never self-clear.

Tests (AC5): no-op dry-run push, real direct push, sanctioned Gitea merge,
fetch-only, root-checkout local commit, feature-branch push allowed, gate
block/reconciler-exempt, redaction. 51 new tests; full suite 2596 passed.

Docs: llm-project-workflow SKILL.md — universal rule, blocker class, and a
dedicated "Stable Branch Push Protection" section (worker sessions must never
push stable branches directly).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-11 20:07:40 -04:00
sysadmin 72b18143f8 fix(preflight): unify review worktree regex, resolve Web UI NameError, and fix test isolation 2026-07-11 19:49:16 -04:00
sysadmin 8886ce201f fix(preflight): bypass workspace checks in test mode and mock subprocess in alignment tests 2026-07-11 19:49:16 -04:00
sysadmin bee24e2b10 Merge pull request 'fix(test): remediate repository-wide regressions (Closes #673)' (#674) from fix/issue-673-remediate-regressions into master 2026-07-11 18:16:05 -05:00
18 changed files with 2511 additions and 19 deletions
+21
View File
@@ -39,6 +39,27 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log
# only — never the token value. Surfaced by gitea_get_profile.
GITEA_TOKEN_SOURCE=GITEA_TOKEN
# ── Optional self-hosted Sentry observability (#606) ────────────────────────
# Emits runtime errors, fail-closed workflow blockers, lease/terminal-lock/
# stale-runtime collisions, and watchdog cron check-ins to a SELF-HOSTED Sentry
# (https://sentry.prgs.cc/) — never Sentry Cloud. Gitea stays the source of
# truth; Sentry is observe-only. OFF by default: with MCP_SENTRY_ENABLED unset
# or SENTRY_DSN empty, nothing is initialised and no events are sent.
#
# Master gate. Truthy = 1/true/yes/on. Both this AND SENTRY_DSN are required.
MCP_SENTRY_ENABLED=0
# DSN for the self-hosted project (create a `gitea-tools-mcp` project in
# https://sentry.prgs.cc/ and copy its DSN). Never commit a real DSN.
SENTRY_DSN=
# Deployment environment tag (local/dev/prod). Defaults to "development".
SENTRY_ENVIRONMENT=development
# Optional release identifier (e.g. a git SHA or version string).
SENTRY_RELEASE=
# Performance-trace sample rate, 0.01.0 (clamped). Default 0.0 (traces off).
MCP_SENTRY_TRACES_SAMPLE_RATE=0.0
# Set to 1 to forward Python logs to Sentry as structured logs. Default off.
MCP_SENTRY_ENABLE_LOGS=0
# Optional canonical runtime-profile config (#19). Instead of the fields above,
# point every LLM launcher at ONE JSON file of named profiles and select one.
# Secrets are referenced (keychain id / env var name), never inlined. See
+138
View File
@@ -0,0 +1,138 @@
# Self-hosted Sentry observability for the Gitea MCP server (#606)
Optional, **off-by-default** instrumentation that reports MCP runtime errors,
fail-closed workflow blockers, lease / terminal-lock / stale-runtime
collisions, and recurring watchdog check-ins to a **self-hosted** Sentry at
`https://sentry.prgs.cc/`.
> **Gitea remains the source of truth.** Sentry is observe-only. It never
> approves, merges, closes, or otherwise mutates Gitea workflow state, and it
> never bypasses leases, #332, workflow roles, or the MCP gates. Sentry alerts
> may only feed the *sanctioned* Gitea issue/comment path via the #612 incident
> bridge — never a direct write.
Implemented by [`sentry_observability.py`](../../sentry_observability.py).
---
## 1. Create the Sentry project
1. Sign in to the self-hosted Sentry at **`https://sentry.prgs.cc/`** (this is
**not** Sentry Cloud — do not use `*.ingest.sentry.io`).
2. Create a new **Python** project named **`gitea-tools-mcp`**.
3. Open **Settings → Projects → gitea-tools-mcp → Client Keys (DSN)** and copy
the DSN. It looks like `https://<publickey>@sentry.prgs.cc/<project-id>`.
4. **Never commit the DSN.** It is a runtime secret supplied via env var only.
## 2. Configure the environment
All configuration is env-var driven (see [`.env.example`](../../.env.example)):
| Variable | Purpose | Default |
|----------|---------|---------|
| `MCP_SENTRY_ENABLED` | Master gate (`1/true/yes/on`). Required. | off |
| `SENTRY_DSN` | Self-hosted DSN. Required. | *(empty)* |
| `SENTRY_ENVIRONMENT` | `local` / `dev` / `prod` tag. | `development` |
| `SENTRY_RELEASE` | Release id (git SHA or version). | *(none)* |
| `MCP_SENTRY_TRACES_SAMPLE_RATE` | Perf-trace sample rate `0.01.0` (clamped). | `0.0` |
| `MCP_SENTRY_ENABLE_LOGS` | Forward Python logs as structured logs. | off |
**The feature stays completely off unless `MCP_SENTRY_ENABLED` is truthy *and*
`SENTRY_DSN` is non-empty.** With either missing, `init_sentry()` is a no-op,
the SDK is never initialised, and no events are sent — existing tool behaviour
and API-call patterns are unchanged.
### Per-environment examples
```bash
# local (quiet: capture errors/blockers, no traces)
export MCP_SENTRY_ENABLED=1
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
export SENTRY_ENVIRONMENT=local
# dev (light tracing + logs)
export MCP_SENTRY_ENABLED=1
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
export SENTRY_ENVIRONMENT=dev
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.2
export MCP_SENTRY_ENABLE_LOGS=1
# prod (errors/blockers + low-rate tracing, release-tagged)
export MCP_SENTRY_ENABLED=1
export SENTRY_DSN="https://<key>@sentry.prgs.cc/<id>"
export SENTRY_ENVIRONMENT=prod
export SENTRY_RELEASE="$(git rev-parse --short HEAD)"
export MCP_SENTRY_TRACES_SAMPLE_RATE=0.05
```
The optional SDK is pinned in [`requirements.txt`](../../requirements.txt)
(`sentry-sdk==2.20.0`). It is imported lazily: if the package is absent, the
module still imports and every entry point is a safe no-op.
## 3. What is instrumented
| Signal | Where | Notes |
|--------|-------|-------|
| Startup init | `gitea_mcp_server.py` `__main__`, before `mcp.run` | Prints a redaction-safe status line to stderr. |
| Failing mutations (exceptions) | `_audited(...)` context manager | `capture_exception` with scrubbed tags. |
| Fail-closed blockers / failed mutations | `_audit_pr_result(...)` (BLOCKED/FAILED) | Structured `capture_workflow_blocker` event incl. the canonical next action when available (criterion 7). |
| Allocator watchdog check-ins | `gitea_allocate_next_work` tool | `allocator_health`, `stale_lease_scan`, `terminal_lock_scan`. |
| Namespace-health check-in | `gitea_assess_mcp_namespace_health` tool | `namespace_health`. |
All capture paths are **best-effort / fail open**: a Sentry outage or capture
error never breaks an MCP tool success path.
## 4. Cron / watchdog monitors
`sentry_observability.MONITOR_SLUGS` defines stable check-in slugs:
| Registry key | Sentry monitor slug | Wired at |
|--------------|--------------------|----------|
| `stale_lease_scan` | `gitea-mcp-stale-lease-scan` | allocator run (global lease expiry) |
| `terminal_lock_scan` | `gitea-mcp-terminal-lock-scan` | allocator run (terminal-lock lookup) |
| `allocator_health` | `gitea-mcp-allocator-health` | allocator run |
| `namespace_health` | `gitea-mcp-namespace-health` | namespace-health probe |
| `dashboard_freshness` | `gitea-mcp-dashboard-freshness` | call `monitor_checkin("dashboard_freshness", ...)` from the dashboard refresh job (#605) |
| `reconciler_cleanup` | `gitea-mcp-reconciler-cleanup` | call `monitor_checkin("reconciler_cleanup", ...)` from the reconciler cleanup entrypoint |
Create matching Cron monitors in Sentry with those slugs. Emit an
`in_progress` check-in at job start and `ok`/`error` at completion via
`sentry_observability.monitor_checkin(slug_key, status)`.
## 5. Redaction guarantees (fail closed)
Redaction fails *closed*: if a field cannot be proven safe it is dropped rather
than sent. The `before_send` (and `before_send_log`) hook `scrub_event`
recursively redacts every outgoing event; on any error it drops the event
entirely. Guarantees, proven by `tests/test_sentry_observability.py`:
- **No** tokens, passwords, keychain IDs, DSNs, cookies, or `user:pass@host`.
- **No** raw session-state or full prompt/comment bodies — `session_id` is only
ever surfaced as a 12-char `session_id_hash`.
- **No** private config contents or raw credential headers.
- **No** full local filesystem paths — a worktree path collapses to a coarse
`worktree_category` (`author` / `reviewer` / `merger` / `reconciler` /
`branches` / `root` / `other`).
- Only the allowlisted tag keys in `ALLOWED_TAG_KEYS` are ever attached.
## 6. Coexistence with GlitchTip / the #612 incident bridge
This is the **outbound** path (MCP → Sentry SDK). It complements — it does not
replace — the **inbound** [`incident_bridge.py`](../../incident_bridge.py)
(#612), which turns Sentry/GlitchTip *observations* into durable Gitea issues
and `incident_links` rows.
- Prefer **one** observability path per environment. Point the MCP server's
`SENTRY_DSN` at the same self-hosted `gitea-tools-mcp` project that the #612
bridge reconciles from, so an MCP-reported error and its Gitea issue line up.
- GlitchTip is Sentry-protocol compatible; if an existing GlitchTip DSN is in
use, either migrate it to `https://sentry.prgs.cc/` or document the split
(MCP → Sentry, legacy → GlitchTip) explicitly for operators.
- The bridge remains the **only** sanctioned route from an alert back into
Gitea workflow state.
## 7. Non-goals
- Sentry must **not** become the workflow source of truth.
- Sentry must **not** approve, merge, close, or mutate Gitea workflow state.
- Sentry must **not** bypass leases, #332, workflow roles, or the MCP gates.
+284
View File
@@ -653,6 +653,10 @@ def verify_preflight_purity(
f"'{task}' (fail closed)"
)
# #671: block review/merge/close/completion mutations while the session is
# contaminated by a direct stable-branch push attempt (reconciler-exempt).
_enforce_stable_branch_contamination_gate(task, remote)
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
canonical_root = ctx["canonical_repo_root"]
@@ -724,6 +728,7 @@ def _verify_role_mutation_workspace(
task: str | None = None,
) -> str:
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
# Check running runtimes to prevent stale mutations
try:
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
@@ -809,6 +814,80 @@ def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None:
raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment))
# ── stable-branch push contamination (#671) ──────────────────────────────────
# A worker session that attempts a direct stable-branch push (or a
# root-checkout local commit) is workflow-contaminated. The marker is durable
# (survives daemon process pools like the other session proofs) and keyed per
# profile identity. It fails closed on gated mutations until a reconciler
# audits and clears it.
def _stable_contamination_profile_identity() -> str:
return mcp_session_state.current_profile_identity(
profile_name=get_profile().get("profile_name"),
)
def _load_stable_contamination_marker(remote: str | None = None) -> dict | None:
return mcp_session_state.load_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
remote=remote,
profile_identity=_stable_contamination_profile_identity(),
)
def _save_stable_contamination_marker(
record: dict,
*,
remote: str | None = None,
) -> dict | None:
return mcp_session_state.save_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
payload=record,
remote=remote,
profile_identity=_stable_contamination_profile_identity(),
)
def _clear_stable_contamination_marker(
*,
remote: str | None = None,
profile_identity: str | None = None,
) -> None:
mcp_session_state.clear_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
remote=remote,
profile_identity=profile_identity or _stable_contamination_profile_identity(),
)
def _enforce_stable_branch_contamination_gate(
task: str | None,
remote: str | None = None,
) -> None:
"""#671 AC4: fail closed on gated mutations while contaminated.
Reconciler role is exempt (the sanctioned audit/clear path). Non-gated
tasks (comment_issue, lock_issue) stay allowed so a contaminated worker can
still post the durable audit comment and hand off.
"""
if _preflight_in_test_mode() and not os.environ.get(
"GITEA_TEST_FORCE_STABLE_CONTAMINATION"
):
return
marker = _load_stable_contamination_marker(remote)
if not marker:
return
gate = stable_branch_push_guard.assess_contamination_gate(
marker,
task=task,
actual_role=_actual_profile_role(),
)
if gate["block"]:
raise RuntimeError(
stable_branch_push_guard.format_contamination_gate_error(gate)
)
from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402
@@ -839,6 +918,7 @@ import allocator_service # noqa: E402
import control_plane_db # noqa: E402
import lease_lifecycle # noqa: E402
import incident_bridge # noqa: E402
import sentry_observability # noqa: E402 (#606 optional Sentry observability)
import agent_temp_artifacts
import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
@@ -849,6 +929,7 @@ import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import root_checkout_guard # noqa: E402
import stable_branch_push_guard # noqa: E402
import remote_repo_guard # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
@@ -1697,6 +1778,18 @@ def _audited(action: str, *, host, remote, org=None, repo=None,
result=gitea_audit.FAILED, reason=_redact(str(exc)),
request_metadata=request_metadata, issue_number=issue_number,
pr_number=pr_number, target_branch=target_branch)
# #606: best-effort Sentry capture of the failing mutation (fail open).
sentry_observability.capture_exception(
exc,
tags={
"mutation_tool": action,
"remote": remote,
"repo": repo,
"org": org,
"issue_number": issue_number,
"pr_number": pr_number,
},
)
raise
_audit(action, host=host, remote=remote, org=org, repo=repo,
result=gitea_audit.SUCCEEDED, request_metadata=request_metadata,
@@ -1743,6 +1836,20 @@ def _audit_pr_result(action: str):
"merge_method": result.get("merge_method"),
},
)
# #606: surface fail-closed blockers / failed mutations to
# Sentry as structured events (best-effort, fail open).
if status in (gitea_audit.BLOCKED, gitea_audit.FAILED):
sentry_observability.capture_workflow_blocker(
action,
message="; ".join(reasons) or action,
next_action=result.get("safe_next_action"),
level="error" if status == gitea_audit.FAILED else "warning",
tags={
"mutation_tool": action,
"pr_number": result.get("pr_number"),
"current_head_sha": result.get("head_sha"),
},
)
except Exception:
pass # best-effort; never break the tool
return result
@@ -9273,6 +9380,160 @@ def gitea_diagnose_terminal(
}
@mcp.tool()
def gitea_record_stable_branch_push_attempt(
command: str | None = None,
remote: str = "dadeschools",
ref: str | None = None,
session_id: str | None = None,
mark: bool = True,
current_branch: str | None = None,
head_sha: str | None = None,
remote_master_sha: str | None = None,
is_under_branches: bool | None = None,
ahead_count: int | None = None,
) -> dict:
"""Classify a proposed command for direct stable-branch push intent (#671).
Worker sessions must never publish stable branches (``master``/``main``/
``dev``/...) directly. This tool detects ``git push <remote> master``
equivalents (refspecs, ``HEAD:master``, ``--force``, ``--dry-run`` no-op
intent, ``:master`` delete) and separately a root/control-checkout
local commit not carried by an issue feature branch.
When ``mark`` is true and contamination is detected, a durable
``stable_branch_contamination`` marker is written for the active profile
identity. Subsequent review/merge/close/completion mutations then fail
closed (via the pre-flight gate) until a reconciler audits and clears it.
The stored command summary is redacted; secrets never persist.
Read-only when nothing is detected (or ``mark`` is false). Returns the
classification, any root-checkout assessment, and the marker state.
"""
classification = stable_branch_push_guard.classify_push_command(command)
root_checkout = None
if any(v is not None for v in (current_branch, head_sha, remote_master_sha,
is_under_branches, ahead_count)):
root_checkout = stable_branch_push_guard.assess_root_checkout_local_commit(
current_branch=current_branch,
head_sha=head_sha,
remote_master_sha=remote_master_sha,
is_under_branches=bool(is_under_branches),
ahead_count=ahead_count,
)
push_contam = bool(classification.get("contamination"))
root_contam = bool(root_checkout and root_checkout.get("contamination"))
contaminated = push_contam or root_contam
marker = None
marked = False
if contaminated and mark:
if push_contam:
reason_class = "stable_branch_push"
command_redacted = classification.get("redacted_command")
detail = "; ".join(classification.get("reasons") or [])
resolved_ref = ref or (
(classification.get("stable_refs") or [None])[0]
)
else:
reason_class = "root_checkout_commit"
command_redacted = None
detail = "; ".join(root_checkout.get("reasons") or [])
resolved_ref = ref or root_checkout.get("current_branch")
record = stable_branch_push_guard.build_contamination_record(
reason_class=reason_class,
command_redacted=command_redacted,
session_id=session_id,
remote=remote,
ref=resolved_ref,
role=_actual_profile_role(),
detail=detail,
)
marker = _save_stable_contamination_marker(record, remote=remote)
marked = marker is not None
return {
"classification": classification,
"root_checkout": root_checkout,
"contaminated": contaminated,
"marked": marked,
"marker": marker,
"profile_identity": _stable_contamination_profile_identity(),
"remediation": stable_branch_push_guard.REMEDIATION if contaminated else None,
}
@mcp.tool()
def gitea_audit_stable_branch_contamination(
action: str = "inspect",
remote: str = "dadeschools",
profile_identity: str | None = None,
) -> dict:
"""Reconciler audit of a stable-branch contamination marker (#671).
``action='inspect'`` (default, read-only) loads the durable marker for the
given ``profile_identity`` (or the active session's) and reports it.
``action='clear'`` removes the marker so the contaminated session may
resume gated mutations. Clearing is the reconciler audit path and requires
an active reconciler profile a worker session must never self-clear
(#671 security requirement). ``profile_identity`` targets the contaminated
worker's marker (a reconciler runs under its own identity).
"""
act = (action or "inspect").strip().lower()
target_identity = (profile_identity or "").strip() or _stable_contamination_profile_identity()
marker = mcp_session_state.load_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
remote=remote,
profile_identity=target_identity,
)
if act == "inspect":
return {
"action": "inspect",
"profile_identity": target_identity,
"contaminated": marker is not None,
"marker": marker,
"read_only": True,
}
if act == "clear":
role = _actual_profile_role()
if role != "reconciler":
return {
"action": "clear",
"success": False,
"performed": False,
"profile_identity": target_identity,
"reasons": [
"stable-branch contamination may only be cleared by a "
f"reconciler audit; active role is '{role}' (fail closed). "
"The contaminated worker session must not self-clear."
],
}
_clear_stable_contamination_marker(
remote=remote,
profile_identity=target_identity,
)
return {
"action": "clear",
"success": True,
"performed": True,
"profile_identity": target_identity,
"was_contaminated": marker is not None,
}
return {
"action": act,
"success": False,
"performed": False,
"reasons": [f"unknown action '{act}'; use 'inspect' or 'clear'"],
}
@mcp.tool()
def gitea_validate_review_final_report(
report_text: str,
@@ -9720,6 +9981,11 @@ def gitea_assess_mcp_namespace_health(
probe_source=probe_source,
)
_record_live_namespace_health(result)
# #606: namespace-health watchdog check-in (best-effort, fail open).
sentry_observability.monitor_checkin(
"namespace_health",
"ok" if result.get("healthy", result.get("callable", True)) else "error",
)
return result
@@ -11647,6 +11913,18 @@ def gitea_allocate_next_work(
result["inventory_source"] = (
"candidates_json" if candidates_json else "gitea_live"
)
# #606: watchdog check-ins for the recurring jobs this allocator run
# performs — global stale-lease expiry, terminal-lock lookup, and the
# allocator itself. Best-effort; a failed selection reports "error".
_alloc_ok = bool(result.get("success"))
sentry_observability.monitor_checkin(
"allocator_health", "ok" if _alloc_ok else "error"
)
if _alloc_ok:
# These two scans complete inside allocate_next_work before selection;
# a successful result proves both ran.
sentry_observability.monitor_checkin("stale_lease_scan", "ok")
sentry_observability.monitor_checkin("terminal_lock_scan", "ok")
return result
@@ -11971,4 +12249,10 @@ if __name__ == "__main__":
# processes (e.g. review_pr.py) can detect and refuse profile
# side-channel overrides (#199).
_export_session_profile_lock()
# #606: optional self-hosted Sentry observability. No-op unless
# MCP_SENTRY_ENABLED is truthy and SENTRY_DSN is set; never blocks startup.
_sentry_status = sentry_observability.init_sentry()
sys.stderr.write(
f"--- Sentry observability: {_sentry_status.get('reason')} ---\n"
)
mcp.run(transport="stdio")
+5
View File
@@ -31,6 +31,11 @@ DEFAULT_TTL_HOURS = 4.0
KIND_WORKFLOW_LOAD = "review_workflow_load"
KIND_DECISION_LOCK = "review_decision_lock"
KIND_REVIEW_DRAFT = "review_draft"
# Durable marker set when a worker session attempts a direct stable-branch push
# or a root-checkout local commit (#671). Keyed per profile identity like the
# other session proofs; a contaminated session fails closed on gated mutations
# until a reconciler audits and clears it.
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
+1
View File
@@ -31,6 +31,7 @@ python-multipart==0.0.32
referencing==0.37.0
rich==15.0.0
rpds-py==2026.5.1
sentry-sdk==2.20.0
shellingham==1.5.4
sse-starlette==3.4.5
starlette==1.3.1
+14
View File
@@ -27,6 +27,20 @@ _READONLY_REVIEWER_GIT = re.compile(
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
# #673: Canonical pattern for identifying review worktrees under branches/.
REVIEW_WORKTREE_RE = re.compile(
r"branches/(?:review-pr\d+[\w/-]*|merge-simulation-pr\d+|review-[\w-]+)",
re.IGNORECASE,
)
def is_review_worktree_path(path: str) -> bool:
"""True when the path belongs to a reviewer or simulation worktree."""
normalized = (path or "").replace("\\", "/")
return bool(REVIEW_WORKTREE_RE.search(normalized))
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
"""Return tracked paths with local modifications from ``git status --porcelain``.
+3 -4
View File
@@ -5,10 +5,9 @@ from __future__ import annotations
import re
from typing import Any
_SESSION_OWNED_RE = re.compile(
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
re.IGNORECASE,
)
from reviewer_worktree import REVIEW_WORKTREE_RE
_SESSION_OWNED_RE = REVIEW_WORKTREE_RE
_WORKTREE_PATH_RE = re.compile(
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
re.IGNORECASE,
+535
View File
@@ -0,0 +1,535 @@
"""Optional self-hosted Sentry observability for the Gitea MCP server (#606).
Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed
workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring
watchdog check-ins are visible in a *self-hosted* Sentry at
``https://sentry.prgs.cc/`` — never Sentry Cloud, and never as the workflow
source of truth (Gitea stays canonical).
Design constraints (mirror ``gitea_audit`` and the #612 incident bridge):
- **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN``
empty, ``init_sentry`` is a no-op and no events are ever sent — existing tool
behaviour and API-call patterns are unchanged (acceptance criterion 1).
- **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk``
package, or any capture error must never break an MCP tool success path. Every
public entry point swallows its own exceptions.
- **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped
rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw
session-state, full prompt bodies, and full filesystem paths never leave here.
- **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully
importable and testable without it installed.
Sentry is observe-only: it must not approve, merge, close, or otherwise mutate
Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed
the sanctioned Gitea issue/comment path via the #612 incident bridge.
"""
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
from typing import Any
# Reuse the most comprehensive existing scrubber so redaction stays consistent
# with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain
# ids, session ids, user:pass@host).
from incident_bridge import redact_text as _redact_text
# Second, complementary scrubber: catches bare ``token <value>`` /
# ``Bearer <value>`` / ``Basic <value>`` prefixes and raw URLs that the
# incident-bridge delimiter patterns miss.
from gitea_audit import _redact_str as _redact_prefixes
# ── Optional SDK (lazy, never a hard dependency) ────────────────────────────
try: # pragma: no cover - trivial import guard
import sentry_sdk # type: ignore
except Exception: # pragma: no cover - absence is a supported state
sentry_sdk = None # type: ignore
# ── Env var names (single source of truth) ──────────────────────────────────
ENV_ENABLED = "MCP_SENTRY_ENABLED"
ENV_DSN = "SENTRY_DSN"
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
ENV_RELEASE = "SENTRY_RELEASE"
ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE"
ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS"
_TRUTHY = frozenset({"1", "true", "yes", "on"})
REDACTED = "[REDACTED]"
REDACTED_PATH = "[REDACTED_PATH]"
# ── Cron / watchdog monitor slugs (acceptance criterion 6) ──────────────────
# Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The
# slug is the durable monitor identity in Sentry; the wiring call sites pass one
# of these keys (or an explicit slug) to ``monitor_checkin``.
MONITOR_SLUGS: dict[str, str] = {
"stale_lease_scan": "gitea-mcp-stale-lease-scan",
"terminal_lock_scan": "gitea-mcp-terminal-lock-scan",
"allocator_health": "gitea-mcp-allocator-health",
"namespace_health": "gitea-mcp-namespace-health",
"dashboard_freshness": "gitea-mcp-dashboard-freshness",
"reconciler_cleanup": "gitea-mcp-reconciler-cleanup",
}
_CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"})
# ── Tag allowlist (issue "Suggested Sentry tags/context") ───────────────────
# Only these keys are ever attached as Sentry tags. Anything else is dropped so
# a caller cannot accidentally leak a sensitive value through a tag.
ALLOWED_TAG_KEYS = frozenset({
"role",
"profile",
"namespace",
"repo",
"org",
"issue_number",
"pr_number",
"blocker_type",
"workflow_hash",
"session_id_hash", # hash only — never the raw session id
"pid",
"worktree_category", # category, never the full sensitive path
"lease_comment_id",
"expected_head_sha",
"current_head_sha",
"terminal_lock_state",
"capability",
"mutation_tool",
})
# Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp).
_PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*")
# ``extra`` keys whose *full* contents are forbidden by the redaction rules
# (raw session-state, full prompt/comment bodies, private config blobs, raw
# headers).
_FORBIDDEN_EXTRA_KEYS = frozenset({
"prompt",
"prompt_body",
"next_prompt",
"body",
"raw_body",
"session_state",
"session_state_contents",
"config",
"config_contents",
"private_config",
"headers",
"authorization",
})
# ── Configuration ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SentryConfig:
"""Immutable snapshot of the Sentry env configuration."""
enabled: bool = False
dsn: str | None = None
environment: str = "development"
release: str | None = None
traces_sample_rate: float = 0.0
enable_logs: bool = False
@property
def active(self) -> bool:
"""True only when the operator both opted in *and* supplied a DSN.
This is the single gate that keeps the feature off by default: enabling
the flag without a DSN (or vice versa) sends nothing.
"""
return bool(self.enabled and self.dsn)
def safe_summary(self) -> dict[str, Any]:
"""Operator-facing status with **no** DSN value (only presence)."""
return {
"enabled": self.enabled,
"dsn_present": bool(self.dsn),
"environment": self.environment,
"release": self.release,
"traces_sample_rate": self.traces_sample_rate,
"enable_logs": self.enable_logs,
"active": self.active,
}
def _env_bool(name: str, env: dict[str, str]) -> bool:
return (env.get(name) or "").strip().lower() in _TRUTHY
def _env_float(name: str, default: float, env: dict[str, str]) -> float:
raw = (env.get(name) or "").strip()
if not raw:
return default
try:
val = float(raw)
except (TypeError, ValueError):
return default
# Clamp to Sentry's valid [0.0, 1.0] sample-rate range.
if val < 0.0:
return 0.0
if val > 1.0:
return 1.0
return val
def load_config(env: dict[str, str] | None = None) -> SentryConfig:
"""Build a :class:`SentryConfig` from the environment (read at call time)."""
env = dict(os.environ if env is None else env)
dsn = (env.get(ENV_DSN) or "").strip() or None
return SentryConfig(
enabled=_env_bool(ENV_ENABLED, env),
dsn=dsn,
environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development",
release=(env.get(ENV_RELEASE) or "").strip() or None,
traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env),
enable_logs=_env_bool(ENV_ENABLE_LOGS, env),
)
def sdk_available() -> bool:
"""True when the optional ``sentry_sdk`` package is importable."""
return sentry_sdk is not None
# ── Redaction (fail closed) ─────────────────────────────────────────────────
def sanitize_path(value: Any) -> str:
"""Reduce a filesystem path to a non-sensitive *category* token.
Full local paths must never be sent. We keep only a coarse worktree
category derived from the path shape (author/reviewer/merger/reconciler/
branches/root/other).
"""
text = "" if value is None else str(value)
low = text.lower()
if not text:
return "unknown"
# Order matters: more specific role markers before the generic "branches".
if "reconcile" in low:
return "reconciler"
if "review" in low:
return "reviewer"
if "merge" in low or "merger" in low:
return "merger"
if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low):
return "author"
if "/branches/" in low:
return "branches"
if low.rstrip("/").endswith("gitea-tools"):
return "root"
return "other"
def redact_value(value: Any) -> Any:
"""Recursively redact a JSON-able value: secret text, absolute paths, and
known-sensitive dict keys are removed. Fail closed — any error drops the
value entirely rather than risk leaking it."""
try:
if isinstance(value, dict):
out: dict[str, Any] = {}
for k, v in value.items():
key = str(k)
low = key.lower()
if low in _FORBIDDEN_EXTRA_KEYS or any(
s in low
for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain")
):
out[key] = REDACTED
continue
out[key] = redact_value(v)
return out
if isinstance(value, (list, tuple)):
return [redact_value(v) for v in value]
if isinstance(value, str):
scrubbed = _redact_text(value)
scrubbed = _redact_prefixes(scrubbed)
scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed)
return scrubbed
return value
except Exception:
return REDACTED
def hash_session_id(session_id: Any) -> str:
"""Short, stable, non-reversible fingerprint of a session id."""
digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest()
return digest[:12]
def build_tags(**kwargs: Any) -> dict[str, str]:
"""Return a scrubbed, allowlisted tag dict.
``session_id`` is accepted but only ever surfaced as ``session_id_hash``.
``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted
key, or a value that still contains redacted material after scrubbing, is
dropped.
"""
raw: dict[str, Any] = dict(kwargs)
# Hash the session id — never emit it raw.
session_id = raw.pop("session_id", None)
if session_id and "session_id_hash" not in raw:
raw["session_id_hash"] = hash_session_id(session_id)
# A full worktree path collapses to a category tag.
wt = raw.pop("worktree_path", None)
if wt and "worktree_category" not in raw:
raw["worktree_category"] = sanitize_path(wt)
out: dict[str, str] = {}
for key, val in raw.items():
if key not in ALLOWED_TAG_KEYS:
continue
if val is None:
continue
scrubbed = redact_value(val)
text = str(scrubbed)
if not text or REDACTED in text or REDACTED_PATH in text:
continue
if len(text) > 200:
text = text[:200] + ""
out[key] = text
return out
def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None:
"""Sentry ``before_send`` / ``before_send_log`` hook.
Recursively redacts the outgoing event. On *any* failure it returns ``None``
so the event is dropped rather than sent unscrubbed (fail closed for
redaction).
"""
try:
if not isinstance(event, dict):
return None
scrubbed = redact_value(event)
# Drop server_name if it leaked a hostname/path; PID is kept via tags.
scrubbed.pop("server_name", None)
return scrubbed
except Exception:
return None
# ── Event builders (pure, independently testable) ───────────────────────────
def build_blocker_event(
blocker_type: str,
*,
message: str | None = None,
next_action: str | None = None,
level: str = "warning",
tags: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a redacted, structured Sentry event for a workflow blocker.
``next_action`` maps the issue's "canonical next action when available"
requirement (acceptance criterion 7).
"""
merged_tags = dict(tags or {})
merged_tags.setdefault("blocker_type", blocker_type)
safe_tags = build_tags(**merged_tags)
safe_extra = redact_value(dict(extra or {}))
if next_action:
# A short canonical next action is allowed (it is not a full prompt).
safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500])
event: dict[str, Any] = {
"message": redact_value(message or blocker_type),
"level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning",
"logger": "gitea-mcp.workflow",
"tags": safe_tags,
"extra": safe_extra,
"fingerprint": ["workflow-blocker", blocker_type],
}
return event
def build_checkin_payload(
monitor: str,
status: str,
*,
check_in_id: str | None = None,
duration: float | None = None,
) -> dict[str, Any]:
"""Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`.
``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an
explicit slug. Raises ``ValueError`` on an unknown status so callers cannot
silently send a malformed check-in.
"""
if status not in _CHECKIN_STATUSES:
raise ValueError(
f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}"
)
slug = MONITOR_SLUGS.get(monitor, monitor)
payload: dict[str, Any] = {"monitor_slug": slug, "status": status}
if check_in_id:
payload["check_in_id"] = str(check_in_id)
if duration is not None:
try:
payload["duration"] = float(duration)
except (TypeError, ValueError):
pass
return payload
# ── Runtime init + capture (fail open) ──────────────────────────────────────
_STATE: dict[str, Any] = {"initialized": False, "config": None}
def is_initialized() -> bool:
return bool(_STATE.get("initialized"))
def active_config() -> SentryConfig | None:
return _STATE.get("config")
def reset_for_tests() -> None:
"""Clear module init state. Test-only helper (never called in production)."""
_STATE["initialized"] = False
_STATE["config"] = None
def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]:
"""Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present.
Idempotent and never raises. Returns an operator-safe status dict (no DSN
value). Behaviour is unchanged when the feature is off.
"""
cfg = config or load_config()
status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()}
try:
if not cfg.active:
status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)"
_STATE["config"] = cfg
return status
if not sdk_available():
status["reason"] = "sentry_sdk not installed"
_STATE["config"] = cfg
return status
init_kwargs: dict[str, Any] = {
"dsn": cfg.dsn,
"environment": cfg.environment,
"release": cfg.release,
"traces_sample_rate": cfg.traces_sample_rate,
"before_send": scrub_event,
"send_default_pii": False,
}
if cfg.enable_logs:
# sentry-sdk 2.x captures Python logs as structured logs when the
# experimental logs feature is enabled; scrub those too.
init_kwargs["_experiments"] = {
"enable_logs": True,
"before_send_log": scrub_event,
}
sentry_sdk.init(**init_kwargs) # type: ignore[union-attr]
_STATE["initialized"] = True
_STATE["config"] = cfg
status["initialized"] = True
status["reason"] = "sentry initialised"
except Exception as exc: # fail open: observability must not block startup
status["reason"] = f"init failed (ignored): {type(exc).__name__}"
_STATE["initialized"] = False
return status
def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None:
for key, val in tags.items():
try:
scope.set_tag(key, val)
except Exception:
pass
def capture_workflow_blocker(
blocker_type: str,
*,
message: str | None = None,
next_action: str | None = None,
level: str = "warning",
tags: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Capture a fail-closed workflow blocker as a structured Sentry event.
Always returns the redacted event dict (so callers/tests can inspect it),
and sends it to Sentry only when initialised. Fail open.
"""
event = build_blocker_event(
blocker_type,
message=message,
next_action=next_action,
level=level,
tags=tags,
extra=extra,
)
try:
if is_initialized() and sdk_available():
sentry_sdk.capture_event(event) # type: ignore[union-attr]
except Exception:
pass
return event
def capture_exception(
exc: BaseException,
*,
tags: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
) -> bool:
"""Capture a runtime exception with scrubbed tags. Fail open.
Returns True only when the event was handed to an initialised SDK.
"""
try:
if not (is_initialized() and sdk_available()):
return False
safe_tags = build_tags(**(tags or {}))
safe_extra = redact_value(dict(extra or {}))
with sentry_sdk.push_scope() as scope: # type: ignore[union-attr]
_set_scope_tags(scope, safe_tags)
for key, val in safe_extra.items():
try:
scope.set_extra(key, val)
except Exception:
pass
sentry_sdk.capture_exception(exc) # type: ignore[union-attr]
return True
except Exception:
return False
def monitor_checkin(
monitor: str,
status: str,
*,
check_in_id: str | None = None,
duration: float | None = None,
) -> dict[str, Any] | None:
"""Send a Sentry cron check-in for a watchdog job. Fail open.
Returns the payload (for inspection/tests), or ``None`` if the status was
invalid. Only transmits when initialised.
"""
try:
payload = build_checkin_payload(
monitor, status, check_in_id=check_in_id, duration=duration
)
except ValueError:
return None
try:
if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"):
sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr]
except Exception:
pass
return payload
+50
View File
@@ -32,6 +32,15 @@ workflow file.
mutation.
- A nearby capability does not count.
- Do not self-review or self-merge.
- **Never push a stable branch directly.** Worker sessions (author/reviewer/merger)
must never run `git push <remote> master` — or `main`/`dev`/other stable refs,
including refspecs (`HEAD:master`), `--force`, `--delete`, or `--dry-run` no-op
probes — and must never commit on the root/control checkout outside an issue
feature branch. Stable-branch updates land ONLY through sanctioned Gitea merge
tooling (`gitea_merge_pr`) or an explicitly authorized reconciler path. A
detected attempt marks the session workflow-contaminated (#671) and fails
closed on review/merge/close/completion until a reconciler audits and clears
it. `git fetch` / `git pull --ff-only` and feature-branch pushes stay allowed.
- Do not mix modes in one run.
- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless.
- If the required workflow cannot be loaded, stop and produce a recovery handoff
@@ -48,6 +57,11 @@ workflow file.
- wrong profile or role for the requested operation
- dirty or misbound worktree (root checkout or non-branches/ path)
- root checkout mutation risk
- stable-branch push contamination (#671): a direct `git push <remote> master`
(or `main`/`dev`) equivalent, or a root/control-checkout commit not on an issue
feature branch, marks the session workflow-contaminated; review/merge/close/
completion mutations then fail closed until a reconciler audits and clears it
(`gitea_audit_stable_branch_contamination action=clear`, reconciler-only)
- mutation guard failure (e.g. branches-only guard)
- missing required MCP tool/schema or operation
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
@@ -118,6 +132,42 @@ The main project checkout is a stable control checkout on `master`, `main`, or
If `cwd` is not inside `branches/`, stop before any file edit, test write,
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
## Stable Branch Push Protection (#671)
Worker sessions must **never** publish a stable branch directly. This is the
prevention hardening for the #670 incident (a bare direct-to-master commit
`2fa97c26` and a PR #654 merger `git push prgs master` attempt).
**Forbidden for author/reviewer/merger sessions:**
- `git push <remote> master` (and `main`, `dev`, `develop`, `development`),
including refspecs (`HEAD:master`, `+refs/heads/x:refs/heads/master`),
`--force`, `--delete` / `:master`, and `--dry-run`/`-n` no-op probes (a
dry-run still proves intent and contaminates the session).
- Local commits on the root/control checkout that are not carried by an issue
feature branch under `branches/`.
**Allowed (never blocked):**
- `git fetch` and `git pull --ff-only` of master into the control checkout when
authorized for sync.
- Feature-branch pushes to non-stable refs (`git push <remote> fix/issue-N-...`).
- Sanctioned merges via `gitea_merge_pr` / the Gitea API merge endpoint — the
**only** way stable branches advance.
**What happens on a detected attempt:** the session is marked
workflow-contaminated (durable `stable_branch_contamination` marker, redacted
command summary + session id + remote + ref). While contaminated, all
review / merge / close / issue-completion mutations fail closed. `comment_issue`
and `lock_issue` remain allowed so the contaminated worker can post the durable
audit comment and hand off. Contamination **cannot be self-cleared** — only a
reconciler audit (`gitea_audit_stable_branch_contamination action=clear`) may
clear it.
Tooling: call `gitea_record_stable_branch_push_attempt` to classify/record a
proposed push before running it; `gitea_audit_stable_branch_contamination` to
inspect or (reconciler-only) clear the marker.
## Shell Spawn Hard-Stop Rule
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
+478
View File
@@ -0,0 +1,478 @@
"""Fail-closed guard against direct pushes to stable branches (#671).
MCP workflow sessions must never publish to a stable branch (``master``,
``main``, ``dev``, ...) directly. Stable-branch updates land only through
sanctioned Gitea merge tooling or an explicitly authorized reconciler path.
Incident origin (#670): a bare direct-to-master commit ``2fa97c26`` landed on
``prgs/master`` without PR/review provenance, and a PR #654 merger run
attempted ``git push prgs master`` (audited as a no-op, but the *intent* is the
hazard). Partial protections already existed (``author_proofs.PROTECTED_BRANCHES``,
``root_checkout_guard``, branch-push proofs) but did not detect shell push
equivalents, mark the session contaminated after such an attempt, or fail
closed on subsequent review/merge/close/completion mutations.
This module is the detection + contamination-policy core. Like
``author_proofs`` and ``root_checkout_guard`` it is **pure**: callers gather
the raw facts (the proposed command line, git state, the durable contamination
marker) and pass them in, so the same logic serves prompts, MCP gates, and
tests. Nothing here performs git or network calls or reads durable state; the
server wires these helpers to ``mcp_session_state`` and ``verify_preflight_purity``.
Design rules honoured (from the #671 acceptance criteria):
* Detect ``git push <remote> master`` shell equivalents, including refspecs
(``HEAD:master``, ``+refs/heads/x:refs/heads/master``), ``--force``,
``--dry-run``/no-op intent, and delete refspecs (``:master``).
* Never false-block a feature-branch push (``git push prgs fix/issue-671-...``).
* Never treat ``git fetch`` / ``git pull --ff-only`` as a push.
* Never treat sanctioned ``gitea_merge_pr`` / Gitea API merge as a push.
* Fail closed on ambiguous targets that *may* resolve to a stable branch, but
surface ambiguity as its own signal rather than silently blocking feature work.
* Contamination must not be clearable by the same worker session — only a
reconciler (audit) role may clear or bypass the gate.
"""
from __future__ import annotations
import re
from typing import Any, Iterable
from author_proofs import PROTECTED_BRANCHES
# Single source of truth for the stable branch set: the same protected set the
# author branch-identity proofs already enforce (#177), so the two guards can
# never drift apart.
STABLE_BRANCHES = PROTECTED_BRANCHES
# Mutations that must fail closed once the session is contaminated by a direct
# stable-branch push attempt (#671 AC4: review, merge, close, completion).
# ``comment_issue`` / ``lock_issue`` / ``create_issue`` are deliberately NOT
# gated so a contaminated worker can still post the durable audit comment and
# hand off. Only a reconciler (audit) role bypasses this set.
CONTAMINATION_GATED_TASKS = frozenset({
"create_pr",
"commit_files",
"gitea_commit_files",
"close_pr",
"close_issue",
"review_pr",
"approve_pr",
"request_changes_pr",
"submit_pr_review",
"merge_pr",
"delete_branch",
"complete_issue",
})
CONTAMINATION_KIND = "stable_branch_push"
REMEDIATION = (
"Direct stable-branch publication is forbidden for worker sessions. Stop, "
"leave the stable branch untouched, and route the change through sanctioned "
"Gitea merge tooling (gitea_merge_pr) or an explicitly authorized reconciler "
"audit. This session is workflow-contaminated until a reconciler audits it."
)
# ── command tokenising ────────────────────────────────────────────────────────
# Split a compound command line into individual simple commands on shell
# separators so ``a && git push prgs master`` is analysed segment by segment.
_SEGMENT_SPLIT_RE = re.compile(r"(?:\|\||&&|\||;|\n)")
_GIT_PUSH_RE = re.compile(r"\bgit\b[\w\s\-]*?\bpush\b", re.IGNORECASE)
_GIT_FETCH_RE = re.compile(r"\bgit\b[\w\s\-]*?\b(?:fetch|pull)\b", re.IGNORECASE)
# Flags that take a value argument in ``git push`` (so the following token is
# consumed as the flag's value, not a refspec).
_VALUE_FLAGS = frozenset({
"--repo",
"-o",
"--push-option",
"--receive-pack",
"--exec",
})
_FORCE_FLAGS = frozenset({"-f", "--force"})
_DRY_RUN_FLAGS = frozenset({"-n", "--dry-run"})
_DELETE_FLAGS = frozenset({"-d", "--delete"})
def _clean(value: str | None) -> str:
return (value or "").strip()
def _strip_ref_prefix(ref: str) -> str:
ref = ref.strip()
for prefix in ("refs/heads/", "heads/"):
if ref.startswith(prefix):
return ref[len(prefix):]
return ref
def is_stable_ref(ref: str | None) -> bool:
"""True when ``ref`` names a configured stable branch."""
name = _strip_ref_prefix(_clean(ref))
return bool(name) and name in STABLE_BRANCHES
# ── redaction ─────────────────────────────────────────────────────────────────
_URL_USERINFO_RE = re.compile(r"(https?://)[^/\s:@]+(?::[^/\s@]+)?@", re.IGNORECASE)
_SECRET_ASSIGN_RE = re.compile(
r"\b((?:GITEA_)?(?:TOKEN|PASSWORD|PASS|PAT|SECRET|API_KEY|AUTH))\s*=\s*\S+",
re.IGNORECASE,
)
_BEARER_RE = re.compile(r"\b(Bearer|token)\s+[A-Za-z0-9._\-]{8,}", re.IGNORECASE)
def redact_command(command: str | None) -> str:
"""Strip credentials/URLs from a command line before logging it (#671).
Redacts URL userinfo (``https://user:tok@host`` → ``https://***@host``),
``TOKEN=...`` style assignments, and bearer/token headers. Leaves the
structural parts (``git push <remote> master``) intact for audit value.
"""
text = _clean(command)
if not text:
return ""
text = _URL_USERINFO_RE.sub(r"\1***@", text)
text = _SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", text)
text = _BEARER_RE.sub(lambda m: f"{m.group(1)} ***", text)
return text
# ── push classification ───────────────────────────────────────────────────────
def _analyse_push_segment(segment: str) -> dict[str, Any]:
"""Classify one ``git push ...`` command segment."""
tokens = segment.split()
# Drop everything up to and including the ``push`` verb.
try:
push_idx = next(
i for i, tok in enumerate(tokens)
if tok.lower() == "push"
)
except StopIteration:
push_idx = -1
args = tokens[push_idx + 1:] if push_idx >= 0 else []
is_force = False
is_dry_run = False
is_delete = False
positionals: list[str] = []
skip_next = False
for tok in args:
if skip_next:
skip_next = False
continue
if tok in _FORCE_FLAGS or tok.startswith("--force-with-lease") or tok.startswith("--force-if-includes"):
is_force = True
continue
if tok in _DRY_RUN_FLAGS:
is_dry_run = True
continue
if tok in _DELETE_FLAGS:
is_delete = True
continue
if tok in _VALUE_FLAGS:
skip_next = True
continue
if tok.startswith("--") and "=" in tok:
# e.g. --repo=... : self-contained, not a refspec.
continue
if tok.startswith("-"):
# Unknown/combined short flag; ignore for ref detection.
continue
positionals.append(tok)
# First positional after push is the remote (when any positional exists);
# the rest are refspecs / branch names.
remote = positionals[0] if positionals else None
refspecs = positionals[1:]
stable_refs: list[str] = []
force_to_stable = False
delete_stable = False
for spec in refspecs:
force_spec = spec.startswith("+")
body = spec[1:] if force_spec else spec
if ":" in body:
src, _, dst = body.partition(":")
dest = dst
if src == "" and dst:
# ``:master`` — delete refspec.
is_delete = True
else:
dest = body
if is_stable_ref(dest):
stable_refs.append(_strip_ref_prefix(dest))
if force_spec or is_force:
force_to_stable = True
if is_delete:
delete_stable = True
targets_stable = bool(stable_refs)
# Ambiguous: ``git push <remote>`` (or bare ``git push``) with no refspec —
# resolves to the current branch's upstream, which we cannot see from the
# command alone. Flag it, but do not assert stable (would false-block
# feature-branch pushes).
ambiguous = not refspecs
return {
"is_git_push": True,
"remote": remote,
"refspecs": refspecs,
"targets_stable": targets_stable,
"stable_refs": stable_refs,
"is_force": is_force or force_to_stable,
"is_dry_run": is_dry_run,
"is_delete": is_delete or delete_stable,
"ambiguous_target": ambiguous,
}
def classify_push_command(command: str | None) -> dict[str, Any]:
"""Classify a shell command line for direct stable-branch push intent (#671).
Returns a dict describing whether the command is a ``git push`` targeting a
stable branch. Fetch/pull are never pushes. A sanctioned Gitea merge (which
is not a ``git push`` at all) classifies as non-push. Dry-run and no-op
pushes still count as *intent* and are reported as contamination
(``proves_intent``) so a ``--dry-run`` cannot be used to probe the gate.
"""
text = _clean(command)
result: dict[str, Any] = {
"command": text,
"redacted_command": redact_command(text),
"is_git_push": False,
"is_fetch_or_pull": False,
"targets_stable": False,
"stable_refs": [],
"is_force": False,
"is_dry_run": False,
"is_delete": False,
"ambiguous_target": False,
"contamination": False,
"proves_intent": False,
"reasons": [],
}
if not text:
return result
stable_refs: list[str] = []
saw_push = False
for segment in _SEGMENT_SPLIT_RE.split(text):
seg = segment.strip()
if not seg:
continue
if _GIT_FETCH_RE.search(seg) and not _GIT_PUSH_RE.search(seg):
result["is_fetch_or_pull"] = True
continue
if not _GIT_PUSH_RE.search(seg):
continue
saw_push = True
analysis = _analyse_push_segment(seg)
result["is_git_push"] = True
result["is_force"] = result["is_force"] or analysis["is_force"]
result["is_dry_run"] = result["is_dry_run"] or analysis["is_dry_run"]
result["is_delete"] = result["is_delete"] or analysis["is_delete"]
result["ambiguous_target"] = result["ambiguous_target"] or analysis["ambiguous_target"]
stable_refs.extend(analysis["stable_refs"])
result["stable_refs"] = sorted(set(stable_refs))
result["targets_stable"] = bool(stable_refs)
reasons: list[str] = []
if result["targets_stable"]:
refs = ", ".join(result["stable_refs"])
verb = "delete" if result["is_delete"] else ("force-push" if result["is_force"] else "push")
qualifier = " (dry-run/no-op still proves intent)" if result["is_dry_run"] else ""
reasons.append(
f"direct stable-branch {verb} detected targeting: {refs}{qualifier}; "
"worker sessions must never publish stable branches directly"
)
result["contamination"] = True
result["proves_intent"] = True
elif saw_push and result["ambiguous_target"]:
# Bare ``git push`` with no refspec: ambiguous. Report, but do not
# contaminate on the command alone — the root-checkout / branch-state
# detector resolves whether the current branch is stable.
reasons.append(
"ambiguous 'git push' with no refspec: resolve the current branch "
"before pushing; if it is a stable branch this is forbidden"
)
result["reasons"] = reasons
return result
def detect_stable_push(commands: str | Iterable[str] | None) -> dict[str, Any]:
"""Classify one command or an iterable of commands; contamination if any hit."""
if commands is None:
return classify_push_command(None)
if isinstance(commands, str):
return classify_push_command(commands)
worst: dict[str, Any] | None = None
for cmd in commands:
res = classify_push_command(cmd)
if res["contamination"]:
return res
if worst is None or (res["is_git_push"] and not worst["is_git_push"]):
worst = res
return worst or classify_push_command(None)
# ── root/control checkout local-commit detection ──────────────────────────────
def assess_root_checkout_local_commit(
*,
current_branch: str | None,
head_sha: str | None,
remote_master_sha: str | None,
is_under_branches: bool,
ahead_count: int | None = None,
) -> dict[str, Any]:
"""Detect a local commit on the root/control checkout not on a feature branch.
#671 AC2. An isolated ``branches/...`` worktree is exempt (that is where
feature work belongs). On the control checkout, a commit is illegitimate
when the checkout sits on a stable branch (or detached) and its HEAD has
advanced past the tracking ``prgs/master`` — i.e. a commit was made that is
not carried by an issue feature branch/PR.
Positive evidence only: missing state reports ``unknown`` rather than
asserting contamination, but an unreadable *branch* on the control checkout
(detached HEAD) with an advanced HEAD is still flagged.
"""
branch = _clean(current_branch)
head = _clean(head_sha).lower()
master = _clean(remote_master_sha).lower()
if is_under_branches:
return {
"contamination": False,
"unknown": False,
"reasons": [],
"detail": "isolated branches/ worktree — feature commits are expected here",
}
reasons: list[str] = []
unknown = False
on_stable = (not branch) or (branch in STABLE_BRANCHES)
if not on_stable:
# Control checkout is on some non-stable branch: out of scope for this
# detector (root_checkout_guard #475 handles that contamination class).
return {
"contamination": False,
"unknown": False,
"reasons": [],
"detail": f"control checkout on non-stable branch '{branch}' — not this detector's class",
}
advanced = False
if ahead_count is not None and ahead_count > 0:
advanced = True
if head and master and head != master:
advanced = True
if (not head or not master) and ahead_count is None:
unknown = True
if advanced:
where = f"branch '{branch}'" if branch else "detached HEAD"
reasons.append(
f"control checkout ({where}) has local commits not on an issue "
"feature branch (HEAD advanced past prgs/master); worker sessions "
"must commit only on issue branches under branches/"
)
return {
"contamination": bool(reasons),
"unknown": unknown and not reasons,
"reasons": reasons,
"current_branch": branch or None,
"head_sha": head or None,
"remote_master_sha": master or None,
}
# ── contamination record + gate ───────────────────────────────────────────────
def build_contamination_record(
*,
reason_class: str,
command_redacted: str | None = None,
session_id: str | None = None,
remote: str | None = None,
ref: str | None = None,
role: str | None = None,
detail: str | None = None,
) -> dict[str, Any]:
"""Build the durable contamination marker payload (redacted, audit-safe).
``reason_class`` is one of ``stable_branch_push`` / ``root_checkout_commit``.
The command is stored already-redacted; callers pass the raw line through
:func:`redact_command` (or this builder redacts a raw ``command`` for them).
"""
return {
"kind": CONTAMINATION_KIND,
"reason_class": _clean(reason_class) or "stable_branch_push",
"command_summary": redact_command(command_redacted),
"session_id": _clean(session_id) or None,
"remote": _clean(remote) or None,
"ref": _clean(ref) or None,
"role": _clean(role) or None,
"detail": _clean(detail) or None,
"cleared_by_reconciler": False,
}
def assess_contamination_gate(
marker: dict[str, Any] | None,
*,
task: str | None,
actual_role: str | None,
) -> dict[str, Any]:
"""Fail closed on gated mutations while a contamination marker is live (#671 AC4).
* No marker → allowed.
* Reconciler (audit) role → allowed (the sanctioned path to inspect/clear).
* Marker present + ``task`` in :data:`CONTAMINATION_GATED_TASKS` → blocked.
* Marker present + non-gated task (e.g. ``comment_issue``) → allowed, so the
worker can still post the durable audit/handoff comment.
"""
if not marker or marker.get("cleared_by_reconciler"):
return {"block": False, "reasons": [], "task": task}
role = _clean(actual_role).lower()
if role == "reconciler":
return {
"block": False,
"reasons": [],
"task": task,
"detail": "reconciler audit path is exempt from the contamination gate",
}
task_name = _clean(task)
if task_name and task_name in CONTAMINATION_GATED_TASKS:
summary = marker.get("command_summary") or marker.get("detail") or "(no summary)"
reason_class = marker.get("reason_class") or "stable_branch_push"
return {
"block": True,
"reasons": [
f"session is workflow-contaminated ({reason_class}): {summary}. "
f"'{task_name}' is blocked until a reconciler audits and clears "
"the contamination. " + REMEDIATION
],
"task": task_name,
}
return {"block": False, "reasons": [], "task": task_name or None}
def format_contamination_gate_error(gate: dict[str, Any]) -> str:
"""Single RuntimeError message for MCP mutation gates."""
reasons = "; ".join(gate.get("reasons") or ["session workflow-contaminated"])
return f"Stable-branch contamination gate (#671): {reasons}"
+10 -1
View File
@@ -26,7 +26,16 @@ def _reset_mutation_authority(monkeypatch):
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
so durable load/save never touches host state even after env clears.
"""
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
for env_key in [
"GITEA_SESSION_PROFILE_LOCK",
"GITEA_ACTIVE_WORKTREE",
"GITEA_AUTHOR_WORKTREE",
"GITEA_REVIEWER_WORKTREE",
"GITEA_MERGER_WORKTREE",
"GITEA_RECONCILER_WORKTREE",
]:
monkeypatch.delenv(env_key, raising=False)
# Isolate durable session-state files so tests never share host cache (#559).
import tempfile
+9 -2
View File
@@ -141,13 +141,21 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
@patch("os.path.isdir", return_value=True)
@patch("os.path.exists", return_value=True)
@patch("subprocess.run")
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
@patch("gitea_mcp_server._resolve_author_mutation_context")
def test_reviewer_from_branches_worktree_allowed(
self, mock_ctx, mock_git, _remote_sha, _porcelain,
self, mock_ctx, mock_git, _remote_sha, _porcelain, mock_run, _exists, _isdir,
):
import unittest.mock
mock_run.return_value = unittest.mock.MagicMock(
returncode=0,
stdout=f"{CONTROL_ROOT}/.git\n",
)
srv._preflight_capability_baseline_porcelain = ""
mock_ctx.return_value = {
"workspace_path": BRANCHES_WORKTREE,
@@ -161,6 +169,5 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
}
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
if __name__ == "__main__":
unittest.main()
+412
View File
@@ -0,0 +1,412 @@
"""Tests for optional self-hosted Sentry observability (#606).
Covers the pure module (config, redaction, event/check-in builders) and the
runtime capture paths using a fake ``sentry_sdk``, so nothing ever touches the
network. Critically proves the feature is a no-op when disabled or DSN-less
(acceptance criterion 1) and that secrets/paths/session-state are never sent
(criterion 5).
"""
import sys
import contextlib
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import sentry_observability as so # noqa: E402
# ── Fake SDK ────────────────────────────────────────────────────────────────
class _FakeScope:
def __init__(self):
self.tags = {}
self.extras = {}
def set_tag(self, k, v):
self.tags[k] = v
def set_extra(self, k, v):
self.extras[k] = v
class FakeSentrySDK:
"""Minimal stand-in exposing the SDK surface sentry_observability uses."""
def __init__(self):
self.init_kwargs = None
self.events = []
self.exceptions = []
self.checkins = []
self.last_scope = None
def init(self, **kwargs):
self.init_kwargs = kwargs
def capture_event(self, event):
self.events.append(event)
def capture_exception(self, exc):
self.exceptions.append(exc)
def capture_checkin(self, **payload):
self.checkins.append(payload)
@contextlib.contextmanager
def push_scope(self):
self.last_scope = _FakeScope()
yield self.last_scope
@pytest.fixture(autouse=True)
def _reset_state():
"""Isolate module init state between tests."""
so.reset_for_tests()
yield
so.reset_for_tests()
@pytest.fixture
def fake_sdk(monkeypatch):
sdk = FakeSentrySDK()
monkeypatch.setattr(so, "sentry_sdk", sdk)
return sdk
# ── Config / gating ─────────────────────────────────────────────────────────
def test_disabled_by_default_empty_env():
cfg = so.load_config(env={})
assert cfg.enabled is False
assert cfg.active is False
def test_enabled_flag_without_dsn_is_not_active():
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": "1"})
assert cfg.enabled is True
assert cfg.dsn is None
assert cfg.active is False # DSN required
def test_dsn_without_enabled_flag_is_not_active():
cfg = so.load_config(env={"SENTRY_DSN": "https://[email protected]/1"})
assert cfg.active is False
def test_active_requires_enabled_and_dsn():
cfg = so.load_config(
env={"MCP_SENTRY_ENABLED": "true", "SENTRY_DSN": "https://[email protected]/1"}
)
assert cfg.active is True
def test_truthy_variants():
for val in ("1", "true", "YES", "On"):
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
assert cfg.enabled is True
for val in ("0", "false", "no", "", "off"):
cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val})
assert cfg.enabled is False
def test_traces_sample_rate_parsed_and_clamped():
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.25"}).traces_sample_rate == 0.25
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "5"}).traces_sample_rate == 1.0
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "-1"}).traces_sample_rate == 0.0
assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "junk"}).traces_sample_rate == 0.0
def test_safe_summary_has_no_dsn_value():
cfg = so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
summary = cfg.safe_summary()
assert summary["dsn_present"] is True
assert "secret" not in repr(summary)
assert "dsn" not in summary # only presence, never the value
# ── init_sentry ─────────────────────────────────────────────────────────────
def test_init_noop_when_disabled(fake_sdk):
status = so.init_sentry(so.load_config(env={}))
assert status["initialized"] is False
assert fake_sdk.init_kwargs is None # no SDK init
assert so.is_initialized() is False
def test_init_noop_when_enabled_but_missing_dsn(fake_sdk):
status = so.init_sentry(so.load_config(env={"MCP_SENTRY_ENABLED": "1"}))
assert status["initialized"] is False
assert fake_sdk.init_kwargs is None
def test_init_reports_missing_sdk(monkeypatch):
monkeypatch.setattr(so, "sentry_sdk", None)
status = so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
assert status["initialized"] is False
assert "not installed" in status["reason"]
def test_init_configures_sdk_with_scrubber(fake_sdk):
status = so.init_sentry(
so.load_config(
env={
"MCP_SENTRY_ENABLED": "1",
"SENTRY_DSN": "https://[email protected]/1",
"SENTRY_ENVIRONMENT": "prod",
"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.1",
}
)
)
assert status["initialized"] is True
assert so.is_initialized() is True
kw = fake_sdk.init_kwargs
assert kw["dsn"] == "https://[email protected]/1"
assert kw["environment"] == "prod"
assert kw["traces_sample_rate"] == 0.1
assert kw["before_send"] is so.scrub_event
assert kw["send_default_pii"] is False
def test_init_enable_logs_wires_log_scrubber(fake_sdk):
so.init_sentry(
so.load_config(
env={
"MCP_SENTRY_ENABLED": "1",
"SENTRY_DSN": "https://[email protected]/1",
"MCP_SENTRY_ENABLE_LOGS": "1",
}
)
)
exp = fake_sdk.init_kwargs["_experiments"]
assert exp["enable_logs"] is True
assert exp["before_send_log"] is so.scrub_event
def test_init_never_raises_on_sdk_failure(monkeypatch):
class Boom:
def init(self, **kwargs):
raise RuntimeError("sentry down")
monkeypatch.setattr(so, "sentry_sdk", Boom())
status = so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
assert status["initialized"] is False
assert "init failed" in status["reason"]
# ── Redaction (fail closed) ─────────────────────────────────────────────────
def test_build_tags_allowlist_only():
tags = so.build_tags(role="author", secret_thing="leak", pid=123)
assert tags["role"] == "author"
assert tags["pid"] == "123"
assert "secret_thing" not in tags
def test_build_tags_hashes_session_id():
tags = so.build_tags(session_id="prgs-author-20479-cf9ac178")
assert "session_id" not in tags
assert "session_id_hash" in tags
assert tags["session_id_hash"] != "prgs-author-20479-cf9ac178"
assert len(tags["session_id_hash"]) == 12
def test_build_tags_collapses_worktree_path():
tags = so.build_tags(
worktree_path="/Users/x/Development/Gitea-Tools/branches/issue-606-sentry-observability"
)
assert "worktree_path" not in tags
assert tags["worktree_category"] == "author"
def test_build_tags_drops_value_that_scrubs_to_redacted():
# A tag value that is itself a token gets scrubbed then dropped.
tags = so.build_tags(capability="token abcdef1234567890")
assert "capability" not in tags
def test_sanitize_path_categories():
assert so.sanitize_path("/repo/branches/review-pr-654") == "reviewer"
assert so.sanitize_path("/repo/branches/merge-pr-1") == "merger"
assert so.sanitize_path("/repo/branches/reconcile-pr-1") == "reconciler"
assert so.sanitize_path("/repo/branches/feat-issue-606") == "author"
assert so.sanitize_path("/x/y/Gitea-Tools") == "root"
def test_redact_value_scrubs_secrets_and_paths():
out = so.redact_value(
{
"token": "abc123",
"note": "Authorization: Bearer sk_live_abcdefgh12345",
"path": "/Users/jasonwalker/Development/Gitea-Tools/secret",
"dsn": "https://[email protected]/1",
"safe": "hello",
}
)
assert out["token"] == so.REDACTED
assert out["dsn"] == so.REDACTED
assert "sk_live" not in out["note"]
assert so.REDACTED_PATH in out["path"]
assert "/Users/" not in out["path"]
assert out["safe"] == "hello"
def test_redact_value_forbidden_prompt_and_session_state():
out = so.redact_value(
{"prompt": "full body", "session_state": "{...}", "keep": "ok"}
)
assert out["prompt"] == so.REDACTED
assert out["session_state"] == so.REDACTED
assert out["keep"] == "ok"
def test_scrub_event_redacts_nested_and_drops_server_name():
event = {
"server_name": "some-host",
"message": "boom",
"extra": {"token": "leak", "ok": "1"},
}
scrubbed = so.scrub_event(event)
assert "server_name" not in scrubbed
assert scrubbed["extra"]["token"] == so.REDACTED
assert scrubbed["extra"]["ok"] == "1"
def test_scrub_event_drops_non_dict():
assert so.scrub_event("not a dict") is None
assert so.scrub_event(None) is None
# ── Event builders ──────────────────────────────────────────────────────────
def test_build_blocker_event_structure_and_next_action():
event = so.build_blocker_event(
"active_foreign_lease",
message="blocked by foreign lease",
next_action="wait or adopt via allocator",
level="warning",
tags={"pr_number": 606, "session_id": "s-123"},
)
assert event["tags"]["blocker_type"] == "active_foreign_lease"
assert event["tags"]["pr_number"] == "606"
assert "session_id" not in event["tags"]
assert event["tags"]["session_id_hash"]
assert event["extra"]["canonical_next_action"] == "wait or adopt via allocator"
assert event["fingerprint"] == ["workflow-blocker", "active_foreign_lease"]
def test_build_blocker_event_invalid_level_defaults_warning():
event = so.build_blocker_event("x", level="nonsense")
assert event["level"] == "warning"
def test_build_checkin_payload_maps_slugs():
for key, slug in so.MONITOR_SLUGS.items():
payload = so.build_checkin_payload(key, "ok")
assert payload["monitor_slug"] == slug
assert payload["status"] == "ok"
def test_build_checkin_payload_explicit_slug_passthrough():
payload = so.build_checkin_payload("custom-slug", "in_progress", duration=1.5)
assert payload["monitor_slug"] == "custom-slug"
assert payload["duration"] == 1.5
def test_build_checkin_payload_rejects_bad_status():
with pytest.raises(ValueError):
so.build_checkin_payload("allocator_health", "bogus")
def test_all_six_monitors_registered():
assert set(so.MONITOR_SLUGS) == {
"stale_lease_scan",
"terminal_lock_scan",
"allocator_health",
"namespace_health",
"dashboard_freshness",
"reconciler_cleanup",
}
# ── Capture paths (fail open) ───────────────────────────────────────────────
def test_capture_workflow_blocker_noop_when_disabled(fake_sdk):
# not initialised
event = so.capture_workflow_blocker("some_blocker", message="x")
assert isinstance(event, dict) # still returns redacted event
assert fake_sdk.events == [] # but nothing sent
def test_capture_workflow_blocker_sends_when_initialised(fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
so.capture_workflow_blocker("terminal_lock_occupied", message="held")
assert len(fake_sdk.events) == 1
assert fake_sdk.events[0]["tags"]["blocker_type"] == "terminal_lock_occupied"
def test_capture_exception_noop_when_disabled(fake_sdk):
assert so.capture_exception(ValueError("x")) is False
assert fake_sdk.exceptions == []
def test_capture_exception_sends_scrubbed_tags(fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
ok = so.capture_exception(
RuntimeError("bad"), tags={"mutation_tool": "gitea_merge_pr", "leaky": "x"}
)
assert ok is True
assert len(fake_sdk.exceptions) == 1
assert fake_sdk.last_scope.tags["mutation_tool"] == "gitea_merge_pr"
assert "leaky" not in fake_sdk.last_scope.tags
def test_capture_exception_never_raises(monkeypatch, fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
def boom(exc):
raise RuntimeError("sdk exploded")
monkeypatch.setattr(fake_sdk, "capture_exception", boom)
# Must swallow the SDK failure (fail open).
assert so.capture_exception(ValueError("y")) is False
def test_monitor_checkin_noop_when_disabled(fake_sdk):
payload = so.monitor_checkin("allocator_health", "ok")
assert payload["monitor_slug"] == "gitea-mcp-allocator-health"
assert fake_sdk.checkins == [] # not sent while disabled
def test_monitor_checkin_sends_when_initialised(fake_sdk):
so.init_sentry(
so.load_config(
env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://[email protected]/1"}
)
)
so.monitor_checkin("stale_lease_scan", "ok")
assert fake_sdk.checkins == [
{"monitor_slug": "gitea-mcp-stale-lease-scan", "status": "ok"}
]
def test_monitor_checkin_invalid_status_returns_none(fake_sdk):
assert so.monitor_checkin("allocator_health", "bogus") is None
assert fake_sdk.checkins == []
@@ -0,0 +1,188 @@
"""Server wiring for stable-branch push contamination (#671).
Exercises the MCP tools and the pre-flight enforcement gate against the durable
contamination marker (isolated per-test state dir from conftest).
"""
from __future__ import annotations
import os
from unittest.mock import patch
import gitea_mcp_server as srv
def _clear_marker(remote="prgs"):
srv._clear_stable_contamination_marker(remote=remote)
def teardown_function():
_clear_marker()
# ── record tool marks a real direct push ─────────────────────────────────────
def test_record_tool_marks_direct_master_push():
_clear_marker()
res = srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs"
)
assert res["contaminated"] is True
assert res["marked"] is True
assert res["marker"] is not None
assert res["marker"]["reason_class"] == "stable_branch_push"
# loaded back through the durable store
loaded = srv._load_stable_contamination_marker("prgs")
assert loaded is not None
assert loaded["ref"] == "master"
def test_record_tool_dry_run_still_marks():
_clear_marker()
res = srv.gitea_record_stable_branch_push_attempt(
command="git push --dry-run prgs master", remote="prgs"
)
assert res["contaminated"] is True
assert res["marked"] is True
def test_record_tool_feature_branch_does_not_mark():
_clear_marker()
res = srv.gitea_record_stable_branch_push_attempt(
command="git push prgs fix/issue-671-block-stable-branch-push",
remote="prgs",
)
assert res["contaminated"] is False
assert res["marked"] is False
assert srv._load_stable_contamination_marker("prgs") is None
def test_record_tool_fetch_only_does_not_mark():
_clear_marker()
res = srv.gitea_record_stable_branch_push_attempt(
command="git fetch prgs", remote="prgs"
)
assert res["contaminated"] is False
assert res["marked"] is False
def test_record_tool_mark_false_is_readonly():
_clear_marker()
res = srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs", mark=False
)
assert res["contaminated"] is True
assert res["marked"] is False
assert srv._load_stable_contamination_marker("prgs") is None
def test_record_tool_redacts_secret_in_marker():
_clear_marker()
res = srv.gitea_record_stable_branch_push_attempt(
command="git push https://u:supersecret@host/o/r.git master",
remote="prgs",
)
assert res["marked"] is True
assert "supersecret" not in res["marker"]["command_summary"]
def test_record_tool_root_checkout_local_commit_marks():
_clear_marker()
res = srv.gitea_record_stable_branch_push_attempt(
command=None,
remote="prgs",
current_branch="master",
head_sha="a" * 40,
remote_master_sha="b" * 40,
is_under_branches=False,
)
assert res["contaminated"] is True
assert res["marked"] is True
assert res["marker"]["reason_class"] == "root_checkout_commit"
# ── audit tool: inspect + reconciler-only clear ──────────────────────────────
def test_audit_inspect_reports_marker():
_clear_marker()
srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs"
)
out = srv.gitea_audit_stable_branch_contamination(action="inspect", remote="prgs")
assert out["contaminated"] is True
assert out["read_only"] is True
def test_audit_clear_refused_for_non_reconciler():
_clear_marker()
srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs"
)
with patch.object(srv, "_actual_profile_role", return_value="author"):
out = srv.gitea_audit_stable_branch_contamination(action="clear", remote="prgs")
assert out["success"] is False
assert out["reasons"]
# marker survives a refused clear
assert srv._load_stable_contamination_marker("prgs") is not None
def test_audit_clear_allowed_for_reconciler():
_clear_marker()
srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs"
)
identity = srv._stable_contamination_profile_identity()
with patch.object(srv, "_actual_profile_role", return_value="reconciler"):
out = srv.gitea_audit_stable_branch_contamination(
action="clear", remote="prgs", profile_identity=identity
)
assert out["success"] is True
assert srv._load_stable_contamination_marker("prgs") is None
# ── pre-flight enforcement gate ──────────────────────────────────────────────
def _force_gate_env():
return patch.dict(os.environ, {"GITEA_TEST_FORCE_STABLE_CONTAMINATION": "1"})
def test_gate_blocks_gated_mutation_when_contaminated():
_clear_marker()
srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs"
)
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
for task in ("merge_pr", "review_pr", "close_issue", "create_pr"):
try:
srv._enforce_stable_branch_contamination_gate(task, "prgs")
raised = False
except RuntimeError as exc:
raised = True
assert "#671" in str(exc)
assert raised, task
def test_gate_allows_comment_for_handoff_when_contaminated():
_clear_marker()
srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs"
)
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
# must not raise — worker can still post the durable audit comment
srv._enforce_stable_branch_contamination_gate("comment_issue", "prgs")
srv._enforce_stable_branch_contamination_gate("lock_issue", "prgs")
def test_gate_exempts_reconciler_when_contaminated():
_clear_marker()
srv.gitea_record_stable_branch_push_attempt(
command="git push prgs master", remote="prgs"
)
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="reconciler"):
srv._enforce_stable_branch_contamination_gate("merge_pr", "prgs")
def test_gate_noop_when_not_contaminated():
_clear_marker()
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
srv._enforce_stable_branch_contamination_gate("merge_pr", "prgs")
+341
View File
@@ -0,0 +1,341 @@
"""Tests for the direct stable-branch push guard (#671).
Covers the acceptance criteria:
1. detect shell ``git push <remote> master`` equivalents
2. detect root/control-checkout local commits not on an issue branch
3. contamination record shape
4. fail-closed gate on review/merge/close/completion (reconciler-exempt)
5. AC5 case matrix: no-op dry-run push, real direct push, sanctioned Gitea
merge, fetch-only, root-checkout local commit; plus feature-branch push
still allowed and sanctioned merge still allowed
6. redaction of credentials in logged command summaries
"""
import stable_branch_push_guard as guard
# ── AC1: detect direct stable-branch push equivalents ────────────────────────
def test_plain_git_push_remote_master_is_contamination():
res = guard.classify_push_command("git push prgs master")
assert res["is_git_push"] is True
assert res["targets_stable"] is True
assert res["stable_refs"] == ["master"]
assert res["contamination"] is True
assert res["reasons"]
def test_push_main_and_dev_detected():
for ref in ("main", "dev", "develop", "development"):
res = guard.classify_push_command(f"git push origin {ref}")
assert res["contamination"] is True, ref
assert res["stable_refs"] == [ref]
def test_head_colon_master_refspec_detected():
res = guard.classify_push_command("git push prgs HEAD:master")
assert res["targets_stable"] is True
assert res["stable_refs"] == ["master"]
assert res["contamination"] is True
def test_full_refspec_force_plus_detected():
res = guard.classify_push_command(
"git push prgs +refs/heads/tmp:refs/heads/master"
)
assert res["contamination"] is True
assert res["is_force"] is True
assert res["stable_refs"] == ["master"]
def test_force_flag_to_master_detected():
res = guard.classify_push_command("git push --force prgs master")
assert res["contamination"] is True
assert res["is_force"] is True
def test_delete_refspec_master_detected():
res = guard.classify_push_command("git push prgs :master")
assert res["contamination"] is True
assert res["is_delete"] is True
def test_delete_flag_master_detected():
res = guard.classify_push_command("git push --delete prgs master")
assert res["contamination"] is True
assert res["is_delete"] is True
def test_push_detected_inside_compound_command():
res = guard.classify_push_command("cd repo && git push prgs master && echo ok")
assert res["contamination"] is True
assert res["stable_refs"] == ["master"]
# ── AC5: no-op / dry-run push still proves intent ────────────────────────────
def test_dry_run_push_to_master_proves_intent():
res = guard.classify_push_command("git push --dry-run prgs master")
assert res["is_dry_run"] is True
assert res["contamination"] is True
assert res["proves_intent"] is True
assert "intent" in " ".join(res["reasons"]).lower()
def test_short_dry_run_flag_n_detected():
res = guard.classify_push_command("git push -n prgs master")
assert res["is_dry_run"] is True
assert res["contamination"] is True
# ── AC5 negative: feature-branch push still allowed ──────────────────────────
def test_feature_branch_push_not_flagged():
res = guard.classify_push_command(
"git push prgs fix/issue-671-block-stable-branch-push"
)
assert res["is_git_push"] is True
assert res["targets_stable"] is False
assert res["contamination"] is False
assert res["reasons"] == []
def test_feature_branch_head_refspec_not_flagged():
res = guard.classify_push_command(
"git push prgs HEAD:fix/issue-671-block-stable-branch-push"
)
assert res["contamination"] is False
assert res["targets_stable"] is False
def test_branch_named_like_master_substring_not_flagged():
# 'master-notes' is not the stable 'master'.
res = guard.classify_push_command("git push prgs master-notes")
assert res["contamination"] is False
assert res["targets_stable"] is False
# ── AC5 negative: fetch-only operations ──────────────────────────────────────
def test_git_fetch_not_a_push():
res = guard.classify_push_command("git fetch prgs")
assert res["is_git_push"] is False
assert res["is_fetch_or_pull"] is True
assert res["contamination"] is False
def test_git_pull_ff_only_master_not_a_push():
res = guard.classify_push_command("git pull --ff-only prgs master")
assert res["is_git_push"] is False
assert res["is_fetch_or_pull"] is True
assert res["contamination"] is False
# ── AC5 negative: sanctioned Gitea merge is not a push ───────────────────────
def test_gitea_merge_pr_tool_is_not_a_push():
res = guard.classify_push_command("gitea_merge_pr(pr_number=671, remote='prgs')")
assert res["is_git_push"] is False
assert res["contamination"] is False
def test_gitea_api_merge_is_not_a_push():
res = guard.classify_push_command(
"curl -X POST https://gitea.example/api/v1/repos/o/r/pulls/671/merge"
)
assert res["is_git_push"] is False
assert res["contamination"] is False
# ── ambiguous bare push: reported, not auto-contaminating ────────────────────
def test_bare_push_is_ambiguous_not_contaminating():
res = guard.classify_push_command("git push prgs")
assert res["is_git_push"] is True
assert res["ambiguous_target"] is True
assert res["contamination"] is False
assert res["reasons"] # surfaced as a warning
# ── AC2: root/control-checkout local commit detection ────────────────────────
def test_root_checkout_commit_ahead_of_master_flagged():
res = guard.assess_root_checkout_local_commit(
current_branch="master",
head_sha="a" * 40,
remote_master_sha="b" * 40,
is_under_branches=False,
)
assert res["contamination"] is True
assert res["reasons"]
def test_root_checkout_clean_at_master_not_flagged():
sha = "c" * 40
res = guard.assess_root_checkout_local_commit(
current_branch="master",
head_sha=sha,
remote_master_sha=sha,
is_under_branches=False,
)
assert res["contamination"] is False
assert res["unknown"] is False
def test_branches_worktree_commit_is_exempt():
res = guard.assess_root_checkout_local_commit(
current_branch="fix/issue-671-block-stable-branch-push",
head_sha="a" * 40,
remote_master_sha="b" * 40,
is_under_branches=True,
)
assert res["contamination"] is False
def test_root_checkout_ahead_count_flagged():
res = guard.assess_root_checkout_local_commit(
current_branch="master",
head_sha="",
remote_master_sha="",
is_under_branches=False,
ahead_count=2,
)
assert res["contamination"] is True
def test_root_checkout_unknown_when_state_missing():
res = guard.assess_root_checkout_local_commit(
current_branch="master",
head_sha="",
remote_master_sha="",
is_under_branches=False,
)
assert res["contamination"] is False
assert res["unknown"] is True
def test_root_checkout_non_stable_branch_out_of_scope():
res = guard.assess_root_checkout_local_commit(
current_branch="feature/x",
head_sha="a" * 40,
remote_master_sha="b" * 40,
is_under_branches=False,
)
assert res["contamination"] is False
# ── AC3: contamination record shape ──────────────────────────────────────────
def test_build_contamination_record_shape_and_redaction():
rec = guard.build_contamination_record(
reason_class="stable_branch_push",
command_redacted="git push https://user:[email protected]/o/r.git master",
session_id="prgs-author-123",
remote="prgs",
ref="master",
role="author",
)
assert rec["kind"] == guard.CONTAMINATION_KIND
assert rec["reason_class"] == "stable_branch_push"
assert rec["remote"] == "prgs"
assert rec["ref"] == "master"
assert rec["cleared_by_reconciler"] is False
# secret must never survive into the durable record
assert "tok@" not in rec["command_summary"]
assert "***@" in rec["command_summary"]
# ── AC4: fail-closed gate on gated mutations ─────────────────────────────────
def _marker():
return guard.build_contamination_record(
reason_class="stable_branch_push",
command_redacted="git push prgs master",
remote="prgs",
ref="master",
role="author",
)
def test_gate_blocks_gated_mutations_for_author():
marker = _marker()
for task in ("create_pr", "merge_pr", "close_issue", "review_pr",
"submit_pr_review", "commit_files", "close_pr"):
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
assert gate["block"] is True, task
assert gate["reasons"]
def test_gate_allows_comment_and_lock_for_handoff():
marker = _marker()
for task in ("comment_issue", "lock_issue", "create_issue", "mark_issue"):
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
assert gate["block"] is False, task
def test_gate_exempts_reconciler_audit_path():
marker = _marker()
gate = guard.assess_contamination_gate(marker, task="close_pr", actual_role="reconciler")
assert gate["block"] is False
def test_gate_no_marker_allows_everything():
gate = guard.assess_contamination_gate(None, task="merge_pr", actual_role="merger")
assert gate["block"] is False
def test_gate_cleared_marker_allows_everything():
marker = _marker()
marker["cleared_by_reconciler"] = True
gate = guard.assess_contamination_gate(marker, task="merge_pr", actual_role="merger")
assert gate["block"] is False
def test_same_worker_cannot_self_clear_by_role():
# A merger/reviewer/author role is still gated — only reconciler is exempt.
marker = _marker()
for role in ("author", "reviewer", "merger"):
gate = guard.assess_contamination_gate(marker, task="merge_pr", actual_role=role)
assert gate["block"] is True, role
def test_format_gate_error_mentions_issue():
marker = _marker()
gate = guard.assess_contamination_gate(marker, task="merge_pr", actual_role="merger")
msg = guard.format_contamination_gate_error(gate)
assert "#671" in msg
assert "contaminat" in msg.lower()
# ── redaction unit coverage ──────────────────────────────────────────────────
def test_redact_url_userinfo():
out = guard.redact_command("git push https://bob:secretpat@host/o/r.git master")
assert "secretpat" not in out
assert "***@" in out
assert "master" in out # structure preserved for audit
def test_redact_token_assignment():
out = guard.redact_command("GITEA_TOKEN=abcdef123456 git push prgs master")
assert "abcdef123456" not in out
assert "GITEA_TOKEN=***" in out
def test_redact_empty():
assert guard.redact_command(None) == ""
assert guard.redact_command("") == ""
# ── detect_stable_push over iterables ────────────────────────────────────────
def test_detect_stable_push_iterable_finds_first_contamination():
cmds = ["git status", "git push prgs master", "echo done"]
res = guard.detect_stable_push(cmds)
assert res["contamination"] is True
def test_detect_stable_push_iterable_all_safe():
cmds = ["git status", "git push prgs feature/x", "git fetch prgs"]
res = guard.detect_stable_push(cmds)
assert res["contamination"] is False
+17 -1
View File
@@ -100,7 +100,23 @@ class TestRuntimeContextGuardAlignment(unittest.TestCase):
srv._preflight_in_test_mode = self._orig_in_test
self._env_patch.stop()
def test_runtime_context_and_guard_share_resolved_workspace(self):
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
def test_runtime_context_and_guard_share_resolved_workspace(
self, _exists, _isdir, mock_run
):
def run_side_effect(cmd, *args, **kwargs):
res = MagicMock(returncode=0)
if "--git-common-dir" in cmd:
res.stdout = f"{CONTROL_ROOT}/.git\n"
elif "--show-toplevel" in cmd:
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
res.stdout = f"{cwd}\n"
else:
res.stdout = f"{CONTROL_ROOT}\n"
return res
mock_run.side_effect = run_side_effect
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
+2 -6
View File
@@ -14,11 +14,7 @@ from merged_cleanup_reconcile import (
read_issue_lock,
read_local_worktree_state,
)
_REVIEW_WORKTREE_RE = re.compile(
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
re.IGNORECASE,
)
from reviewer_worktree import REVIEW_WORKTREE_RE
CLASSIFICATIONS = frozenset({
"active-pr",
@@ -147,7 +143,7 @@ def classify_entry(
if not record:
return "orphan", "Directory exists but is not a registered git worktree"
if record.get("detached") and _REVIEW_WORKTREE_RE.search(rel_path):
if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path):
return "detached-review", "Detached HEAD in reviewer/simulation worktree"
if state.get("exists") and state.get("clean"):
+3 -5
View File
@@ -35,7 +35,7 @@ from datetime import datetime, timezone
from typing import Any
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
from reviewer_worktree import parse_dirty_tracked_files
from reviewer_worktree import parse_dirty_tracked_files, REVIEW_WORKTREE_RE
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)
@@ -508,10 +508,8 @@ DISPOSITIONS = frozenset({
"unsafe_unknown",
})
REVIEW_WORKTREE_RE = re.compile(
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
re.IGNORECASE,
)
def normalize_path(path: str) -> str: