Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,6 +39,21 @@ 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
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
run: |
Expand Down
7 changes: 6 additions & 1 deletion CRON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
27 changes: 23 additions & 4 deletions bin/run_autopull.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions bin/secret_scan.py
Original file line number Diff line number Diff line change
@@ -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())
5 changes: 4 additions & 1 deletion hooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,17 @@ 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
fi
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"
Expand Down
5 changes: 5 additions & 0 deletions orchestrator/approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
110 changes: 62 additions & 48 deletions orchestrator/pr_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -246,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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
Expand All @@ -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"):
Expand All @@ -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"]),
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -1953,15 +1932,50 @@ 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
pr_state["risk_level"] = risk.level
_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
Expand Down
Loading
Loading