feat(preflight): block workspace edits before identity/capability verification
This commit is contained in:
+101
@@ -91,6 +91,100 @@ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
|||||||
if PROJECT_ROOT not in sys.path:
|
if PROJECT_ROOT not in sys.path:
|
||||||
sys.path.insert(0, PROJECT_ROOT)
|
sys.path.insert(0, PROJECT_ROOT)
|
||||||
|
|
||||||
|
PREFLIGHT_FILE = "/tmp/gitea_preflight_check.json"
|
||||||
|
|
||||||
|
def record_preflight_check(type_name: str, resolved_role: str | None = None):
|
||||||
|
"""Record a pre-flight check (whoami or capability) and check for workspace edits."""
|
||||||
|
import time
|
||||||
|
is_dirty = False
|
||||||
|
in_test = "pytest" in sys.modules or "unittest" in sys.modules
|
||||||
|
if in_test and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
|
||||||
|
is_dirty = False
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "status", "--porcelain"],
|
||||||
|
capture_output=True, text=True, cwd=PROJECT_ROOT
|
||||||
|
)
|
||||||
|
for line in res.stdout.splitlines():
|
||||||
|
if line and not line.startswith("??"):
|
||||||
|
is_dirty = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
if os.path.exists(PREFLIGHT_FILE):
|
||||||
|
try:
|
||||||
|
with open(PREFLIGHT_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if is_dirty:
|
||||||
|
if type_name == "whoami" and not data.get("whoami_called"):
|
||||||
|
data["whoami_preflight_violation"] = True
|
||||||
|
if type_name == "capability" and not data.get("capability_called"):
|
||||||
|
data["capability_preflight_violation"] = True
|
||||||
|
|
||||||
|
if type_name == "whoami":
|
||||||
|
data["whoami_called"] = True
|
||||||
|
data["whoami_timestamp"] = time.time()
|
||||||
|
elif type_name == "capability":
|
||||||
|
data["capability_called"] = True
|
||||||
|
data["capability_timestamp"] = time.time()
|
||||||
|
if resolved_role:
|
||||||
|
data["role"] = resolved_role
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(PREFLIGHT_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def verify_preflight_purity(remote: str | None = None):
|
||||||
|
"""Verify that identity and capability were verified prior to edits, and that reviewers made no edits."""
|
||||||
|
in_test = "pytest" in sys.modules or "unittest" in sys.modules
|
||||||
|
if in_test and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
|
||||||
|
return
|
||||||
|
if not os.path.exists(PREFLIGHT_FILE):
|
||||||
|
raise RuntimeError("Pre-flight order violation: Identity and capability verification were skipped (fail closed)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(PREFLIGHT_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Could not read pre-flight check record: {e} (fail closed)")
|
||||||
|
|
||||||
|
if not data.get("whoami_called"):
|
||||||
|
raise RuntimeError("Pre-flight order violation: Identity (gitea_whoami) has not been verified (fail closed)")
|
||||||
|
if not data.get("capability_called"):
|
||||||
|
raise RuntimeError("Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)")
|
||||||
|
|
||||||
|
if data.get("whoami_preflight_violation"):
|
||||||
|
raise RuntimeError("Pre-flight order violation: Workspace file edits occurred before gitea_whoami verification (fail closed)")
|
||||||
|
if data.get("capability_preflight_violation"):
|
||||||
|
raise RuntimeError("Pre-flight order violation: Workspace file edits occurred before gitea_resolve_task_capability verification (fail closed)")
|
||||||
|
|
||||||
|
is_dirty = False
|
||||||
|
if os.environ.get("GITEA_TEST_ENVIRONMENT") == "1" and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
|
||||||
|
is_dirty = False
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "status", "--porcelain"],
|
||||||
|
capture_output=True, text=True, cwd=PROJECT_ROOT
|
||||||
|
)
|
||||||
|
for line in res.stdout.splitlines():
|
||||||
|
if line and not line.startswith("??"):
|
||||||
|
is_dirty = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if data.get("role") == "reviewer" and is_dirty:
|
||||||
|
raise RuntimeError("Reviewer role violation: Reviewer profile is forbidden from modifying tracked workspace files (fail closed)")
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP # noqa: E402
|
from mcp.server.fastmcp import FastMCP # noqa: E402
|
||||||
|
|
||||||
from gitea_auth import ( # noqa: E402
|
from gitea_auth import ( # noqa: E402
|
||||||
@@ -385,6 +479,7 @@ def gitea_create_issue(
|
|||||||
dict with 'number' of the created issue ('url' only with the reveal opt-in).
|
dict with 'number' of the created issue ('url' only with the reveal opt-in).
|
||||||
"""
|
"""
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
verify_preflight_purity(remote)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
url = f"{repo_api_url(h, o, r)}/issues"
|
url = f"{repo_api_url(h, o, r)}/issues"
|
||||||
try:
|
try:
|
||||||
@@ -505,6 +600,7 @@ def gitea_create_pr(
|
|||||||
dict with 'number' of the created PR ('url' only with the reveal opt-in).
|
dict with 'number' of the created PR ('url' only with the reveal opt-in).
|
||||||
"""
|
"""
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
verify_preflight_purity(remote)
|
||||||
|
|
||||||
# ── Issue Lock Validation (Issue #194 / #196) ──
|
# ── Issue Lock Validation (Issue #194 / #196) ──
|
||||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
if not os.path.exists(ISSUE_LOCK_FILE):
|
||||||
@@ -1149,6 +1245,7 @@ def gitea_submit_pr_review(
|
|||||||
authenticated user, profile name, PR author, PR number, head SHA
|
authenticated user, profile name, PR author, PR number, head SHA
|
||||||
checked, and the reasons/gates passed or blocked. Never secrets.
|
checked, and the reasons/gates passed or blocked. Never secrets.
|
||||||
"""
|
"""
|
||||||
|
verify_preflight_purity(remote)
|
||||||
action = (action or "").strip().lower()
|
action = (action or "").strip().lower()
|
||||||
result = {
|
result = {
|
||||||
"requested_action": action,
|
"requested_action": action,
|
||||||
@@ -1379,6 +1476,7 @@ def gitea_commit_files(
|
|||||||
dict with success status and commit/branch information.
|
dict with success status and commit/branch information.
|
||||||
"""
|
"""
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
verify_preflight_purity(remote)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
url = f"{repo_api_url(h, o, r)}/contents"
|
url = f"{repo_api_url(h, o, r)}/contents"
|
||||||
|
|
||||||
@@ -1472,6 +1570,7 @@ def gitea_merge_pr(
|
|||||||
reasons/gates passed or blocked, and merge result / merge commit if
|
reasons/gates passed or blocked, and merge result / merge commit if
|
||||||
available. Never secrets.
|
available. Never secrets.
|
||||||
"""
|
"""
|
||||||
|
verify_preflight_purity(remote)
|
||||||
do = (do or "").strip().lower()
|
do = (do or "").strip().lower()
|
||||||
result = {
|
result = {
|
||||||
"performed": False,
|
"performed": False,
|
||||||
@@ -2744,6 +2843,7 @@ def gitea_whoami(
|
|||||||
"""
|
"""
|
||||||
if remote not in REMOTES:
|
if remote not in REMOTES:
|
||||||
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
|
||||||
|
record_preflight_check("whoami")
|
||||||
h = host or REMOTES[remote]["host"]
|
h = host or REMOTES[remote]["host"]
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
url = gitea_url(h, "/api/v1/user")
|
url = gitea_url(h, "/api/v1/user")
|
||||||
@@ -3580,6 +3680,7 @@ def gitea_resolve_task_capability(
|
|||||||
|
|
||||||
required_permission = TASK_MAP[task]["permission"]
|
required_permission = TASK_MAP[task]["permission"]
|
||||||
required_role = TASK_MAP[task]["role"]
|
required_role = TASK_MAP[task]["role"]
|
||||||
|
record_preflight_check("capability", required_role)
|
||||||
|
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
config = gitea_config.load_config()
|
config = gitea_config.load_config()
|
||||||
|
|||||||
Reference in New Issue
Block a user