From 288ebfa0b9e5607bb80f69cad4aad7f36e776fb8 Mon Sep 17 00:00:00 2001 From: kai-linux Date: Mon, 13 Jul 2026 12:58:10 +0200 Subject: [PATCH 1/2] Harden autonomous merge and deployment controls --- .github/workflows/ci.yml | 6 ++ CRON.md | 7 ++- bin/run_autopull.sh | 27 +++++++-- bin/secret_scan.py | 59 ++++++++++++++++++ hooks/pre-commit | 5 +- orchestrator/approvals.py | 5 ++ orchestrator/pr_monitor.py | 108 +++++++++++++++++++-------------- orchestrator/queue.py | 42 ++++++++++--- tests/test_gh_project.py | 38 +++--------- tests/test_pr_monitor_stuck.py | 21 ++++++- tests/test_queue.py | 19 +++++- 11 files changed, 241 insertions(+), 96 deletions(-) create mode 100755 bin/secret_scan.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f768a9d..315183d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -37,6 +39,10 @@ jobs: set -o pipefail python -m py_compile orchestrator/*.py 2>&1 | tee "$CI_ARTIFACT_DIR/lint.log" + - name: Scan added content for secrets + shell: bash + run: python bin/secret_scan.py --base "${{ github.event.pull_request.base.sha || github.event.before }}" + - name: Run tests shell: bash run: | diff --git a/CRON.md b/CRON.md index 587d7ab..a1ee13a 100644 --- a/CRON.md +++ b/CRON.md @@ -14,6 +14,11 @@ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin # Auto-pull latest orchestrator code * * * * * /path/to/agent-os/bin/run_autopull.sh >> /path/to/agent-os/logs/autopull.log 2>&1 +`run_autopull.sh` deploys only the exact 40-character commit recorded in +`runtime/deploy-approved-sha`. Update that file as an explicit operator action +after reviewing a release; the runtime checkout never pushes or follows a +mutable branch tip. + # Dispatch ready issues from GitHub Project → mailbox * * * * * /path/to/agent-os/bin/run_dispatcher.sh >> /path/to/agent-os/runtime/logs/dispatcher.log 2>&1 @@ -71,7 +76,7 @@ Each wrapper emits a timestamp banner like `[2026-03-30T12:34:56+0200] queue sta | Schedule | Script | Role | |---|---|---| -| `* * * * *` | `run_autopull.sh` | Fast-forwards the orchestrator checkout so cron always runs the latest code | +| `* * * * *` | `run_autopull.sh` | Checks out the explicitly approved, SHA-pinned release | | `* * * * *` | `run_dispatcher.sh` | Picks up Ready issues, formats them, writes to mailbox | | `* * * * *` | `run_queue.sh` | Executes tasks in isolated worktrees, manages agent fallback | | `*/5 * * * *` | `run_pr_monitor.sh` | CI gate + auto-merge + auto-rebase for agent PRs | diff --git a/bin/run_autopull.sh b/bin/run_autopull.sh index 3961d2a..e6a8433 100755 --- a/bin/run_autopull.sh +++ b/bin/run_autopull.sh @@ -7,9 +7,28 @@ set -euo pipefail log_cron_start "autopull" cd "$ROOT" -git pull --rebase --autostash || true -# Push any local-only commits (e.g. CODEBASE.md updates from agents) -if [ "$(git rev-list --count @{u}..HEAD 2>/dev/null)" -gt 0 ] 2>/dev/null; then - git push +# Runtime deploys are immutable and operator-approved. Write a full commit SHA +# to runtime/deploy-approved-sha during an explicit deployment. This checkout +# never pushes and never follows a mutable branch tip. +APPROVED_SHA_FILE="${AGENTOS_APPROVED_SHA_FILE:-$ROOT/runtime/deploy-approved-sha}" +if [[ ! -f "$APPROVED_SHA_FILE" ]]; then + echo "No approved deploy SHA at $APPROVED_SHA_FILE; leaving runtime unchanged." + exit 0 +fi + +APPROVED_SHA="$(tr -d '[:space:]' < "$APPROVED_SHA_FILE")" +if [[ ! "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid approved deploy SHA; expected 40 lowercase hexadecimal characters." >&2 + exit 1 +fi + +git fetch --quiet origin main +if ! git merge-base --is-ancestor "$APPROVED_SHA" origin/main; then + echo "Approved SHA is not an ancestor of origin/main; refusing deployment." >&2 + exit 1 +fi + +if [[ "$(git rev-parse HEAD)" != "$APPROVED_SHA" ]]; then + git checkout --detach "$APPROVED_SHA" fi diff --git a/bin/secret_scan.py b/bin/secret_scan.py new file mode 100755 index 0000000..6d72789 --- /dev/null +++ b/bin/secret_scan.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Fail closed on common credential shapes without echoing secret material.""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys + +PATTERNS = { + "private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"), + "GitHub token": re.compile(r"\b(?:gh[opusr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b"), + "AWS access key": re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"), + "Google API key": re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), + "Telegram token": re.compile(r"\b[0-9]{9,10}:[A-Za-z0-9_-]{35}\b"), + "provider API key": re.compile(r"\b(?:sk-(?:or-v1-)?|sk-ant-api03-)[A-Za-z0-9_-]{20,}\b"), + "bearer token": re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}\b"), +} + + +def added_lines(diff: str): + current = "unknown" + for line in diff.splitlines(): + if line.startswith("+++ b/"): + current = line[6:] + elif line.startswith("+") and not line.startswith("+++"): + yield current, line[1:] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--staged", action="store_true") + parser.add_argument("--base", default="") + args = parser.parse_args() + cmd = ["git", "diff", "--no-color", "-U0"] + if args.staged: + cmd.append("--cached") + elif args.base: + cmd.append(f"{args.base}...HEAD") + else: + parser.error("use --staged or --base") + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + print("secret scan could not read the diff; blocking", file=sys.stderr) + return 2 + findings = [] + for path, line in added_lines(result.stdout): + for label, pattern in PATTERNS.items(): + if pattern.search(line): + findings.append((path, label)) + if findings: + for path, label in sorted(set(findings)): + print(f"possible {label} in added content: {path} (value redacted)", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hooks/pre-commit b/hooks/pre-commit index 9c3b772..15393d1 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -52,7 +52,6 @@ if [[ -n "$diff_blob" ]]; then if [[ "$line" =~ ^\+[^+] ]]; then if [[ "$line" =~ [0-9]{9,10}:[A-Za-z0-9_-]{35} ]]; then red "✖ staged diff contains what looks like a Telegram bot token." - red " line: ${line:0:120}" fail=1 break fi @@ -60,6 +59,10 @@ if [[ -n "$diff_blob" ]]; then done <<< "$diff_blob" fi +if ! python3 bin/secret_scan.py --staged; then + fail=1 +fi + if [[ "$fail" -ne 0 ]]; then yellow "" yellow "If this is a false positive, rerun with: git commit --no-verify" diff --git a/orchestrator/approvals.py b/orchestrator/approvals.py index bfb6995..d8c6ee7 100644 --- a/orchestrator/approvals.py +++ b/orchestrator/approvals.py @@ -139,6 +139,11 @@ def get(cfg: dict, approval_id: str) -> dict[str, Any] | None: return None +def list_records(cfg: dict, *, include_resolved: bool = False) -> list[dict[str, Any]]: + """Return approval records without exposing the private storage helper.""" + return _iter_records(cfg, include_resolved=include_resolved) + + def request( cfg: dict, *, diff --git a/orchestrator/pr_monitor.py b/orchestrator/pr_monitor.py index 326f335..b877621 100644 --- a/orchestrator/pr_monitor.py +++ b/orchestrator/pr_monitor.py @@ -131,43 +131,11 @@ def _get_conflicted_files(worktree_path: Path) -> list[str]: def _try_union_resolve(worktree_path: Path, conflict_files: list[str]) -> bool: - """Try to resolve conflicts by keeping content from both sides (union merge). - - For each file, strips conflict markers and keeps all lines. Returns True - only if every file was resolved cleanly. - """ - marker_ours = re.compile(r"^<{7}\s.*$", re.MULTILINE) - marker_sep = re.compile(r"^={7}\s*$", re.MULTILINE) - marker_theirs = re.compile(r"^>{7}\s.*$", re.MULTILINE) - - for filepath in conflict_files: - full_path = worktree_path / filepath - if not full_path.exists(): - return False - try: - content = full_path.read_text() - except Exception: - return False - - # Verify it actually has conflict markers - if not marker_ours.search(content): - continue - - # Strip all three marker types, keeping all code from both sides - resolved = marker_ours.sub("", content) - resolved = marker_sep.sub("", resolved) - resolved = marker_theirs.sub("", resolved) - - # Clean up excessive blank lines left by marker removal - resolved = re.sub(r"\n{3,}", "\n\n", resolved) - - full_path.write_text(resolved) - subprocess.run( - ["git", "-C", str(worktree_path), "add", filepath], - check=True, capture_output=True, - ) - print(f" Auto-resolved conflicts in {filepath} (union merge)") - + """Reject source conflicts; union-merging arbitrary code is unsafe.""" + del worktree_path + if conflict_files: + print(" Source conflicts require a fresh agent run or human resolution: " + ", ".join(conflict_files)) + return False return True @@ -558,7 +526,7 @@ def _list_agent_prs(repo: str) -> list[dict]: prs = gh_json([ "pr", "list", "-R", repo, "--state", "open", - "--json", "number,title,headRefName,baseRefName,isDraft,mergeable,mergeStateStatus,url,body,isCrossRepository,createdAt", + "--json", "number,title,headRefName,headRefOid,baseRefName,isDraft,mergeable,mergeStateStatus,url,body,isCrossRepository,createdAt", ]) or [] except Exception as e: print(f"Warning: failed to list PRs for {repo}: {e}") @@ -1413,7 +1381,9 @@ def _post_risk_comment(repo: str, pr_number: int, risk: RiskAssessment): print(f"Warning: failed to post risk comment on PR #{pr_number}: {e}") -def _send_risk_telegram(cfg: dict, repo: str, pr_number: int, risk: RiskAssessment): +def _send_risk_telegram( + cfg: dict, repo: str, pr_number: int, head_sha: str, risk: RiskAssessment +): if risk.level != "high": return from orchestrator.queue import send_telegram @@ -1426,9 +1396,10 @@ def _send_risk_telegram(cfg: dict, repo: str, pr_number: int, risk: RiskAssessme context={ "repo": repo, "pr_number": pr_number, + "head_sha": head_sha, "risk_level": risk.level, "summary": risk.short_summary, - "dedup_key": f"high-risk-pr:{repo}:{pr_number}", + "dedup_key": f"high-risk-pr:{repo}:{pr_number}:{head_sha}", }, ) if record.get("telegram_message_id"): @@ -1441,7 +1412,14 @@ def _send_risk_telegram(cfg: dict, repo: str, pr_number: int, risk: RiskAssessme f"Risk: {risk.short_summary}\n" "Default on expiry: hold" ) - message_id = send_telegram(cfg, text) + message_id = send_telegram( + cfg, + text, + reply_markup={"inline_keyboard": [[ + {"text": "Approve", "callback_data": f"hrp:{record['id']}:approve"}, + {"text": "Hold", "callback_data": f"hrp:{record['id']}:reject"}, + ]]}, + ) approvals.update( cfg, str(record["id"]), @@ -1717,8 +1695,8 @@ def _quality_harness_gate(cfg: dict, repo: str, pr_number: int) -> tuple[bool, s def _work_verifier_gate(cfg: dict, repo: str, pr: dict, pr_state: dict) -> tuple[bool, str]: # A verifier crash must not take pr_monitor down for every other repo # (2026-04-21: a .format() brace bug here killed the whole poll, burning - # auto-merge attempts on unrelated PRs). Fail open with a warning — a - # transient verifier error should not block a mergeable PR. + # auto-merge attempts on unrelated PRs). A verifier is a security gate: + # isolate the failure to this PR, but always fail closed. try: report = verify_pull_request( cfg, @@ -1728,8 +1706,9 @@ def _work_verifier_gate(cfg: dict, repo: str, pr: dict, pr_state: dict) -> tuple worker_agent=str(pr_state.get("worker_agent") or ""), ) except Exception as e: - print(f"Warning: work verifier crashed on PR #{pr['number']}: {e!r} — failing open") - return True, f"work verifier unavailable ({type(e).__name__})" + print(f"Warning: work verifier crashed on PR #{pr['number']}: {e!r} — blocking merge") + pr_state["work_verifier_verdict"] = "error" + return False, f"work verifier unavailable ({type(e).__name__})" pr_state["work_verifier_signature"] = report.signature pr_state["work_verifier_verdict"] = report.verdict if not report.blocked: @@ -1953,7 +1932,17 @@ def monitor_prs(): print(f" PR #{pr_number}: rebase failed, skipping") continue - # Semantic risk assessment (once per PR, cached in state) + # Bind risk and any human approval to the exact PR head. A push to + # the branch invalidates both and forces a fresh assessment. + head_sha = str(pr.get("headRefOid") or "").strip() + if not head_sha: + print(f" PR #{pr_number}: head SHA unavailable — blocking merge") + continue + if pr_state.get("risk_head_sha") != head_sha: + pr_state.pop("risk_assessed", None) + pr_state.pop("risk_level", None) + pr_state.pop("risk_approval_id", None) + pr_state["risk_head_sha"] = head_sha if not pr_state.get("risk_assessed"): risk = assess_pr_risk(repo, pr_number) pr_state["risk_assessed"] = True @@ -1961,7 +1950,32 @@ def monitor_prs(): _save_state(paths, state) print(f" PR #{pr_number}: risk={risk.level} ({risk.short_summary})") _post_risk_comment(repo, pr_number, risk) - _send_risk_telegram(cfg, repo, pr_number, risk) + _send_risk_telegram(cfg, repo, pr_number, head_sha, risk) + if risk.level == "high": + pending = [ + record for record in approvals.list_records(cfg, include_resolved=True) + if record.get("kind") == "high_risk_pr" + and (record.get("context") or {}).get("dedup_key") + == f"high-risk-pr:{repo}:{pr_number}:{head_sha}" + ] + if pending: + pr_state["risk_approval_id"] = pending[-1]["id"] + + if pr_state.get("risk_level") == "high": + approval = approvals.get(cfg, str(pr_state.get("risk_approval_id") or "")) + context = (approval or {}).get("context") or {} + approved = ( + approval is not None + and approval.get("status") == "resolved" + and approval.get("decision") == "approve" + and context.get("repo") == repo + and int(context.get("pr_number") or 0) == pr_number + and context.get("head_sha") == head_sha + ) + if not approved: + decision = (approval or {}).get("decision", "missing") + print(f" PR #{pr_number}: high-risk approval gate blocked merge ({decision})") + continue new_attempts = attempts + 1 state.setdefault(pr_url, {})["attempts"] = new_attempts diff --git a/orchestrator/queue.py b/orchestrator/queue.py index b0f6de6..57bee6a 100644 --- a/orchestrator/queue.py +++ b/orchestrator/queue.py @@ -10,6 +10,9 @@ import subprocess import tempfile import traceback +import urllib.error +import urllib.parse +import urllib.request from datetime import datetime, timedelta, timezone from pathlib import Path from uuid import uuid4 @@ -735,23 +738,25 @@ def telegram_api( url = f"https://api.telegram.org/bot{token}/{method}" try: - cmd = ["curl", "-sS", "-X", "POST", url] + encoded_payload: dict[str, str] = {} for key, value in (payload or {}).items(): if isinstance(value, (dict, list)): value = json.dumps(value, separators=(",", ":")) elif isinstance(value, bool): value = "true" if value else "false" - cmd.extend(["--data-urlencode", f"{key}={value}"]) - result = subprocess.run(cmd, capture_output=True, text=True, timeout=20) - if result.returncode != 0: - log(f"Telegram {method} failed: {result.stderr}", logfile, queue_summary_log=queue_summary_log) - return None - data = json.loads(result.stdout) if result.stdout else {} + encoded_payload[key] = str(value) + request = urllib.request.Request( + url, + data=urllib.parse.urlencode(encoded_payload).encode("utf-8"), + method="POST", + ) + with urllib.request.urlopen(request, timeout=20) as response: + data = json.loads(response.read().decode("utf-8")) if not data.get("ok"): log(f"Telegram {method} error: {data}", logfile, queue_summary_log=queue_summary_log) return None return data - except Exception as e: + except (OSError, urllib.error.URLError, json.JSONDecodeError) as e: log(f"Telegram {method} exception: {e}", logfile, queue_summary_log=queue_summary_log) return None @@ -1103,11 +1108,30 @@ def handle_telegram_callback( from orchestrator.audit_log import append_audit_event from orchestrator import approvals - m = re.fullmatch(r"(esc|plan|rvt):([a-f0-9]{12}):(requeue|retry|close|skip|approve|reject|cancel)", callback_data or "") + m = re.fullmatch(r"(esc|plan|rvt|hrp):([a-f0-9]{12}):(requeue|retry|close|skip|approve|reject|cancel)", callback_data or "") if not m: return {"text": "Unknown action.", "show_alert": True, "remove_keyboard": False} action_type, action_id, operation = m.groups() + if action_type == "hrp": + if operation not in {"approve", "reject"}: + return {"text": "Unknown approval action.", "show_alert": True, "remove_keyboard": True} + record = approvals.get(cfg, action_id) + if not record or record.get("kind") != "high_risk_pr": + return {"text": "This approval is no longer available.", "show_alert": True, "remove_keyboard": True} + if record.get("status") == "resolved": + return {"text": "This approval was already handled.", "show_alert": True, "remove_keyboard": True} + decision = "approve" if operation == "approve" else "hold" + approvals.resolve(cfg, action_id, decision, f"{decision.title()} from Telegram callback.") + append_audit_event(cfg, "telegram_callback", { + "action_type": action_type, + "action_id": action_id, + "operation": operation, + "repo": (record.get("context") or {}).get("repo"), + "head_sha": (record.get("context") or {}).get("head_sha"), + }) + return {"text": f"High-risk PR {decision} recorded.", "show_alert": False, "remove_keyboard": True} + action = load_telegram_action(actions_dir, action_id) if not action: return {"text": "This escalation action is no longer available.", "show_alert": True, "remove_keyboard": True} diff --git a/tests/test_gh_project.py b/tests/test_gh_project.py index 5e859b8..a242cb9 100644 --- a/tests/test_gh_project.py +++ b/tests/test_gh_project.py @@ -569,22 +569,8 @@ def mock_run(cmd, **kw): # --------------------------------------------------------------------------- -def test_try_union_resolve_strips_markers(tmp_path, monkeypatch): - """Union resolve keeps both sides and strips conflict markers.""" - import subprocess as _sp - git_add_calls = [] - orig_run = _sp.run - - def mock_run(cmd, **kw): - if "add" in cmd: - git_add_calls.append(cmd) - r = Mock() - r.returncode = 0 - return r - return orig_run(cmd, **kw) - - monkeypatch.setattr(_sp, "run", mock_run) - +def test_try_union_resolve_rejects_source_conflicts(tmp_path): + """Arbitrary source conflicts must never be concatenated automatically.""" conflicted = tmp_path / "foo.py" conflicted.write_text( "import os\n" @@ -599,15 +585,8 @@ def mock_run(cmd, **kw): ) result = _try_union_resolve(tmp_path, ["foo.py"]) - assert result is True - - resolved = conflicted.read_text() - assert "<<<<<<" not in resolved - assert "=======" not in resolved - assert ">>>>>>>" not in resolved - assert "def hello():" in resolved - assert "def world():" in resolved - assert "# end" in resolved + assert result is False + assert "<<<<<<< HEAD" in conflicted.read_text() def test_try_union_resolve_missing_file(tmp_path, monkeypatch): @@ -616,15 +595,12 @@ def test_try_union_resolve_missing_file(tmp_path, monkeypatch): assert result is False -def test_try_union_resolve_no_markers(tmp_path, monkeypatch): - """Files without markers are skipped (considered already resolved).""" - import subprocess as _sp - monkeypatch.setattr(_sp, "run", lambda cmd, **kw: Mock(returncode=0)) - +def test_try_union_resolve_rejects_any_non_allowlisted_file(tmp_path): + """Only separately handled metadata files may be auto-resolved.""" clean = tmp_path / "clean.py" clean.write_text("def foo():\n pass\n") result = _try_union_resolve(tmp_path, ["clean.py"]) - assert result is True + assert result is False # Content unchanged assert clean.read_text() == "def foo():\n pass\n" diff --git a/tests/test_pr_monitor_stuck.py b/tests/test_pr_monitor_stuck.py index 4c1fe98..9fc412a 100644 --- a/tests/test_pr_monitor_stuck.py +++ b/tests/test_pr_monitor_stuck.py @@ -223,7 +223,7 @@ def test_monitor_prs_work_verifier_block_clears_poisoned_attempts(monkeypatch, t def test_send_risk_telegram_creates_high_risk_approval(tmp_path, monkeypatch): sent = [] - monkeypatch.setattr("orchestrator.queue.send_telegram", lambda cfg, text: sent.append(text) or 321) + monkeypatch.setattr("orchestrator.queue.send_telegram", lambda cfg, text, **kwargs: sent.append((text, kwargs)) or 321) cfg = {"root_dir": str(tmp_path), "telegram_chat_id": "-100123"} risk = RiskAssessment( level="high", @@ -234,7 +234,7 @@ def test_send_risk_telegram_creates_high_risk_approval(tmp_path, monkeypatch): has_test_changes=False, ) - pr_monitor._send_risk_telegram(cfg, "owner/repo", 77, risk) + pr_monitor._send_risk_telegram(cfg, "owner/repo", 77, "abc123", risk) assert sent approval_files = list((tmp_path / "runtime" / "approvals").glob("approval-*.md")) @@ -242,3 +242,20 @@ def test_send_risk_telegram_creates_high_risk_approval(tmp_path, monkeypatch): text = approval_files[0].read_text(encoding="utf-8") assert "kind: high_risk_pr" in text assert "pr_number: 77" in text + assert "head_sha: abc123" in text + assert sent[0][1]["reply_markup"]["inline_keyboard"] + + +def test_work_verifier_exception_fails_closed(monkeypatch): + monkeypatch.setattr( + pr_monitor, + "verify_pull_request", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("judge unavailable")), + ) + state = {} + allowed, reason = pr_monitor._work_verifier_gate( + {}, "owner/repo", {"number": 77, "body": ""}, state + ) + assert allowed is False + assert reason == "work verifier unavailable (RuntimeError)" + assert state["work_verifier_verdict"] == "error" diff --git a/tests/test_queue.py b/tests/test_queue.py index a0b5c99..67ce036 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1735,7 +1735,24 @@ def test_handle_telegram_callback_plan_approve(): resolved = approval_path.read_text(encoding="utf-8") assert "decision: approve" in resolved audit_lines = (Path(d) / "runtime" / "audit" / "audit.jsonl").read_text(encoding="utf-8").splitlines() - assert any('"event_type":"telegram_callback"' in line for line in audit_lines) + assert any('"event_type":"telegram_callback"' in line for line in audit_lines) + + +def test_handle_telegram_callback_high_risk_pr_is_sha_bound(tmp_path): + cfg = {"root_dir": str(tmp_path)} + record = approvals.request( + cfg, + kind="high_risk_pr", + approval_id="abcdef123456", + context={"repo": "owner/repo", "pr_number": 7, "head_sha": "abc123"}, + ) + outcome = handle_telegram_callback( + cfg, tmp_path / "telegram_actions", f"hrp:{record['id']}:approve" + ) + assert outcome["remove_keyboard"] is True + resolved = approvals.get(cfg, record["id"]) + assert resolved["decision"] == "approve" + assert resolved["context"]["head_sha"] == "abc123" def test_handle_telegram_callback_revert_approve(tmp_path, monkeypatch): From 19c2f2aad93c7a3ac260173f5fd8bb3b6f840089 Mon Sep 17 00:00:00 2001 From: kai-linux Date: Mon, 13 Jul 2026 12:59:26 +0200 Subject: [PATCH 2/2] Cover push scans and Telegram polling --- .github/workflows/ci.yml | 13 ++++++++++++- orchestrator/pr_monitor.py | 2 +- orchestrator/queue.py | 25 +++++++++---------------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 315183d..21eebd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,18 @@ jobs: - name: Scan added content for secrets shell: bash - run: python bin/secret_scan.py --base "${{ github.event.pull_request.base.sha || github.event.before }}" + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + base="$PR_BASE_SHA" + if [[ -z "$base" || "$base" =~ ^0+$ ]]; then + base="$PUSH_BEFORE_SHA" + fi + if [[ -z "$base" || "$base" =~ ^0+$ ]]; then + base="HEAD^" + fi + python bin/secret_scan.py --base "$base" - name: Run tests shell: bash diff --git a/orchestrator/pr_monitor.py b/orchestrator/pr_monitor.py index b877621..689cb47 100644 --- a/orchestrator/pr_monitor.py +++ b/orchestrator/pr_monitor.py @@ -214,7 +214,7 @@ def _rebase_pr_onto_main(repo: str, pr: dict) -> bool: subprocess.run(["git", "-C", str(worktree_path), "rm", "-f", ".agent_result.md"], capture_output=True) subprocess.run(["git", "-C", str(worktree_path), "checkout", "--theirs", "CODEBASE.md"], capture_output=True) - # For remaining conflicted files, try union merge (keep both sides) + # Any remaining source conflict is unsafe to auto-resolve. conflict_files = _get_conflicted_files(worktree_path) if conflict_files: had_real_content_merge = True diff --git a/orchestrator/queue.py b/orchestrator/queue.py index 57bee6a..ffbb136 100644 --- a/orchestrator/queue.py +++ b/orchestrator/queue.py @@ -997,23 +997,16 @@ def _save_telegram_offset(offset_path: Path, update_id: int): offset_path.write_text(str(update_id), encoding="utf-8") def _get_telegram_updates(cfg: dict, offset: int, logfile: Path | None = None, queue_summary_log: Path | None = None) -> list[dict]: - token = str(cfg.get("telegram_bot_token", "")).strip() - if not token: - return [] - url = f"https://api.telegram.org/bot{token}/getUpdates?offset={offset}&timeout=0" - try: - result = subprocess.run(["curl", "-sS", url], capture_output=True, text=True, timeout=20) - if result.returncode != 0: - log(f"Telegram getUpdates failed: {result.stderr}", logfile, queue_summary_log=queue_summary_log) - return [] - data = json.loads(result.stdout) if result.stdout else {} - if not data.get("ok"): - log(f"Telegram getUpdates error: {data}", logfile, queue_summary_log=queue_summary_log) - return [] - return data.get("result", []) - except Exception as e: - log(f"Telegram getUpdates exception: {e}", logfile, queue_summary_log=queue_summary_log) + data = telegram_api( + cfg, + "getUpdates", + {"offset": offset, "timeout": 0}, + logfile, + queue_summary_log, + ) + if not data: return [] + return data.get("result", []) def _project_cfg(cfg: dict, project_key: str) -> dict: project_cfg = cfg.get("github_projects", {}).get(project_key)