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.
Files changed (112) hide show
  1. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/CHANGELOG.md +70 -0
  2. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/pyproject.toml +1 -1
  4. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/cli.py +30 -1
  5. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/secops.py +156 -7
  6. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/socket_client.py +11 -0
  7. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_secops_pipeline.py +392 -0
  8. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_transport.py +97 -0
  9. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/dependabot.yml +0 -0
  13. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/build.yml +0 -0
  14. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/cla.yml +0 -0
  15. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/pages.yml +0 -0
  16. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/publish.yml +0 -0
  17. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.github/workflows/test.yml +0 -0
  18. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/.gitignore +0 -0
  19. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  20. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/CONTRIBUTING.md +0 -0
  21. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/LICENSE +0 -0
  22. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/README.md +0 -0
  23. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/SECURITY.md +0 -0
  24. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/config/orchestrator.yaml.example +0 -0
  25. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/Gemfile +0 -0
  26. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/_config.yml +0 -0
  27. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/architecture.md +0 -0
  28. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/bridge.md +0 -0
  29. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/cli.md +0 -0
  30. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/configuration.md +0 -0
  31. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/development.md +0 -0
  32. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/feedback-loop.md +0 -0
  33. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/getting-started.md +0 -0
  34. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/index.md +0 -0
  35. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/operations.md +0 -0
  36. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/docs/personalization.md +0 -0
  37. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/__init__.py +0 -0
  38. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  39. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  40. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  41. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/server.py +0 -0
  42. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  43. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/__init__.py +0 -0
  44. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/audit.py +0 -0
  45. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  46. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/config.py +0 -0
  47. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  48. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/github.py +0 -0
  49. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/obs.py +0 -0
  50. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/poller.py +0 -0
  51. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  52. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  53. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/scheduler.py +0 -0
  54. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/state.py +0 -0
  55. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/core/worktree.py +0 -0
  56. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  57. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/dashboard/client.py +0 -0
  58. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/install.py +0 -0
  59. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  60. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/manager.py +0 -0
  61. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/personalization/paths.py +0 -0
  62. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  63. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/base.py +0 -0
  64. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/dev.py +0 -0
  65. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  66. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/pipelines/task.py +0 -0
  67. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/setup.py +0 -0
  68. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/__init__.py +0 -0
  69. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  70. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  71. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  72. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  73. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  74. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  75. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  76. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/__init__.py +0 -0
  77. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/base.py +0 -0
  78. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  79. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/__init__.py +0 -0
  80. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/conftest.py +0 -0
  81. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_audit.py +0 -0
  82. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_bridge_protocol.py +0 -0
  83. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_bridge_server.py +0 -0
  84. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_checkpoint.py +0 -0
  85. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_ci_wait.py +0 -0
  86. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_dev.py +0 -0
  87. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_repos.py +0 -0
  88. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_secops.py +0 -0
  89. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_start.py +0 -0
  90. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_cli_version.py +0 -0
  91. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_config.py +0 -0
  92. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dashboard_client.py +0 -0
  93. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dev_integration.py +0 -0
  94. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dev_pipeline.py +0 -0
  95. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_dispatcher.py +0 -0
  96. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_docs_site.py +0 -0
  97. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_github.py +0 -0
  98. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_install.py +0 -0
  99. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_obs.py +0 -0
  100. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_personalization.py +0 -0
  101. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_pipeline_base.py +0 -0
  102. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_poller.py +0 -0
  103. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_post_merge.py +0 -0
  104. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_pr_verifier.py +0 -0
  105. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_pr_watcher.py +0 -0
  106. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_scheduler.py +0 -0
  107. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_secops_integration.py +0 -0
  108. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_setup.py +0 -0
  109. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_state.py +0 -0
  110. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_task_pipeline.py +0 -0
  111. {ctrlrelay-0.4.1 → ctrlrelay-0.5.0}/tests/test_telegram_handler.py +0 -0
  112. {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.4.1
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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=None,
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. For each alert or PR:
137
- - Review the severity and impact
138
- - If patch/minor update with passing CI, merge the PR
139
- - If major or unclear, signal BLOCKED to ask for guidance
140
- 4. Summarize actions taken
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