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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Secops sweeps now remember operator decisions across days.** The
`automation_decisions` table had a schema but no producer or consumer
— every 6am sweep re-asked about the same Dependabot PRs the operator
had already answered, drowning Telegram. Wired both halves:
1. When the BLOCKED loop in `run_secops_all` (or the out-of-band
`resume_secops_from_pending` sweeper) receives an answer, regex
extracts every PR# from the question and records one row per PR#
keyed on `(repo, "dependabot_pr", "#N")` with the operator's
verbatim answer. PR# regex handles both `PR #60` and bare `#60`
forms, dedupes, and tolerates questions with no PR# (e.g.
CodeQL-only) by writing nothing.
2. Before building each sweep's prompt, `run_secops_all` pulls the
last 30 days of decisions for that repo (namespaced to
`operation="dependabot_pr"` so e.g. CodeQL-suppression decisions
don't leak into the Dependabot prompt) and threads them via
`ctx.extra['prior_decisions']` into `_build_prompt`, which renders
a `## Prior operator decisions (last 30 days)` block listing each
decision verbatim alongside the original question snippet so the
agent can detect a force-pushed PR that swapped the version bump
under the same PR number. The block carries instructions to act
on prior answers unless circumstances have materially changed
(different version bump, CI state flipped). Without this the
persistence layer was dead weight.

### Fixed

- **Secops handles "no CI configured" without inventing options.**
Repos with no `.github/workflows/*` previously confused the agent
into freelance suggestions ("Want me to batch into a review PR?")
because the "auto-merge with passing CI" gate couldn't fire. Prompt
now explicitly directs: treat ALL Dependabot PRs as ASK regardless
of tier, and signal BLOCKED ONCE with a single consolidated question
per repo (not one per PR).
- **Secops escalates pre-existing CI failures instead of stalling PRs.**
When CI is failing on a check unrelated to the PR's package (e.g.
`pip-audit` flagging a transitive CVE in `idna` while the PR bumps
`boto3`), patch PRs were sitting stuck for weeks because the agent
reported the failure and moved on. Prompt now requires the agent to
signal BLOCKED with a one-line question naming the underlying issue
and a root-cause hint from `gh run view --log-failed`.
- **Secops scope discipline.** The prompt now enforces "exactly three
legitimate exits" per open PR (merge / leave / BLOCKED) and
explicitly forbids batching PRs into review PRs, opening tracking
issues, or performing manual reviews. Cuts off the freelance
options observed in production sweeps.

## [0.5.0] - 2026-05-11

Minor release. Three changes on the secops path; together they take the
Expand Down
1 change: 1 addition & 0 deletions src/ctrlrelay/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,7 @@ async def _run_pending_resume_sweeper() -> None:
if sweep_repo_cfg is not None
else None
),
question=row.get("question"),
)
elif pipeline_name == "dev":
# Dev resume needs the repo's branch template
Expand Down
74 changes: 74 additions & 0 deletions src/ctrlrelay/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,80 @@ def mark_pending_resume_resumed(self, session_id: str) -> bool:
self._conn.commit()
return cursor.rowcount > 0

# Automation decisions (operator answers persisted across sweeps)

def record_automation_decision(
self,
*,
repo: str,
operation: str,
item_id: str,
decision: str,
decided_by: str = "operator",
context: str | None = None,
policy: str = "",
) -> None:
"""Record an operator decision so future sweeps can avoid
re-asking the same question.

``operation`` namespaces the decision domain
(e.g. ``"dependabot_pr"`` or ``"codeql_alert"``); ``item_id``
is the specific item the decision applies to (e.g. ``"#60"``
for PR #60). ``decision`` is the operator's verbatim answer —
the agent that reads this back interprets it; we don't try to
normalize to yes/no here because Telegram replies are free-form.
"""
self._conn.execute(
"""INSERT INTO automation_decisions
(repo, operation, policy, item_id, decision,
decided_by, decided_at, context)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(repo, operation, policy, item_id, decision,
decided_by, int(time.time()), context),
)
self._conn.commit()

def list_recent_automation_decisions(
self,
repo: str,
*,
operation: str | None = None,
since_ts: int | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""Recent operator decisions for this repo, newest first.

``operation`` namespaces the read so a Dependabot-PR consumer
only sees ``dependabot_pr`` rows, not e.g. ``codeql_alert``
decisions also tracked in the same repo. Without this filter,
a "suppress this CVE" answer for a CodeQL alert would render
into the Dependabot prompt as a prior-PR decision and the
agent could act on it. Defaults to ``None`` (all operations)
to preserve the original behaviour for callers that want a
full audit view.

``since_ts`` (unix seconds) filters to decisions made
at-or-after that point — used by the secops prompt builder to
inject a 30-day rolling window into the agent's context so the
agent can act on prior decisions instead of asking again.
"""
clauses = ["repo = ?"]
params: list[Any] = [repo]
if operation is not None:
clauses.append("operation = ?")
params.append(operation)
if since_ts is not None:
clauses.append("decided_at >= ?")
params.append(since_ts)
sql = (
"SELECT * FROM automation_decisions WHERE "
+ " AND ".join(clauses)
+ " ORDER BY decided_at DESC LIMIT ?"
)
params.append(limit)
rows = self._conn.execute(sql, tuple(params)).fetchall()
return [dict(row) for row in rows]

# PR watches (durable, cross-restart)

def add_pr_watch(
Expand Down
Loading
Loading