ctrlrelay 0.4.1__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/CHANGELOG.md +70 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/PKG-INFO +1 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/pyproject.toml +1 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/cli.py +30 -1
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/secops.py +156 -7
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/socket_client.py +11 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_secops_pipeline.py +392 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_transport.py +97 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.gitignore +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/LICENSE +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/README.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/SECURITY.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/Gemfile +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/_config.yml +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/architecture.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/bridge.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/cli.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/configuration.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/development.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/getting-started.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/index.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/operations.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/personalization.md +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/__init__.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/conftest.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_audit.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_config.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_github.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_install.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_obs.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_poller.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_setup.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_state.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_worktree.py +0 -0
|
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-05-11
|
|
11
|
+
|
|
12
|
+
Minor release. Three changes on the secops path; together they take the
|
|
13
|
+
secops sweep from "silently drops operator decisions" to "respects per-repo
|
|
14
|
+
policy and reaches the operator on every blocked decision".
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **Secops BLOCKED questions now reach the operator via Telegram.**
|
|
19
|
+
Three discrete bugs were stacking to drop every blocked secops session
|
|
20
|
+
silently (#131):
|
|
21
|
+
1. `run_secops_all` never called `transport.ask()` after a blocked
|
|
22
|
+
pipeline result. The DB row was marked `status='blocked'` and a
|
|
23
|
+
`pending_resumes` entry was inserted, but the question went nowhere.
|
|
24
|
+
Now mirrors the dev/task pipelines: post the question, await an
|
|
25
|
+
answer, resume — up to `DEFAULT_MAX_BLOCKED_ROUNDS=5` rounds. On
|
|
26
|
+
transport failure preserves `blocked=True` so the existing
|
|
27
|
+
persistence-on-blocked branch fires.
|
|
28
|
+
2. `ctrlrelay run secops` (manual CLI) was passing `transport=None`,
|
|
29
|
+
so even with the dispatch loop in place, the `is not None` gate
|
|
30
|
+
short-circuited and questions still went to pending_resumes
|
|
31
|
+
instead of Telegram. Now builds a `SocketTransport` when the
|
|
32
|
+
bridge socket is up, mirroring the scheduled-cron path.
|
|
33
|
+
3. `SocketTransport._receive_loop` resolved the per-request future on
|
|
34
|
+
the FIRST message matching `request_id`. The bridge sends two
|
|
35
|
+
messages for an ASK: an intermediate `ACK(status="pending")` then
|
|
36
|
+
a terminal `ANSWER`. Receiving the ACK fulfilled the future early
|
|
37
|
+
and `ask()` raised `TransportError("Unexpected response: BridgeOp.ACK")`.
|
|
38
|
+
Now skips `ACK(status="pending")` and waits for `ANSWER`/`ERROR`.
|
|
39
|
+
|
|
40
|
+
Real-world impact: an 86-repo secops sweep with 21 sessions hitting
|
|
41
|
+
BLOCKED produced zero Telegram messages pre-fix. Post-fix, every
|
|
42
|
+
blocked session reaches the operator with a structured question.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Auto-merge operator-authored `.github/dependabot.yml`-only PRs.**
|
|
47
|
+
The secops agent's policy treated all operator-authored PRs as needing
|
|
48
|
+
explicit approval, even ones that ONLY add an ecosystem entry to
|
|
49
|
+
`.github/dependabot.yml`. These are the prerequisite PRs the operator
|
|
50
|
+
files when bulk-enabling Dependabot across repos with branch protection,
|
|
51
|
+
and they sat indefinitely (#132). The carve-out is narrow and
|
|
52
|
+
multi-gated:
|
|
53
|
+
1. **Author check**: `author.login == $OPERATOR` (derived from
|
|
54
|
+
`gh api user --jq .login`) — collaborators, GitHub apps, or
|
|
55
|
+
external contributors are NOT eligible even for dependabot.yml-only.
|
|
56
|
+
2. **Diff check**: `gh pr diff` must be PURELY ADDITIVE — any line
|
|
57
|
+
beginning with `-` (other than `---` file headers) signals a
|
|
58
|
+
deletion or modification of existing config -> BLOCKED.
|
|
59
|
+
3. **CI check**: `gh pr checks` must all pass — a PR adding invalid
|
|
60
|
+
YAML can pass author+diff and still break Dependabot for the repo
|
|
61
|
+
if merged without this gate.
|
|
62
|
+
|
|
63
|
+
All three must pass before merge. Any one failing -> BLOCKED.
|
|
64
|
+
|
|
65
|
+
- **Per-repo `automation:` policy now drives the secops agent.**
|
|
66
|
+
`orchestrator.yaml`'s per-repo `automation` block (defining
|
|
67
|
+
`dependabot_patch`, `dependabot_minor`, `dependabot_major` as
|
|
68
|
+
auto/ask/never) was decorative — the secops prompt had hardcoded
|
|
69
|
+
prose that ignored it (#133). Now `_build_prompt` accepts an
|
|
70
|
+
`AutomationConfig` and renders per-tier directives like
|
|
71
|
+
"patch updates: AUTO-MERGE", "minor updates: ASK", "major updates: NEVER"
|
|
72
|
+
per repo. `run_secops_all` puts `repo_config.automation` in
|
|
73
|
+
`ctx.extra`; `resume_secops_from_pending` accepts an `automation`
|
|
74
|
+
kwarg; the pending-resume sweeper in `cli.py` looks up the per-repo
|
|
75
|
+
policy and threads it on resume.
|
|
76
|
+
|
|
77
|
+
Operators can now set a sensitive repo to `dependabot_minor: never`
|
|
78
|
+
and the agent will actually respect it.
|
|
79
|
+
|
|
10
80
|
## [0.4.1] - 2026-05-08
|
|
11
81
|
|
|
12
82
|
Patch release. Two follow-ups against the v0.4.0 setup flow surfaced
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -593,6 +593,29 @@ def run_secops(
|
|
|
593
593
|
console.print(f"Running secops on {len(repos)} repo(s)...")
|
|
594
594
|
|
|
595
595
|
async def _run():
|
|
596
|
+
# Build a transport so secops blocked questions reach Telegram.
|
|
597
|
+
# Without this, agents that end BLOCKED_NEEDS_INPUT silently land
|
|
598
|
+
# in the DB+pending_resumes and the operator never sees the
|
|
599
|
+
# question (mirror of the wiring in the scheduled path).
|
|
600
|
+
transport = None
|
|
601
|
+
if (
|
|
602
|
+
config.transport.type.value == "telegram"
|
|
603
|
+
and config.transport.telegram
|
|
604
|
+
):
|
|
605
|
+
from ctrlrelay.transports import SocketTransport
|
|
606
|
+
sock = config.transport.telegram.socket_path.expanduser().resolve()
|
|
607
|
+
if sock.exists():
|
|
608
|
+
try:
|
|
609
|
+
candidate = SocketTransport(sock)
|
|
610
|
+
await candidate.connect()
|
|
611
|
+
transport = candidate
|
|
612
|
+
except Exception as e:
|
|
613
|
+
console.print(
|
|
614
|
+
f"[yellow]Bridge transport connect failed ({e}) — "
|
|
615
|
+
f"running without notifications. Blocked sessions "
|
|
616
|
+
f"will land in pending_resumes for later resume.[/yellow]"
|
|
617
|
+
)
|
|
618
|
+
|
|
596
619
|
return await run_secops_all(
|
|
597
620
|
repos=repos,
|
|
598
621
|
dispatcher=dispatcher,
|
|
@@ -600,7 +623,7 @@ def run_secops(
|
|
|
600
623
|
worktree=worktree,
|
|
601
624
|
dashboard=dashboard,
|
|
602
625
|
state_db=db,
|
|
603
|
-
transport=
|
|
626
|
+
transport=transport,
|
|
604
627
|
contexts_dir=config.paths.contexts,
|
|
605
628
|
)
|
|
606
629
|
|
|
@@ -1591,6 +1614,7 @@ def poller_start(
|
|
|
1591
1614
|
|
|
1592
1615
|
try:
|
|
1593
1616
|
if pipeline_name == "secops":
|
|
1617
|
+
sweep_repo_cfg = repo_cfg_by_name.get(repo)
|
|
1594
1618
|
result = await resume_secops_from_pending(
|
|
1595
1619
|
session_id=session_id,
|
|
1596
1620
|
repo=repo,
|
|
@@ -1602,6 +1626,11 @@ def poller_start(
|
|
|
1602
1626
|
state_db=state_db,
|
|
1603
1627
|
transport=sweeper_transport,
|
|
1604
1628
|
contexts_dir=config.paths.contexts,
|
|
1629
|
+
automation=(
|
|
1630
|
+
sweep_repo_cfg.automation
|
|
1631
|
+
if sweep_repo_cfg is not None
|
|
1632
|
+
else None
|
|
1633
|
+
),
|
|
1605
1634
|
)
|
|
1606
1635
|
elif pipeline_name == "dev":
|
|
1607
1636
|
# Dev resume needs the repo's branch template
|
|
@@ -21,6 +21,8 @@ from ctrlrelay.transports.base import Transport
|
|
|
21
21
|
|
|
22
22
|
_logger = get_logger("pipeline.secops")
|
|
23
23
|
|
|
24
|
+
DEFAULT_MAX_BLOCKED_ROUNDS = 5
|
|
25
|
+
|
|
24
26
|
|
|
25
27
|
def _question_for_persist(session_id: str, result: PipelineResult) -> str:
|
|
26
28
|
"""Return a non-empty question string for pending_resumes storage.
|
|
@@ -58,6 +60,7 @@ class SecopsPipeline:
|
|
|
58
60
|
ctx.repo,
|
|
59
61
|
session_id=ctx.session_id,
|
|
60
62
|
state_file=ctx.state_file,
|
|
63
|
+
automation=ctx.extra.get("automation"),
|
|
61
64
|
)
|
|
62
65
|
|
|
63
66
|
result = await self._spawn(ctx, prompt, resume=False)
|
|
@@ -123,21 +126,118 @@ class SecopsPipeline:
|
|
|
123
126
|
repo: str,
|
|
124
127
|
session_id: str = "",
|
|
125
128
|
state_file: Path | None = None,
|
|
129
|
+
automation: Any = None,
|
|
126
130
|
) -> str:
|
|
127
|
-
"""Build the secops prompt.
|
|
131
|
+
"""Build the secops prompt.
|
|
132
|
+
|
|
133
|
+
``automation`` is an optional ``AutomationConfig`` carrying the
|
|
134
|
+
per-repo Dependabot policy (auto/ask/never per severity tier).
|
|
135
|
+
When provided, the prompt explicitly tells the agent which tiers
|
|
136
|
+
auto-merge vs ask vs never-merge. When ``None``, falls back to
|
|
137
|
+
the default policy (patch=auto, minor=ask, never-major) so
|
|
138
|
+
existing callers and the schema defaults stay coherent."""
|
|
128
139
|
state_file_path = str(state_file) if state_file else "/tmp/state.json"
|
|
129
140
|
|
|
141
|
+
# Translate per-repo policy into prompt directives. Use defaults
|
|
142
|
+
# from AutomationConfig if no policy was passed.
|
|
143
|
+
patch_pol = (
|
|
144
|
+
automation.dependabot_patch.value
|
|
145
|
+
if automation is not None and hasattr(automation, "dependabot_patch")
|
|
146
|
+
else "auto"
|
|
147
|
+
)
|
|
148
|
+
minor_pol = (
|
|
149
|
+
automation.dependabot_minor.value
|
|
150
|
+
if automation is not None and hasattr(automation, "dependabot_minor")
|
|
151
|
+
else "ask"
|
|
152
|
+
)
|
|
153
|
+
major_pol = (
|
|
154
|
+
automation.dependabot_major.value
|
|
155
|
+
if automation is not None and hasattr(automation, "dependabot_major")
|
|
156
|
+
else "never"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _policy_directive(tier: str, policy: str) -> str:
|
|
160
|
+
if policy == "auto":
|
|
161
|
+
return (
|
|
162
|
+
f" - {tier}: AUTO-MERGE with passing CI. Squash + "
|
|
163
|
+
"delete branch."
|
|
164
|
+
)
|
|
165
|
+
if policy == "ask":
|
|
166
|
+
return (
|
|
167
|
+
f" - {tier}: ASK — signal BLOCKED with a one-line "
|
|
168
|
+
f"summary so the operator decides."
|
|
169
|
+
)
|
|
170
|
+
return (
|
|
171
|
+
f" - {tier}: NEVER — do not merge. Leave the PR open "
|
|
172
|
+
"for manual review."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
dependabot_policy_block = "\n".join(
|
|
176
|
+
[
|
|
177
|
+
_policy_directive("patch updates", patch_pol),
|
|
178
|
+
_policy_directive("minor updates", minor_pol),
|
|
179
|
+
_policy_directive("major updates", major_pol),
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
|
|
130
183
|
return f"""Execute security operations for repository {repo}.
|
|
131
184
|
|
|
132
185
|
1. Check Dependabot alerts:
|
|
133
186
|
`gh api repos/{repo}/dependabot/alerts --jq '.[] | select(.state=="open")'`
|
|
134
|
-
2. Check security PRs:
|
|
187
|
+
2. Check Dependabot-authored security PRs:
|
|
135
188
|
`gh pr list --repo {repo} --author "app/dependabot" --json number,title`
|
|
136
|
-
3.
|
|
137
|
-
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
189
|
+
3. Determine the trusted operator's GitHub login (the identity the
|
|
190
|
+
agent itself runs as). The auto-merge carve-out below applies ONLY
|
|
191
|
+
to PRs authored by this exact login — never to other contributors,
|
|
192
|
+
apps, or bots even if their PR looks safe:
|
|
193
|
+
`OPERATOR=$(gh api user --jq '.login')`
|
|
194
|
+
4. Check open PRs authored by the trusted operator that enable
|
|
195
|
+
Dependabot itself. These are prerequisites for future Dependabot
|
|
196
|
+
work and block the security pipeline if left open:
|
|
197
|
+
`gh pr list --repo {repo} --state open --author "$OPERATOR" --json number,title,files`
|
|
198
|
+
For each, inspect files: `gh pr view <NUM> --repo {repo} --json files`
|
|
199
|
+
5. For each alert or PR:
|
|
200
|
+
- **Dependabot PRs**: classify the update as patch/minor/major and
|
|
201
|
+
apply the per-repo policy below. If the severity is unclear, signal
|
|
202
|
+
BLOCKED to ask for guidance.
|
|
203
|
+
|
|
204
|
+
{dependabot_policy_block}
|
|
205
|
+
- **PR authored by `$OPERATOR` touching ONLY `.github/dependabot.yml`**
|
|
206
|
+
(additive ecosystem entries, no other files changed): MAYBE
|
|
207
|
+
auto-merge — but only after diff validation. These are the
|
|
208
|
+
"enable Dependabot" prerequisites the operator opened.
|
|
209
|
+
|
|
210
|
+
Required validation BEFORE merging:
|
|
211
|
+
a. **Author check**: confirm `author.login` exactly equals
|
|
212
|
+
`$OPERATOR`. Collaborators, GitHub apps, or external
|
|
213
|
+
contributors can craft a `dependabot.yml`-only PR to slip
|
|
214
|
+
past review — those go to BLOCKED, not auto-merge.
|
|
215
|
+
b. **Diff check**: pull the diff and confirm it is PURELY
|
|
216
|
+
ADDITIVE — no `-` lines (deletions or modifications of
|
|
217
|
+
existing stanzas), only `+` lines that introduce new
|
|
218
|
+
`package-ecosystem` blocks. Run:
|
|
219
|
+
`gh pr diff <NUM> --repo {repo}`
|
|
220
|
+
If any line in the diff begins with `-` (other than `---`
|
|
221
|
+
file headers), the PR modifies or removes existing config:
|
|
222
|
+
signal BLOCKED with a one-line summary like "operator
|
|
223
|
+
dependabot.yml PR #N modifies existing config — needs
|
|
224
|
+
review". Do NOT auto-merge.
|
|
225
|
+
c. **CI check**: confirm all status checks pass (matches the
|
|
226
|
+
Dependabot PR rule above). Run:
|
|
227
|
+
`gh pr checks <NUM> --repo {repo}`
|
|
228
|
+
Any failing or pending check -> BLOCKED. A PR that adds
|
|
229
|
+
invalid dependabot YAML can pass author+diff but fail
|
|
230
|
+
repo validation; without this gate the agent could merge
|
|
231
|
+
broken config and break Dependabot for the repo.
|
|
232
|
+
|
|
233
|
+
If (a), (b), AND (c) all pass: merge with squash, delete the
|
|
234
|
+
branch. If any one fails: BLOCKED.
|
|
235
|
+
- **Any other PR from `$OPERATOR`** (touches code, tests, configs
|
|
236
|
+
other than dependabot.yml): signal BLOCKED for operator approval.
|
|
237
|
+
Never auto-merge code changes, even from the trusted operator.
|
|
238
|
+
- **PRs from anyone else** (collaborators, contributors, other bots):
|
|
239
|
+
signal BLOCKED. Never on this path.
|
|
240
|
+
6. Summarize actions taken
|
|
141
241
|
|
|
142
242
|
## Signaling Completion
|
|
143
243
|
|
|
@@ -214,6 +314,7 @@ async def run_secops_all(
|
|
|
214
314
|
state_db: StateDB,
|
|
215
315
|
transport: Transport | None,
|
|
216
316
|
contexts_dir: Path,
|
|
317
|
+
max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
|
|
217
318
|
) -> list[PipelineResult]:
|
|
218
319
|
"""Run secops pipeline on all configured repos."""
|
|
219
320
|
results = []
|
|
@@ -260,6 +361,7 @@ async def run_secops_all(
|
|
|
260
361
|
worktree_path=worktree_path,
|
|
261
362
|
context_path=context_path,
|
|
262
363
|
state_file=state_file,
|
|
364
|
+
extra={"automation": getattr(repo_config, "automation", None)},
|
|
263
365
|
)
|
|
264
366
|
|
|
265
367
|
state_db.execute(
|
|
@@ -271,6 +373,51 @@ async def run_secops_all(
|
|
|
271
373
|
session_row_inserted = True
|
|
272
374
|
|
|
273
375
|
result = await pipeline.run(ctx)
|
|
376
|
+
|
|
377
|
+
# BLOCKED loop: when the agent ends BLOCKED_NEEDS_INPUT, push
|
|
378
|
+
# the question to the operator via the configured transport
|
|
379
|
+
# (Telegram), wait for an answer, and resume. Mirrors the
|
|
380
|
+
# dev/task pipelines — without this, secops blocked sessions
|
|
381
|
+
# silently land as "blocked" in the DB without ever reaching
|
|
382
|
+
# the operator. transport.ask() failure preserves blocked=True
|
|
383
|
+
# so the persistence-on-blocked branch below still fires and
|
|
384
|
+
# writes a pending_resumes row that a later out-of-band reply
|
|
385
|
+
# could resume via the sweeper.
|
|
386
|
+
rounds = 0
|
|
387
|
+
while (
|
|
388
|
+
result.blocked
|
|
389
|
+
and transport is not None
|
|
390
|
+
and rounds < max_blocked_rounds
|
|
391
|
+
):
|
|
392
|
+
question = _question_for_persist(session_id, result)
|
|
393
|
+
try:
|
|
394
|
+
answer = await transport.ask(
|
|
395
|
+
question,
|
|
396
|
+
session_id=session_id,
|
|
397
|
+
repo=repo,
|
|
398
|
+
)
|
|
399
|
+
except Exception as e:
|
|
400
|
+
log_event(
|
|
401
|
+
_logger,
|
|
402
|
+
"secops.transport.ask_failed",
|
|
403
|
+
session_id=session_id,
|
|
404
|
+
repo=repo,
|
|
405
|
+
error_type=type(e).__name__,
|
|
406
|
+
error=str(e)[:200],
|
|
407
|
+
)
|
|
408
|
+
result = PipelineResult(
|
|
409
|
+
success=False,
|
|
410
|
+
blocked=True,
|
|
411
|
+
session_id=session_id,
|
|
412
|
+
summary=f"Blocked session deferred (transport): {e}",
|
|
413
|
+
error=str(e),
|
|
414
|
+
question=question,
|
|
415
|
+
outputs=result.outputs,
|
|
416
|
+
)
|
|
417
|
+
break
|
|
418
|
+
rounds += 1
|
|
419
|
+
result = await pipeline.resume(ctx, answer)
|
|
420
|
+
|
|
274
421
|
results.append(result)
|
|
275
422
|
|
|
276
423
|
status = "done" if result.success else ("blocked" if result.blocked else "failed")
|
|
@@ -452,6 +599,7 @@ async def resume_secops_from_pending(
|
|
|
452
599
|
state_db: StateDB,
|
|
453
600
|
transport: Transport | None,
|
|
454
601
|
contexts_dir: Path,
|
|
602
|
+
automation: Any = None,
|
|
455
603
|
) -> PipelineResult:
|
|
456
604
|
"""Resume a BLOCKED secops session using an answer that arrived via
|
|
457
605
|
Telegram after the original session had already torn down.
|
|
@@ -497,6 +645,7 @@ async def resume_secops_from_pending(
|
|
|
497
645
|
worktree_path=worktree_path,
|
|
498
646
|
context_path=context_path,
|
|
499
647
|
state_file=state_file,
|
|
648
|
+
extra={"automation": automation},
|
|
500
649
|
)
|
|
501
650
|
|
|
502
651
|
# Flip the session back to running so an observer sees progress,
|
|
@@ -55,6 +55,17 @@ class SocketTransport:
|
|
|
55
55
|
try:
|
|
56
56
|
msg = parse_message(line.decode())
|
|
57
57
|
if msg.request_id and msg.request_id in self._pending:
|
|
58
|
+
# The bridge sends two messages back for an ASK:
|
|
59
|
+
# an intermediate ACK(status="pending") immediately
|
|
60
|
+
# after queuing for Telegram, then an ANSWER (or
|
|
61
|
+
# ERROR) once the operator replies. Resolving the
|
|
62
|
+
# future on the first ACK collapses the wait
|
|
63
|
+
# prematurely — caller sees "Unexpected response:
|
|
64
|
+
# BridgeOp.ACK" and throws. Skip those to keep
|
|
65
|
+
# waiting. Terminal ACKs (status="sent" from SEND)
|
|
66
|
+
# remain the final response and resolve normally.
|
|
67
|
+
if msg.op == BridgeOp.ACK and msg.status == "pending":
|
|
68
|
+
continue
|
|
58
69
|
self._pending[msg.request_id].set_result(msg)
|
|
59
70
|
except ProtocolError:
|
|
60
71
|
pass
|