ctrlrelay 0.10.0__py3-none-any.whl

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 (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,1018 @@
1
+ """Secops pipeline for security triage across repos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ctrlrelay.core.checkpoint import CheckpointStatus
13
+ from ctrlrelay.core.dispatcher import AgentAdapter, SessionResult
14
+ from ctrlrelay.core.github import GitHubCLI
15
+ from ctrlrelay.core.obs import get_logger, hash_text, log_event
16
+ from ctrlrelay.core.question_expiry import extract_referenced_numbers
17
+ from ctrlrelay.core.state import StateDB
18
+ from ctrlrelay.core.worktree import WorktreeManager
19
+ from ctrlrelay.dashboard.client import DashboardClient, EventPayload
20
+ from ctrlrelay.pipelines.base import PipelineContext, PipelineResult
21
+ from ctrlrelay.transports.base import Transport
22
+
23
+ _logger = get_logger("pipeline.secops")
24
+
25
+ DEFAULT_MAX_BLOCKED_ROUNDS = 5
26
+
27
+ # 30-day rolling window of operator decisions injected into each
28
+ # sweep's prompt. Long enough to cover weekly cadences and a vacation;
29
+ # short enough that a stale "approve" from months ago can't auto-merge
30
+ # a PR whose risk profile may have shifted (new transitive deps,
31
+ # changed maintainer, etc.).
32
+ DECISION_RECALL_SECONDS = 30 * 86400
33
+
34
+ # Shared with the question-expiry sweep, which parses the same "#N"
35
+ # citations out of the same questions to decide whether a question's
36
+ # subject has already been resolved elsewhere. One regex, one meaning.
37
+ _extract_pr_numbers = extract_referenced_numbers
38
+
39
+
40
+ def _record_decisions_from_answer(
41
+ state_db: StateDB,
42
+ *,
43
+ repo: str,
44
+ question: str,
45
+ answer: str,
46
+ session_id: str,
47
+ ) -> None:
48
+ """Best-effort persistence of an operator's verbatim Telegram
49
+ answer against every PR# mentioned in the question. Failure here
50
+ is logged but does not break the pipeline — the answer has
51
+ already been acted on by the agent; losing the persistence layer
52
+ just means the next sweep will re-ask, which is the pre-fix
53
+ status quo."""
54
+ pr_numbers = _extract_pr_numbers(question)
55
+ if not pr_numbers:
56
+ return
57
+ for pr_num in pr_numbers:
58
+ try:
59
+ state_db.record_automation_decision(
60
+ repo=repo,
61
+ operation="dependabot_pr",
62
+ item_id=f"#{pr_num}",
63
+ decision=answer,
64
+ decided_by="operator",
65
+ context=(question or "")[:500],
66
+ )
67
+ except Exception as e:
68
+ log_event(
69
+ _logger,
70
+ "secops.decision.record_failed",
71
+ session_id=session_id,
72
+ repo=repo,
73
+ pr_number=pr_num,
74
+ error_type=type(e).__name__,
75
+ error=str(e)[:200],
76
+ )
77
+
78
+
79
+ def _question_for_persist(session_id: str, result: PipelineResult) -> str:
80
+ """Return a non-empty question string for pending_resumes storage.
81
+
82
+ When a BLOCKED result includes no question text (rare — the prompt
83
+ is supposed to make the agent write one), fall back to a synthesized
84
+ placeholder so the row is still persisted. Without this, the empty
85
+ question branch would silently skip the persist and the session
86
+ would become unresumable via Telegram."""
87
+ q = (result.question or "").strip()
88
+ if q:
89
+ return q
90
+ return (
91
+ f"Session {session_id} is blocked but did not include a "
92
+ "question. Reply with guidance to resume."
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class SecopsPipeline:
98
+ """Security operations pipeline for daily triage."""
99
+
100
+ dispatcher: AgentAdapter
101
+ github: GitHubCLI
102
+ worktree: WorktreeManager
103
+ dashboard: DashboardClient | None
104
+ state_db: StateDB
105
+ transport: Transport | None
106
+
107
+ name: str = "secops"
108
+
109
+ async def run(self, ctx: PipelineContext) -> PipelineResult:
110
+ """Run secops on a single repo."""
111
+ prompt = self._build_prompt(
112
+ ctx.repo,
113
+ session_id=ctx.session_id,
114
+ state_file=ctx.state_file,
115
+ automation=ctx.extra.get("automation"),
116
+ prior_decisions=ctx.extra.get("prior_decisions"),
117
+ )
118
+
119
+ result = await self._spawn(ctx, prompt, resume=False)
120
+ return self._session_to_result(result)
121
+
122
+ async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
123
+ """Resume blocked secops with user answer."""
124
+ prompt = f"User answered: {answer}\n\nContinue from where you left off."
125
+
126
+ # Resume uses Claude's own session UUID (captured on the first spawn
127
+ # and persisted to state_db). Our composite id is not a UUID and will
128
+ # be rejected by `claude --resume` on v2.0.x+.
129
+ resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
130
+
131
+ log_event(
132
+ _logger,
133
+ "dev.session.resumed",
134
+ session_id=ctx.session_id,
135
+ repo=ctx.repo,
136
+ issue_number=ctx.issue_number,
137
+ pipeline=self.name,
138
+ resume_session_id=resume_uuid,
139
+ answer_length=len(answer),
140
+ answer_hash=hash_text(answer),
141
+ )
142
+
143
+ result = await self._spawn(ctx, prompt, resume=True)
144
+ return self._session_to_result(result)
145
+
146
+ async def _spawn(
147
+ self,
148
+ ctx: PipelineContext,
149
+ prompt: str,
150
+ *,
151
+ resume: bool,
152
+ ) -> SessionResult:
153
+ """Centralized dispatcher call: looks up the agent UUID on resume,
154
+ persists any newly-captured UUID to state_db."""
155
+ resume_uuid: str | None = None
156
+ if resume:
157
+ resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
158
+
159
+ result = await self.dispatcher.spawn_session(
160
+ session_id=ctx.session_id,
161
+ prompt=prompt,
162
+ working_dir=ctx.worktree_path,
163
+ state_file=ctx.state_file,
164
+ resume_session_id=resume_uuid,
165
+ repo=ctx.repo,
166
+ issue_number=ctx.issue_number,
167
+ )
168
+
169
+ if result.agent_session_id:
170
+ try:
171
+ self.state_db.set_agent_session_id(
172
+ ctx.session_id, result.agent_session_id
173
+ )
174
+ except Exception:
175
+ pass
176
+
177
+ return result
178
+
179
+ def _build_prompt(
180
+ self,
181
+ repo: str,
182
+ session_id: str = "",
183
+ state_file: Path | None = None,
184
+ automation: Any = None,
185
+ prior_decisions: list[dict[str, Any]] | None = None,
186
+ ) -> str:
187
+ """Build the secops prompt.
188
+
189
+ ``automation`` is an optional ``AutomationConfig`` carrying the
190
+ per-repo Dependabot policy (auto/ask/never per severity tier).
191
+ When provided, the prompt explicitly tells the agent which tiers
192
+ auto-merge vs ask vs never-merge. When ``None``, falls back to
193
+ the default policy (patch=auto, minor=ask, never-major) so
194
+ existing callers and the schema defaults stay coherent.
195
+
196
+ ``prior_decisions`` is a list of recent automation_decisions
197
+ rows for this repo. When present, they're rendered as a
198
+ "Prior operator decisions" block so the agent can act on a
199
+ past answer instead of asking again about the same PR.
200
+ """
201
+ state_file_path = str(state_file) if state_file else "/tmp/state.json"
202
+
203
+ # Translate per-repo policy into prompt directives. Use defaults
204
+ # from AutomationConfig if no policy was passed.
205
+ patch_pol = (
206
+ automation.dependabot_patch.value
207
+ if automation is not None and hasattr(automation, "dependabot_patch")
208
+ else "auto"
209
+ )
210
+ minor_pol = (
211
+ automation.dependabot_minor.value
212
+ if automation is not None and hasattr(automation, "dependabot_minor")
213
+ else "ask"
214
+ )
215
+ major_pol = (
216
+ automation.dependabot_major.value
217
+ if automation is not None and hasattr(automation, "dependabot_major")
218
+ else "never"
219
+ )
220
+
221
+ def _policy_directive(tier: str, policy: str) -> str:
222
+ if policy == "auto":
223
+ return (
224
+ f" - {tier}: AUTO-MERGE with passing CI. Squash + "
225
+ "delete branch."
226
+ )
227
+ if policy == "ask":
228
+ return (
229
+ f" - {tier}: ASK — signal BLOCKED with a one-line "
230
+ f"summary so the operator decides."
231
+ )
232
+ return (
233
+ f" - {tier}: NEVER — do not merge. Leave the PR open "
234
+ "for manual review."
235
+ )
236
+
237
+ dependabot_policy_block = "\n".join(
238
+ [
239
+ _policy_directive("patch updates", patch_pol),
240
+ _policy_directive("minor updates", minor_pol),
241
+ _policy_directive("major updates", major_pol),
242
+ ]
243
+ )
244
+
245
+ # Render the rolling window of prior operator decisions for this
246
+ # repo. The agent reads the verbatim Telegram answer and decides
247
+ # whether the prior call still applies — we don't try to
248
+ # normalize "yes"/"approve"/"merge it" to a canonical decision
249
+ # because that classification is exactly what the agent does
250
+ # best, and getting it wrong silently auto-merges PRs.
251
+ #
252
+ # We render the stored ``context`` (question snippet captured
253
+ # at decision time) alongside item_id + answer so the agent
254
+ # can compare prior bump/CI state with the PR it's looking at
255
+ # right now. Without context, the "only re-ASK if circumstances
256
+ # materially changed" rule below is unenforceable — the agent
257
+ # can't detect a force-pushed PR that swapped the version bump
258
+ # under the same PR number.
259
+ if prior_decisions:
260
+ lines = ["## Prior operator decisions (last 30 days)", ""]
261
+ for d in prior_decisions:
262
+ ts = d.get("decided_at") or 0
263
+ date = (
264
+ time.strftime("%Y-%m-%d", time.gmtime(int(ts)))
265
+ if ts else "unknown-date"
266
+ )
267
+ item = d.get("item_id") or "?"
268
+ ans = (d.get("decision") or "").strip().replace("\n", " ")
269
+ # Cap each answer so a pathological Telegram reply
270
+ # doesn't bloat the prompt past the model's context.
271
+ if len(ans) > 160:
272
+ ans = ans[:157] + "..."
273
+ ctx = (d.get("context") or "").strip().replace("\n", " ")
274
+ # Same cap rationale as answers; the prompt has many
275
+ # entries and at-scale this is the bigger context risk.
276
+ if len(ctx) > 240:
277
+ ctx = ctx[:237] + "..."
278
+ if ctx:
279
+ lines.append(
280
+ f'- {date} {item}: operator said "{ans}" '
281
+ f"(prior question: {ctx})"
282
+ )
283
+ else:
284
+ lines.append(
285
+ f'- {date} {item}: operator said "{ans}"'
286
+ )
287
+ lines.append("")
288
+ lines.append(
289
+ "**Use these to avoid re-asking.** If a Dependabot PR "
290
+ "matches an item above and circumstances are unchanged "
291
+ "(same package, version range still applies, CI state "
292
+ "still the same — compare the current PR against the "
293
+ "prior-question snippet above), act per the prior "
294
+ "decision without signalling BLOCKED. Only re-ASK if "
295
+ "the situation has materially changed (different "
296
+ "version bump, CI flipped red, new conflicts) — and "
297
+ "quote what changed in the question."
298
+ )
299
+ prior_decisions_block = "\n".join(lines) + "\n\n"
300
+ else:
301
+ prior_decisions_block = ""
302
+
303
+ return f"""Execute security operations for repository {repo}.
304
+
305
+ 1. Check Dependabot alerts:
306
+ `gh api repos/{repo}/dependabot/alerts --jq '.[] | select(.state=="open")'`
307
+ 2. Check Dependabot-authored security PRs:
308
+ `gh pr list --repo {repo} --author "app/dependabot" --json number,title`
309
+ 3. Determine the trusted operator's GitHub login (the identity the
310
+ agent itself runs as). The auto-merge carve-out below applies ONLY
311
+ to PRs authored by this exact login — never to other contributors,
312
+ apps, or bots even if their PR looks safe:
313
+ `OPERATOR=$(gh api user --jq '.login')`
314
+ 4. Check open PRs authored by the trusted operator that enable
315
+ Dependabot itself. These are prerequisites for future Dependabot
316
+ work and block the security pipeline if left open:
317
+ `gh pr list --repo {repo} --state open --author "$OPERATOR" --json number,title,files`
318
+ For each, inspect files: `gh pr view <NUM> --repo {repo} --json files`
319
+ 5. For each alert or PR:
320
+ - **Dependabot PRs**: classify the update as patch/minor/major and
321
+ apply the per-repo policy below. If the severity is unclear, signal
322
+ BLOCKED to ask for guidance.
323
+
324
+ {dependabot_policy_block}
325
+
326
+ {prior_decisions_block}**Edge cases that override the policy ladder above:**
327
+
328
+ - **No CI configured** (repo has no `.github/workflows/*.yml`): the
329
+ "passing CI" gate cannot be evaluated, so an "auto" policy cannot
330
+ fire safely. Treat ALL open Dependabot PRs as ASK regardless of
331
+ their tier — signal BLOCKED ONCE for the whole repo with a single
332
+ consolidated question naming every open PR, e.g.: "Repo has no CI;
333
+ approve merging: #N (pkg X -> Y, patch), #M (pkg A -> B, minor)?"
334
+ Do not ask one BLOCKED per PR — operators get one consolidated
335
+ question per repo per sweep.
336
+
337
+ - **CI failing on a check UNRELATED to the PR's own package** (e.g.
338
+ pip-audit fails on a transitive CVE in `idna` while the PR bumps
339
+ `boto3`): do NOT silently report this and move on — that's how
340
+ patch PRs end up stuck for weeks. Signal BLOCKED with a one-line
341
+ question naming the underlying issue: "Patch PRs blocked by
342
+ pre-existing pip-audit failure on idna CVE-2026-... in lockfile.
343
+ Fix idna >= 3.15 first, then re-evaluate #117 and #119?" Include
344
+ the failing check name and a one-line root-cause hint extracted
345
+ from `gh run view --log-failed`.
346
+
347
+ **Scope discipline:**
348
+
349
+ You have exactly three legitimate exits for each open PR:
350
+ 1. Merge it (per policy or per prior decision).
351
+ 2. Leave it open (per policy or per prior decision).
352
+ 3. Signal BLOCKED with a one-line policy question.
353
+
354
+ Do NOT propose alternative actions outside this ladder. In
355
+ particular, do NOT offer to: batch multiple Dependabot PRs into a
356
+ single review PR, open a tracking issue for the operator, perform a
357
+ manual code review of the bump, or any other freelance option. If
358
+ the right action isn't clear from policy + prior decisions + edge
359
+ cases above, ASK with a one-line question — don't invent.
360
+
361
+ Every BLOCKED question must contain ONLY items where the operator's
362
+ answer changes what you do next. Never pad a question with items you
363
+ have already decided — no "confirm no action wanted", no "left open
364
+ per policy" tails, no FYI clauses. Those belong in the step-6
365
+ summary. If nothing in the repo needs a decision, do not signal
366
+ BLOCKED at all: finish DONE and say what you left alone.
367
+
368
+ - **PR authored by `$OPERATOR` touching ONLY `.github/dependabot.yml`**
369
+ (additive ecosystem entries, no other files changed): MAYBE
370
+ auto-merge — but only after diff validation. These are the
371
+ "enable Dependabot" prerequisites the operator opened.
372
+
373
+ Required validation BEFORE merging:
374
+ a. **Author check**: confirm `author.login` exactly equals
375
+ `$OPERATOR`. Collaborators, GitHub apps, or external
376
+ contributors can craft a `dependabot.yml`-only PR to slip
377
+ past review — those go to BLOCKED, not auto-merge.
378
+ b. **Diff check**: pull the diff and confirm it is PURELY
379
+ ADDITIVE — no `-` lines (deletions or modifications of
380
+ existing stanzas), only `+` lines that introduce new
381
+ `package-ecosystem` blocks. Run:
382
+ `gh pr diff <NUM> --repo {repo}`
383
+ If any line in the diff begins with `-` (other than `---`
384
+ file headers), the PR modifies or removes existing config:
385
+ signal BLOCKED with a one-line summary like "operator
386
+ dependabot.yml PR #N modifies existing config — needs
387
+ review". Do NOT auto-merge.
388
+ c. **CI check**: confirm all status checks pass (matches the
389
+ Dependabot PR rule above). Run:
390
+ `gh pr checks <NUM> --repo {repo}`
391
+ Any failing or pending check -> BLOCKED. A PR that adds
392
+ invalid dependabot YAML can pass author+diff but fail
393
+ repo validation; without this gate the agent could merge
394
+ broken config and break Dependabot for the repo.
395
+
396
+ If (a), (b), AND (c) all pass: merge with squash, delete the
397
+ branch. If any one fails: BLOCKED.
398
+ - **Any other PR from `$OPERATOR`** (touches code, tests, configs
399
+ other than dependabot.yml): signal BLOCKED for operator approval.
400
+ Never auto-merge code changes, even from the trusted operator.
401
+ - **PRs from anyone else** (collaborators, contributors, other bots):
402
+ out of scope — see the PR scope fence below. Do not fetch them, do
403
+ not evaluate them, do not name them, do not signal BLOCKED on them.
404
+ 6. Summarize actions taken. Report only what you acted on or decided
405
+ under the queries in steps 1-4. Do not report counts or descriptions
406
+ of PRs outside that scope.
407
+
408
+ **PR scope fence:**
409
+
410
+ The ONLY open PRs in scope are the ones returned by the two author-
411
+ filtered queries above: `--author "app/dependabot"` (step 2) and
412
+ `--author "$OPERATOR"` (step 4). That is the complete universe of PRs
413
+ this pipeline may look at.
414
+
415
+ Never run an unfiltered `gh pr list` (or any other broad PR/issue
416
+ search) to discover PRs beyond those two queries. A third party's open
417
+ PR is not this pipeline's business: it cannot be merged here, it needs
418
+ no decision here, and surfacing it only produces noise. If you catch
419
+ yourself about to write a phrase like "no open PRs at all" or "N open
420
+ PRs total", you have already stepped outside the fence — you can only
421
+ speak to the author-filtered results.
422
+
423
+ ## Signaling Completion
424
+
425
+ **CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
426
+
427
+ STATE_FILE: {state_file_path}
428
+ SESSION_ID: {session_id}
429
+
430
+ **DONE** (completed):
431
+ ```bash
432
+ mkdir -p "$(dirname '{state_file_path}')"
433
+ printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\
434
+ '"timestamp":"%s","summary":"%s"}}' \\
435
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<SUMMARY>" > '{state_file_path}'
436
+ ```
437
+
438
+ **BLOCKED** (need input):
439
+ ```bash
440
+ mkdir -p "$(dirname '{state_file_path}')"
441
+ printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\
442
+ '"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
443
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
444
+ ```
445
+
446
+ **FAILED**:
447
+ ```bash
448
+ mkdir -p "$(dirname '{state_file_path}')"
449
+ printf '{{"version":"1","status":"FAILED","session_id":"{session_id}",'\
450
+ '"timestamp":"%s","error":"%s"}}' \\
451
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
452
+ ```"""
453
+
454
+ def _session_to_result(self, result: SessionResult) -> PipelineResult:
455
+ """Convert SessionResult to PipelineResult."""
456
+ if result.state is None:
457
+ return PipelineResult(
458
+ success=False,
459
+ session_id=result.session_id,
460
+ summary="No checkpoint state returned",
461
+ error=result.stderr or "Unknown error",
462
+ )
463
+
464
+ if result.state.status == CheckpointStatus.DONE:
465
+ return PipelineResult(
466
+ success=True,
467
+ session_id=result.session_id,
468
+ summary=result.state.summary or "Completed",
469
+ outputs=result.state.outputs,
470
+ )
471
+
472
+ if result.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT:
473
+ return PipelineResult(
474
+ success=False,
475
+ session_id=result.session_id,
476
+ summary="Blocked on user input",
477
+ blocked=True,
478
+ question=result.state.question,
479
+ )
480
+
481
+ return PipelineResult(
482
+ success=False,
483
+ session_id=result.session_id,
484
+ summary="Failed",
485
+ error=result.state.error,
486
+ )
487
+
488
+
489
+ async def run_secops_all(
490
+ repos: list[Any],
491
+ dispatcher: AgentAdapter,
492
+ github: GitHubCLI,
493
+ worktree: WorktreeManager,
494
+ dashboard: DashboardClient | None,
495
+ state_db: StateDB,
496
+ transport: Transport | None,
497
+ contexts_dir: Path,
498
+ max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
499
+ ) -> list[PipelineResult]:
500
+ """Run secops pipeline on all configured repos."""
501
+ results = []
502
+
503
+ pipeline = SecopsPipeline(
504
+ dispatcher=dispatcher,
505
+ github=github,
506
+ worktree=worktree,
507
+ dashboard=dashboard,
508
+ state_db=state_db,
509
+ transport=transport,
510
+ )
511
+
512
+ for repo_config in repos:
513
+ repo = repo_config.name
514
+ session_id = f"secops-{repo.replace('/', '-')}-{uuid.uuid4().hex[:8]}"
515
+
516
+ if not state_db.acquire_lock(repo, session_id):
517
+ results.append(PipelineResult(
518
+ success=False,
519
+ session_id=session_id,
520
+ summary=f"Could not acquire lock for {repo}",
521
+ error="Repository locked by another session",
522
+ ))
523
+ continue
524
+
525
+ worktree_path: Path | None = None
526
+ session_row_inserted = False
527
+ session_final_state_written = False
528
+ try:
529
+ await worktree.ensure_bare_repo(repo)
530
+ worktree_path = await worktree.create_worktree(repo, session_id)
531
+
532
+ context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
533
+ if context_path.exists():
534
+ worktree.symlink_context(worktree_path, context_path)
535
+
536
+ state_file = worktree_path / ".ctrlrelay" / "state.json"
537
+ state_file.parent.mkdir(parents=True, exist_ok=True)
538
+
539
+ # Pull the 30-day rolling window of prior operator decisions
540
+ # so the prompt can include them. Best-effort: if the lookup
541
+ # fails for any reason, fall through with an empty list —
542
+ # losing this just means the next sweep re-asks, which is the
543
+ # pre-fix status quo, not a regression.
544
+ try:
545
+ prior_decisions = state_db.list_recent_automation_decisions(
546
+ repo,
547
+ operation="dependabot_pr",
548
+ since_ts=int(time.time()) - DECISION_RECALL_SECONDS,
549
+ )
550
+ except Exception as e:
551
+ log_event(
552
+ _logger,
553
+ "secops.prior_decisions.lookup_failed",
554
+ session_id=session_id,
555
+ repo=repo,
556
+ error_type=type(e).__name__,
557
+ error=str(e)[:200],
558
+ )
559
+ prior_decisions = []
560
+
561
+ ctx = PipelineContext(
562
+ session_id=session_id,
563
+ repo=repo,
564
+ worktree_path=worktree_path,
565
+ context_path=context_path,
566
+ state_file=state_file,
567
+ extra={
568
+ "automation": getattr(repo_config, "automation", None),
569
+ "prior_decisions": prior_decisions,
570
+ },
571
+ )
572
+
573
+ state_db.execute(
574
+ """INSERT INTO sessions (id, pipeline, repo, worktree_path, status, started_at)
575
+ VALUES (?, ?, ?, ?, ?, ?)""",
576
+ (session_id, "secops", repo, str(worktree_path), "running", int(time.time())),
577
+ )
578
+ state_db.commit()
579
+ session_row_inserted = True
580
+
581
+ result = await pipeline.run(ctx)
582
+
583
+ # BLOCKED loop: when the agent ends BLOCKED_NEEDS_INPUT, push
584
+ # the question to the operator via the configured transport
585
+ # (Telegram), wait for an answer, and resume. Mirrors the
586
+ # dev/task pipelines — without this, secops blocked sessions
587
+ # silently land as "blocked" in the DB without ever reaching
588
+ # the operator. transport.ask() failure preserves blocked=True
589
+ # so the persistence-on-blocked branch below still fires and
590
+ # writes a pending_resumes row that a later out-of-band reply
591
+ # could resume via the sweeper.
592
+ rounds = 0
593
+ while (
594
+ result.blocked
595
+ and transport is not None
596
+ and rounds < max_blocked_rounds
597
+ ):
598
+ question = _question_for_persist(session_id, result)
599
+ try:
600
+ answer = await transport.ask(
601
+ question,
602
+ session_id=session_id,
603
+ repo=repo,
604
+ )
605
+ except Exception as e:
606
+ log_event(
607
+ _logger,
608
+ "secops.transport.ask_failed",
609
+ session_id=session_id,
610
+ repo=repo,
611
+ error_type=type(e).__name__,
612
+ error=str(e)[:200],
613
+ )
614
+ result = PipelineResult(
615
+ success=False,
616
+ blocked=True,
617
+ session_id=session_id,
618
+ summary=f"Blocked session deferred (transport): {e}",
619
+ error=str(e),
620
+ question=question,
621
+ outputs=result.outputs,
622
+ )
623
+ break
624
+ _record_decisions_from_answer(
625
+ state_db,
626
+ repo=repo,
627
+ question=question,
628
+ answer=answer,
629
+ session_id=session_id,
630
+ )
631
+ rounds += 1
632
+ result = await pipeline.resume(ctx, answer)
633
+
634
+ results.append(result)
635
+
636
+ status = "done" if result.success else ("blocked" if result.blocked else "failed")
637
+ state_db.execute(
638
+ "UPDATE sessions SET status = ?, summary = ?, ended_at = ? WHERE id = ?",
639
+ (status, result.summary, int(time.time()), session_id),
640
+ )
641
+ state_db.commit()
642
+ session_final_state_written = True
643
+
644
+ # Persist BLOCKED so a Telegram reply arriving after this
645
+ # session tears down can still be routed to a resume. Without
646
+ # this, the bridge's in-memory `_pending_questions` dies with
647
+ # the ASK socket and the operator's answer lands in a void.
648
+ if result.blocked:
649
+ try:
650
+ state_db.add_pending_resume(
651
+ session_id=session_id,
652
+ pipeline="secops",
653
+ repo=repo,
654
+ question=_question_for_persist(session_id, result),
655
+ )
656
+ except Exception as e:
657
+ log_event(
658
+ _logger,
659
+ "secops.pending_resume.insert_failed",
660
+ session_id=session_id,
661
+ repo=repo,
662
+ error_type=type(e).__name__,
663
+ error=str(e)[:200],
664
+ )
665
+
666
+ if dashboard and result.success:
667
+ await dashboard.push_event(EventPayload(
668
+ level="info",
669
+ pipeline="secops",
670
+ repo=repo,
671
+ message=result.summary,
672
+ session_id=session_id,
673
+ ))
674
+
675
+ except asyncio.CancelledError:
676
+ # Scheduled secops interrupted mid-run (SIGTERM during a
677
+ # scheduler.shutdown). Mark the session as cancelled so later
678
+ # inspection doesn't see a phantom "running" row — but ONLY
679
+ # if we hadn't already written a final state. Without this
680
+ # guard, a cancel landing during the post-run dashboard push
681
+ # would clobber a successful "done" status with "cancelled"
682
+ # even though the work completed.
683
+ if session_row_inserted and not session_final_state_written:
684
+ try:
685
+ state_db.execute(
686
+ "UPDATE sessions SET status = ?, summary = ?, "
687
+ "ended_at = ? WHERE id = ?",
688
+ (
689
+ "cancelled",
690
+ "Cancelled during shutdown",
691
+ int(time.time()),
692
+ session_id,
693
+ ),
694
+ )
695
+ state_db.commit()
696
+ except Exception:
697
+ pass
698
+ raise
699
+
700
+ except Exception as e:
701
+ # Never let a repo fail silently. Two independent holes put
702
+ # the cause beyond reach for the operator:
703
+ # 1. No log_event here, so nothing reached poller.log.
704
+ # 2. The DB row is written only when the failure lands
705
+ # AFTER the INSERT — anything raised by ensure_bare_repo
706
+ # or create_worktree (both network-bound, both before
707
+ # it) left no row at all.
708
+ # With an exception whose str() is empty (a bare
709
+ # asyncio.TimeoutError) the Telegram alert then fell back to
710
+ # the generic summary and named only the repo. Log first, so
711
+ # the cause survives regardless of which branch runs next.
712
+ detail = str(e) or type(e).__name__
713
+ # This is the last-resort handler for the whole repo, and
714
+ # every repo after this one in the sweep depends on it
715
+ # returning normally. A raising logger here would abort the
716
+ # remaining repos AND destroy the original exception —
717
+ # trading one repo's failure for the entire pass. Losing the
718
+ # log line is the strictly better failure.
719
+ try:
720
+ log_event(
721
+ _logger,
722
+ "secops.repo.failed",
723
+ session_id=session_id,
724
+ repo=repo,
725
+ session_row_inserted=session_row_inserted,
726
+ error_type=type(e).__name__,
727
+ error=detail[:200],
728
+ )
729
+ except Exception:
730
+ pass
731
+ if session_row_inserted:
732
+ state_db.execute(
733
+ "UPDATE sessions SET status = ?, summary = ?, "
734
+ "ended_at = ? WHERE id = ?",
735
+ ("failed", f"Error: {detail}", int(time.time()), session_id),
736
+ )
737
+ state_db.commit()
738
+ results.append(PipelineResult(
739
+ success=False,
740
+ session_id=session_id,
741
+ summary=f"Error processing {repo}: {type(e).__name__}: {detail}",
742
+ # `str(e)` alone can be empty; the notifier picks
743
+ # `result.error or result.summary`, so an empty error
744
+ # silently degraded the alert to the bare summary.
745
+ error=f"{type(e).__name__}: {detail}",
746
+ ))
747
+
748
+ finally:
749
+ # Hold the per-repo lock THROUGH worktree cleanup so a
750
+ # concurrent dev/secops run can't acquire the same repo and
751
+ # race `git worktree prune` on the shared bare clone.
752
+ # Cleanup is bounded by an asyncio.wait_for timeout so a
753
+ # truly-stuck operation can't pin the lock forever — once
754
+ # the timeout fires we release anyway, preferring "possibly
755
+ # leaked git admin state (recoverable via `git worktree
756
+ # prune`)" over "repo locked across daemon restart".
757
+ #
758
+ # No asyncio.shield here: `_run_git` kills its subprocess
759
+ # synchronously on CancelledError so unshielded cleanup
760
+ # unwinds fast and without leaking a stray git. Previously
761
+ # the shield made the outer task cancel immediately while
762
+ # the inner cleanup kept running untracked — leaking state.
763
+ if worktree_path is not None:
764
+ try:
765
+ worktree.remove_context_symlink(worktree_path)
766
+ except Exception as cleanup_exc:
767
+ log_event(
768
+ _logger,
769
+ "secops.cleanup.symlink_failed",
770
+ session_id=session_id,
771
+ repo=repo,
772
+ error_type=type(cleanup_exc).__name__,
773
+ error=str(cleanup_exc)[:200],
774
+ )
775
+ try:
776
+ # Timeout matches Scheduler's cancel budget (150s) — a
777
+ # full worktree prune can take up to 120s per
778
+ # WorktreeManager._run_git ceiling.
779
+ await asyncio.wait_for(
780
+ worktree.remove_worktree(repo, session_id),
781
+ timeout=130.0,
782
+ )
783
+ except asyncio.TimeoutError:
784
+ log_event(
785
+ _logger,
786
+ "secops.cleanup.worktree_timeout",
787
+ session_id=session_id,
788
+ repo=repo,
789
+ worktree_path=str(worktree_path),
790
+ )
791
+ except asyncio.CancelledError:
792
+ log_event(
793
+ _logger,
794
+ "secops.cleanup.worktree_cancelled_mid_shutdown",
795
+ session_id=session_id,
796
+ repo=repo,
797
+ worktree_path=str(worktree_path),
798
+ )
799
+ # Release the lock before propagating — leaving it
800
+ # held across a daemon restart is worse than the
801
+ # small race with a future scheduled run (the dev
802
+ # handler retries on lock conflict anyway).
803
+ try:
804
+ state_db.release_lock(repo, session_id)
805
+ except Exception:
806
+ pass
807
+ raise
808
+ except Exception as cleanup_exc:
809
+ log_event(
810
+ _logger,
811
+ "secops.cleanup.worktree_failed",
812
+ session_id=session_id,
813
+ repo=repo,
814
+ worktree_path=str(worktree_path),
815
+ error_type=type(cleanup_exc).__name__,
816
+ error=str(cleanup_exc)[:200],
817
+ )
818
+
819
+ # Release the lock AFTER cleanup attempt so new runs don't
820
+ # race the prune. In the cancel path above we released early
821
+ # and re-raised; here we handle the non-cancel paths.
822
+ try:
823
+ state_db.release_lock(repo, session_id)
824
+ except Exception as lock_exc:
825
+ log_event(
826
+ _logger,
827
+ "secops.cleanup.lock_release_failed",
828
+ session_id=session_id,
829
+ repo=repo,
830
+ error_type=type(lock_exc).__name__,
831
+ error=str(lock_exc)[:200],
832
+ )
833
+
834
+ return results
835
+
836
+
837
+ async def resume_secops_from_pending(
838
+ session_id: str,
839
+ repo: str,
840
+ answer: str,
841
+ dispatcher: AgentAdapter,
842
+ github: GitHubCLI,
843
+ worktree: WorktreeManager,
844
+ dashboard: DashboardClient | None,
845
+ state_db: StateDB,
846
+ transport: Transport | None,
847
+ contexts_dir: Path,
848
+ automation: Any = None,
849
+ question: str | None = None,
850
+ ) -> PipelineResult:
851
+ """Resume a BLOCKED secops session using an answer that arrived via
852
+ Telegram after the original session had already torn down.
853
+
854
+ Mirrors ``run_secops_all``'s per-repo lifecycle (lock → worktree →
855
+ resume → cleanup) but targets a single session that's already got a
856
+ row in ``sessions`` (status='blocked') and a ``pending_resumes`` row
857
+ ready for ``mark_pending_resume_resumed``. Caller is responsible for
858
+ flipping the pending_resumes row after this returns.
859
+
860
+ ``question`` is the original BLOCKED question (carried in the
861
+ pending_resumes row). When supplied, the operator answer is
862
+ persisted against any PR# the question mentioned so the next
863
+ sweep skips re-asking. Optional for backwards compat with older
864
+ callers that pre-date the persistence work.
865
+ """
866
+ if question:
867
+ _record_decisions_from_answer(
868
+ state_db,
869
+ repo=repo,
870
+ question=question,
871
+ answer=answer,
872
+ session_id=session_id,
873
+ )
874
+ pipeline = SecopsPipeline(
875
+ dispatcher=dispatcher,
876
+ github=github,
877
+ worktree=worktree,
878
+ dashboard=dashboard,
879
+ state_db=state_db,
880
+ transport=transport,
881
+ )
882
+
883
+ if not state_db.acquire_lock(repo, session_id):
884
+ return PipelineResult(
885
+ success=False,
886
+ session_id=session_id,
887
+ summary=f"Could not acquire lock for {repo} to resume",
888
+ error="Repository locked by another session",
889
+ )
890
+
891
+ worktree_path: Path | None = None
892
+ try:
893
+ await worktree.ensure_bare_repo(repo)
894
+ worktree_path = await worktree.create_worktree(repo, session_id)
895
+
896
+ context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
897
+ if context_path.exists():
898
+ worktree.symlink_context(worktree_path, context_path)
899
+
900
+ state_file = worktree_path / ".ctrlrelay" / "state.json"
901
+ state_file.parent.mkdir(parents=True, exist_ok=True)
902
+
903
+ ctx = PipelineContext(
904
+ session_id=session_id,
905
+ repo=repo,
906
+ worktree_path=worktree_path,
907
+ context_path=context_path,
908
+ state_file=state_file,
909
+ extra={"automation": automation},
910
+ )
911
+
912
+ # Flip the session back to running so an observer sees progress,
913
+ # not a stale 'blocked' row while the resume is in flight.
914
+ state_db.execute(
915
+ "UPDATE sessions SET status = ?, ended_at = NULL WHERE id = ?",
916
+ ("running", session_id),
917
+ )
918
+ state_db.commit()
919
+
920
+ result = await pipeline.resume(ctx, answer)
921
+
922
+ status = "done" if result.success else (
923
+ "blocked" if result.blocked else "failed"
924
+ )
925
+ state_db.execute(
926
+ "UPDATE sessions SET status = ?, summary = ?, ended_at = ? "
927
+ "WHERE id = ?",
928
+ (status, result.summary, int(time.time()), session_id),
929
+ )
930
+ state_db.commit()
931
+
932
+ # If the resume re-blocked (operator answer was ambiguous, agent
933
+ # needs more), refresh the pending_resumes row so a new reply
934
+ # routes to this same session again.
935
+ if result.blocked:
936
+ try:
937
+ state_db.add_pending_resume(
938
+ session_id=session_id,
939
+ pipeline="secops",
940
+ repo=repo,
941
+ question=_question_for_persist(session_id, result),
942
+ )
943
+ except Exception as e:
944
+ log_event(
945
+ _logger,
946
+ "secops.pending_resume.reblock_insert_failed",
947
+ session_id=session_id,
948
+ repo=repo,
949
+ error_type=type(e).__name__,
950
+ error=str(e)[:200],
951
+ )
952
+
953
+ return result
954
+
955
+ except Exception as e:
956
+ state_db.execute(
957
+ "UPDATE sessions SET status = ?, summary = ?, ended_at = ? "
958
+ "WHERE id = ?",
959
+ ("failed", f"Resume error: {e}", int(time.time()), session_id),
960
+ )
961
+ state_db.commit()
962
+ return PipelineResult(
963
+ success=False,
964
+ session_id=session_id,
965
+ summary=f"Error resuming {repo}",
966
+ error=str(e),
967
+ )
968
+
969
+ finally:
970
+ # Same cleanup pattern as run_secops_all: symlink → worktree →
971
+ # lock, bounded timeouts, log-don't-crash.
972
+ if worktree_path is not None:
973
+ try:
974
+ worktree.remove_context_symlink(worktree_path)
975
+ except Exception as cleanup_exc:
976
+ log_event(
977
+ _logger,
978
+ "secops.resume.cleanup.symlink_failed",
979
+ session_id=session_id,
980
+ repo=repo,
981
+ error_type=type(cleanup_exc).__name__,
982
+ error=str(cleanup_exc)[:200],
983
+ )
984
+ try:
985
+ await asyncio.wait_for(
986
+ worktree.remove_worktree(repo, session_id),
987
+ timeout=130.0,
988
+ )
989
+ except asyncio.TimeoutError:
990
+ log_event(
991
+ _logger,
992
+ "secops.resume.cleanup.worktree_timeout",
993
+ session_id=session_id,
994
+ repo=repo,
995
+ worktree_path=str(worktree_path),
996
+ )
997
+ except Exception as cleanup_exc:
998
+ log_event(
999
+ _logger,
1000
+ "secops.resume.cleanup.worktree_failed",
1001
+ session_id=session_id,
1002
+ repo=repo,
1003
+ worktree_path=str(worktree_path),
1004
+ error_type=type(cleanup_exc).__name__,
1005
+ error=str(cleanup_exc)[:200],
1006
+ )
1007
+
1008
+ try:
1009
+ state_db.release_lock(repo, session_id)
1010
+ except Exception as lock_exc:
1011
+ log_event(
1012
+ _logger,
1013
+ "secops.resume.cleanup.lock_release_failed",
1014
+ session_id=session_id,
1015
+ repo=repo,
1016
+ error_type=type(lock_exc).__name__,
1017
+ error=str(lock_exc)[:200],
1018
+ )