Fix verified P1 findings: denylist bypasses, CAS concurrency, operator CLI

Resolves three execution-proven verifier findings from the scaffold review.
Full suite: 548 passed, 1 skipped (stable across repeated runs); ruff clean.

builders denylist (§3.3.2 #2): scan was +++-only and missed header-only
sections. Now section-driven off `diff --git a/<src> b/<dest>`, catching the 4
proven bypasses — delete of a denied path, mode-change-only, `copy to` a denied
path, out-of-scope delete (regression tests for each).

§3.3.1 compare-and-set concurrency: BEGIN IMMEDIATE moved inside guarded retry;
each CAS now runs on its own connection (shared sqlite3.Connection cannot hold
two transactions, and is unsafe for concurrent use even for reads). connect()
stashes the db path on a Connection subclass so the path is derived by a
thread-safe attribute read, not a PRAGMA on the shared conn; busy_timeout set
before the WAL pragma. Added shared-connection concurrent regression tests
(distinct + same question) — previously raised "transaction within a
transaction".

operator CLI (run-team.py): added the design-named re-deliver and force-resume
verbs (were missing); audit now records the attempt BEFORE the mutation and the
outcome after, so a ledger mutation can never land without a trail; main()
catches OSError instead of leaving an uncaught traceback on audit-write failure.
This commit is contained in:
Adam Moussa 2026-06-17 14:35:51 -04:00
parent 15a416d31a
commit 3c29ce3fdb
6 changed files with 728 additions and 87 deletions

View file

@ -21,6 +21,7 @@ does not serialize a check-and-set.
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -107,6 +108,19 @@ CREATE TABLE IF NOT EXISTS schema_meta (
""".strip() """.strip()
class _Connection(sqlite3.Connection):
"""``sqlite3.Connection`` subclass that can carry its backing file path.
The base ``Connection`` has no ``__dict__``, so a path cannot be stashed on
it. This thin subclass (passed as ``factory=`` to :func:`sqlite3.connect`)
lets :func:`connect` record the db file for a thread-safe attribute lookup by
the compare-and-set, avoiding a ``PRAGMA`` on a connection shared across
threads.
"""
agent_team_db_path: str = ""
def connect(db_path: Path) -> sqlite3.Connection: def connect(db_path: Path) -> sqlite3.Connection:
"""Open ``db_path`` with WAL, foreign keys, and a busy timeout. """Open ``db_path`` with WAL, foreign keys, and a busy timeout.
@ -123,11 +137,23 @@ def connect(db_path: Path) -> sqlite3.Connection:
str(db_path), str(db_path),
isolation_level=None, isolation_level=None,
check_same_thread=False, check_same_thread=False,
factory=_Connection,
) )
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
# Set the busy timeout FIRST so every subsequent statement — including the
# journal-mode pragma below, which briefly needs the write lock — waits for
# the lock instead of failing immediately when another connection is mid
# -write. (Without this, opening a connection under concurrent writers could
# raise "database is locked" before the timeout was ever applied.)
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA foreign_keys=ON")
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") # Record the backing file path so the compare-and-set can derive it via a
# thread-safe attribute read instead of running a PRAGMA on a connection that
# callers share across threads (a sqlite3.Connection is not safe for
# concurrent use — even a read would corrupt its transaction state). Empty
# for an in-memory DB (no file to reopen on a second connection).
conn.agent_team_db_path = "" if str(db_path) == ":memory:" else str(db_path)
return conn return conn
@ -262,27 +288,107 @@ def supersede_question(
) )
# Bounded retry if the write lock is still contended after ``busy_timeout``
# elapses, so transient over-timeout contention does not surface as an error to
# the responder / deadline-timer callers.
_CAS_RETRY_ATTEMPTS: int = 3
_CAS_RETRY_BACKOFF_S: float = 0.05
def _main_db_file(conn: sqlite3.Connection) -> str | None:
"""Return the file backing ``conn``'s ``main`` database, or ``None``.
``None`` signals an in-memory database (no file to reopen on a second
connection). Prefers the path stashed by :func:`connect` — a thread-safe
attribute read, so it is safe even when callers share ``conn`` across
threads. Falls back to ``PRAGMA database_list`` (rows of ``(seq, name,
file)``, indexed positionally to be ``row_factory``-agnostic) only for a
connection not opened via :func:`connect`; such a connection must not be
shared across threads.
"""
stashed = getattr(conn, "agent_team_db_path", None)
if stashed is not None:
return stashed or None
for row in conn.execute("PRAGMA database_list"):
if row[1] == "main":
return row[2] or None
return None
def _compare_and_set( def _compare_and_set(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
sql: str, sql: str,
params: tuple[Any, ...], params: tuple[Any, ...],
) -> bool: ) -> bool:
"""Run a single compare-and-set UPDATE under ``BEGIN IMMEDIATE``. """Run a single compare-and-set UPDATE under ``BEGIN IMMEDIATE`` (§3.3.1).
Returns ``True`` iff exactly one row changed. Takes the write lock up front Returns ``True`` iff exactly one row changed. The check-and-set takes the
so concurrent responders cannot both observe ``status='open'`` (SQLite's write lock up front so concurrent responders cannot both observe
default deferred isolation would not serialize them). ``status='open'`` (SQLite's default deferred isolation would not serialize
them).
**Concurrency safety.** The write runs on a private, short-lived connection
to the same database file — never on the passed ``conn``. A single SQLite
connection cannot hold two explicit transactions at once, so if a caller
shares one ``conn`` across threads (the responder and resume worker do, and
``connect()`` sets ``check_same_thread=False``), two concurrent
``BEGIN IMMEDIATE`` statements on it would raise "cannot start a transaction
within a transaction". Giving each call its own connection makes the
compare-and-set safe under that sharing; WAL serializes the writers via the
busy handler. A lock that outlasts ``busy_timeout`` is retried a bounded
number of times before propagating. ``BEGIN IMMEDIATE`` runs inside the
guarded path so its lock error is caught and retried, not raised uncaught.
For an in-memory database (no file to reopen) the call falls back to the
passed ``conn``; in-memory DBs are single-connection and not the concurrent
production path.
"""
db_file = _main_db_file(conn)
if db_file is None:
return _cas_once(conn, sql, params)
last_err: sqlite3.OperationalError | None = None
for attempt in range(_CAS_RETRY_ATTEMPTS):
write = connect(Path(db_file))
try:
return _cas_once(write, sql, params)
except sqlite3.OperationalError as err:
if "locked" not in str(err).lower():
raise
last_err = err
finally:
write.close()
time.sleep(_CAS_RETRY_BACKOFF_S * (attempt + 1))
assert last_err is not None # loop only exits early via return or raise
raise last_err
def _cas_once(
conn: sqlite3.Connection,
sql: str,
params: tuple[Any, ...],
) -> bool:
"""Execute one ``BEGIN IMMEDIATE`` compare-and-set on ``conn``.
``BEGIN IMMEDIATE`` is issued before the try so a lock-acquisition error
propagates to the caller's retry loop with no transaction to unwind; once
the transaction is open, any failure rolls it back (best-effort) and
re-raises.
""" """
conn.execute("BEGIN IMMEDIATE") conn.execute("BEGIN IMMEDIATE")
try: try:
cur = conn.execute(sql, params) cur = conn.execute(sql, params)
changed = cur.rowcount == 1 changed = cur.rowcount == 1
conn.execute("COMMIT") conn.execute("COMMIT")
return changed
except BaseException: except BaseException:
conn.execute("ROLLBACK") try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
pass
raise raise
return changed
def _utc_now_iso() -> str: def _utc_now_iso() -> str:

View file

@ -202,14 +202,20 @@ def _render_build_prompt(plan: Mapping[str, Any]) -> str:
# Diff parsing + canonicalization # Diff parsing + canonicalization
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Matches the "+++ b/<path>" (and "--- a/<path>") target lines of a unified # A unified diff is parsed **per file section**, each section delimited by its
# diff, plus git "rename to"/"rename from" lines. We read targets from the # ``diff --git a/<src> b/<dest>`` header. That header carries BOTH the source and
# header so the scan sees exactly the paths the patch would create/modify. # destination path for every change kind — modify, add, delete, mode-change,
# rename, and copy — so reading it (not only the ``+++ b/`` body line) is what
# lets the scan see deletes, mode-only changes, and ``copy to`` targets that have
# no ``+++`` line or whose ``+++`` is ``/dev/null``. The ``---``/``+++`` and
# rename/copy ``from``/``to`` lines refine the section's source/dest when present.
_PLUS_RE = re.compile(r"^\+\+\+ (?:b/)?(.+?)\s*$") _PLUS_RE = re.compile(r"^\+\+\+ (?:b/)?(.+?)\s*$")
_MINUS_RE = re.compile(r"^--- (?:a/)?(.+?)\s*$") _MINUS_RE = re.compile(r"^--- (?:a/)?(.+?)\s*$")
_DIFF_GIT_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)\s*$") _DIFF_GIT_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)\s*$")
_RENAME_FROM_RE = re.compile(r"^rename from (.+?)\s*$") _RENAME_FROM_RE = re.compile(r"^rename from (.+?)\s*$")
_RENAME_TO_RE = re.compile(r"^rename to (.+?)\s*$") _RENAME_TO_RE = re.compile(r"^rename to (.+?)\s*$")
_COPY_FROM_RE = re.compile(r"^copy from (.+?)\s*$")
_COPY_TO_RE = re.compile(r"^copy to (.+?)\s*$")
# /dev/null appears as the source of an add or target of a delete; it is never a # /dev/null appears as the source of an add or target of a delete; it is never a
# real repo path and must not be scanned/scoped as one. # real repo path and must not be scanned/scoped as one.
@ -257,58 +263,106 @@ def _canonicalize(raw: str) -> str | None:
def iter_diff_target_paths(diff: str) -> list[_DiffTarget]: def iter_diff_target_paths(diff: str) -> list[_DiffTarget]:
"""Parse a unified diff into the set of target paths it would write. """Parse a unified diff into every path it would create/modify/move/remove.
Reads the ``+++ b/<path>`` header lines (the destinations a patch creates or The diff is walked **per file section**, each delimited by its ``diff --git
modifies) and git ``rename to`` lines (carrying the matching ``rename a/<src> b/<dest>`` header. Because that header carries the source and the
from``), canonicalizing each. A path that fails :func:`_canonicalize` is destination for *every* change kind — including deletes (``+++ /dev/null``),
surfaced as an unsafe target (path preserved raw, marked via a sentinel) so mode-only changes (no ``+++`` line at all), and ``copy to`` targets — reading
the scan rejects it. Pure header parsing — it never executes the diff. it closes the bypasses that a ``+++``-only scan misses. Within a section the
``--- a/`` / ``+++ b/`` lines and the rename/copy ``from``/``to`` lines refine
the source/destination when present (a rename/copy ``to`` is authoritative for
the destination and links it to its source). BOTH the section source and
destination are emitted as targets, so removing or moving a file *away from* a
denied/out-of-scope path is flagged too. A path that fails
:func:`_canonicalize` is surfaced as an unsafe target. Pure header parsing —
it never executes the diff.
""" """
targets: list[_DiffTarget] = [] targets: list[_DiffTarget] = []
seen: set[tuple[str, str | None]] = set() seen: set[tuple[str, str | None]] = set()
pending_rename_from: str | None = None
# Per-section accumulators; flushed at each new ``diff --git`` and at EOF.
src_raw: str | None = None
dst_raw: str | None = None
move_from: str | None = None
def flush() -> None:
nonlocal src_raw, dst_raw, move_from
if src_raw is None and dst_raw is None:
return
rf = _canonicalize(move_from) if move_from else None
# Source side: catches deletes and renames/copies away from a denied or
# out-of-scope path. Destination side: catches creates/modifies/mode
# changes/copy targets, linked to its source for a clear violation note.
_emit_target(targets, seen, src_raw)
_emit_target(targets, seen, dst_raw, rename_from=rf)
src_raw = dst_raw = move_from = None
for line in diff.splitlines(): for line in diff.splitlines():
rename_from = _RENAME_FROM_RE.match(line) git = _DIFF_GIT_RE.match(line)
if rename_from: if git:
pending_rename_from = _canonicalize(rename_from.group(1)) flush()
src_raw, dst_raw = git.group(1), git.group(2)
continue continue
rename_to = _RENAME_TO_RE.match(line) for pattern in (_RENAME_TO_RE, _COPY_TO_RE):
if rename_to: m = pattern.match(line)
canon = _canonicalize(rename_to.group(1)) if m:
target = _DiffTarget( dst_raw = m.group(1) # authoritative destination for the section
path=canon if canon is not None else rename_to.group(1).strip(), break
rename_from=pending_rename_from, else:
) for pattern in (_RENAME_FROM_RE, _COPY_FROM_RE):
_append_unique(targets, seen, target, unsafe=canon is None) m = pattern.match(line)
pending_rename_from = None if m:
continue move_from = m.group(1)
break
plus = _PLUS_RE.match(line)
if plus:
# Ignore hunk body lines that merely start with "+++"; a real header
# is "+++ b/path" or "+++ /dev/null". _canonicalize maps /dev/null
# to None which we drop (a delete has no created target).
raw = plus.group(1)
if raw.strip() == _DEV_NULL:
continue
canon = _canonicalize(raw)
if canon is None:
# Unsafe (absolute / parent-escaping) target line.
_append_unique(
targets,
seen,
_DiffTarget(path=raw.strip()),
unsafe=True,
)
else: else:
_append_unique(targets, seen, _DiffTarget(path=canon), unsafe=False) minus = _MINUS_RE.match(line)
if minus and minus.group(1).strip() != _DEV_NULL:
src_raw = minus.group(1)
continue
plus = _PLUS_RE.match(line)
# Ignore hunk body lines starting with "+++"; a real header is
# "+++ b/path" or "+++ /dev/null". A /dev/null target means a
# delete, so the destination stays the diff --git path.
if plus and plus.group(1).strip() != _DEV_NULL:
dst_raw = plus.group(1)
flush()
return targets return targets
def _emit_target(
targets: list[_DiffTarget],
seen: set[tuple[str, str | None]],
raw: str | None,
*,
rename_from: str | None = None,
) -> None:
"""Canonicalize ``raw`` and append it as a target (unsafe paths flagged).
``/dev/null`` and empty values are dropped (no real path). A path that fails
:func:`_canonicalize` (absolute / parent-escaping) is appended as an unsafe
target so the scan rejects it rather than letting an indirection bypass the
denylist.
"""
if raw is None:
return
stripped = raw.strip()
if not stripped or stripped == _DEV_NULL:
return
canon = _canonicalize(stripped)
if canon is None:
_append_unique(targets, seen, _DiffTarget(path=stripped), unsafe=True)
else:
_append_unique(
targets,
seen,
_DiffTarget(path=canon, rename_from=rename_from),
unsafe=False,
)
# Marks a target whose path could not be safely canonicalized. Stored on the # Marks a target whose path could not be safely canonicalized. Stored on the
# _DiffTarget via a parallel set keyed by identity is overkill; instead we use a # _DiffTarget via a parallel set keyed by identity is overkill; instead we use a
# reserved reason string the scanner recognizes. # reserved reason string the scanner recognizes.

View file

@ -98,7 +98,13 @@ _QUESTION_COLUMNS: tuple[str, ...] = (
) )
# Destructive subcommands that require ``--confirm`` and are audit-logged. # Destructive subcommands that require ``--confirm`` and are audit-logged.
_DESTRUCTIVE_ACTIONS: frozenset[str] = frozenset({"expire", "answer", "supersede"}) # ``force-resume`` is the design-named operator verb (§3.3.1/§6.6); ``supersede``
# is kept as its lower-level alias. ``redeliver`` is NOT here — it is idempotent
# and non-destructive (it only clears a delivery ref), though it is still
# audit-logged for provenance.
_DESTRUCTIVE_ACTIONS: frozenset[str] = frozenset(
{"expire", "answer", "supersede", "force-resume"}
)
def _utc_now_iso() -> str: def _utc_now_iso() -> str:
@ -131,6 +137,64 @@ def _append_audit(audit_log: Path, entry: dict[str, Any]) -> None:
atomic_write(audit_log, existing + line) atomic_write(audit_log, existing + line)
def _audit_attempt(
audit_log: Path,
action: str,
*,
question_id: str,
operator: str,
detail: dict[str, Any] | None = None,
) -> None:
"""Record the *intent* to perform a destructive action BEFORE it mutates.
§3.3.1 requires every destructive action to be audit-logged. Writing the
attempt before the ledger mutation closes the "mutation applied with no
audit record" gap: if this append fails (e.g. an unwritable audit path) it
raises before any ledger row is touched, so the action aborts cleanly with
nothing changed. The matching :func:`_audit_outcome` records what happened.
"""
_append_audit(
audit_log,
{
"ts": _utc_now_iso(),
"action": action,
"phase": "attempt",
"question_id": question_id,
"operator": operator,
**(detail or {}),
},
)
def _audit_outcome(
audit_log: Path,
action: str,
*,
question_id: str,
operator: str,
applied: bool,
detail: dict[str, Any] | None = None,
) -> None:
"""Record the *result* of a destructive action AFTER it ran.
Carries ``applied`` (did the compare-and-set change a row). Pairs with the
:func:`_audit_attempt` record written before the mutation, so even if this
outcome append fails the attempt already proves the action was made.
"""
_append_audit(
audit_log,
{
"ts": _utc_now_iso(),
"action": action,
"phase": "outcome",
"question_id": question_id,
"operator": operator,
"applied": applied,
**(detail or {}),
},
)
def _require_confirm(action: str, *, confirm: bool) -> None: def _require_confirm(action: str, *, confirm: bool) -> None:
"""Raise unless a destructive ``action`` was explicitly confirmed. """Raise unless a destructive ``action`` was explicitly confirmed.
@ -216,22 +280,26 @@ def _cmd_show(args: argparse.Namespace, *, out: Any) -> int:
def _cmd_expire(args: argparse.Namespace, *, out: Any) -> int: def _cmd_expire(args: argparse.Namespace, *, out: Any) -> int:
"""Force-expire an ``open`` question (destructive; audit-logged).""" """Force-expire an ``open`` question (destructive; audit-logged).
Audits the attempt BEFORE mutating so a mutation can never land without a
trail (§3.3.1); records the outcome after.
"""
_require_confirm("expire", confirm=args.confirm) _require_confirm("expire", confirm=args.confirm)
_audit_attempt(
args.audit_log, "expire", question_id=args.question_id, operator=args.operator
)
conn = connect(args.db) conn = connect(args.db)
try: try:
changed = expire_question(conn, question_id=args.question_id) changed = expire_question(conn, question_id=args.question_id)
finally: finally:
conn.close() conn.close()
_append_audit( _audit_outcome(
args.audit_log, args.audit_log,
{ "expire",
"ts": _utc_now_iso(), question_id=args.question_id,
"action": "expire", operator=args.operator,
"question_id": args.question_id, applied=changed,
"operator": args.operator,
"applied": changed,
},
) )
if not changed: if not changed:
print( print(
@ -244,6 +312,65 @@ def _cmd_expire(args: argparse.Namespace, *, out: Any) -> int:
return 0 return 0
def _cmd_redeliver(args: argparse.Namespace, *, out: Any) -> int:
"""Clear an ``open`` question's ``channel_ref`` so it is re-posted (§3.3.1).
The design's "re-deliver" operator action. Re-delivery itself is performed
by the transport reconcile loop; clearing ``channel_ref`` makes that loop
re-post and record a fresh ref. Idempotent and non-destructive (the question
stays ``open``), so it needs no ``--confirm`` — but it is audit-logged for
provenance. Returns ``1`` if the question is absent or not ``open``.
"""
_audit_attempt(
args.audit_log,
"redeliver",
question_id=args.question_id,
operator=args.operator,
)
conn = connect(args.db)
try:
row = _fetch_question(conn, args.question_id)
if row is None:
applied = False
prior_ref = None
status = None
elif row["status"] != "open":
applied = False
prior_ref = row["channel_ref"]
status = row["status"]
else:
prior_ref = row["channel_ref"]
status = "open"
conn.execute(
"UPDATE pending_questions SET channel_ref=NULL WHERE question_id=?",
(args.question_id,),
)
applied = True
finally:
conn.close()
_audit_outcome(
args.audit_log,
"redeliver",
question_id=args.question_id,
operator=args.operator,
applied=applied,
detail={"prior_channel_ref": prior_ref},
)
if not applied:
reason = "absent" if status is None else f"status={status}, not open"
print(
f"redeliver no-op: question {args.question_id} ({reason}); "
"nothing to re-post",
file=sys.stderr,
)
return 1
print(
f"cleared channel_ref for {args.question_id}; reconcile loop will re-post",
file=out,
)
return 0
def _cmd_answer(args: argparse.Namespace, *, out: Any) -> int: def _cmd_answer(args: argparse.Namespace, *, out: Any) -> int:
"""Answer a question on a task's behalf (destructive; audit-logged). """Answer a question on a task's behalf (destructive; audit-logged).
@ -254,6 +381,13 @@ def _cmd_answer(args: argparse.Namespace, *, out: Any) -> int:
""" """
_require_confirm("answer", confirm=args.confirm) _require_confirm("answer", confirm=args.confirm)
via = args.via or f"cli:{args.operator}" via = args.via or f"cli:{args.operator}"
_audit_attempt(
args.audit_log,
"answer",
question_id=args.question_id,
operator=args.operator,
detail={"answered_via": via},
)
conn = connect(args.db) conn = connect(args.db)
try: try:
changed = answer_question( changed = answer_question(
@ -264,16 +398,13 @@ def _cmd_answer(args: argparse.Namespace, *, out: Any) -> int:
) )
finally: finally:
conn.close() conn.close()
_append_audit( _audit_outcome(
args.audit_log, args.audit_log,
{ "answer",
"ts": _utc_now_iso(), question_id=args.question_id,
"action": "answer", operator=args.operator,
"question_id": args.question_id, applied=changed,
"operator": args.operator, detail={"answered_via": via},
"answered_via": via,
"applied": changed,
},
) )
if not changed: if not changed:
print( print(
@ -288,22 +419,68 @@ def _cmd_answer(args: argparse.Namespace, *, out: Any) -> int:
def _cmd_supersede(args: argparse.Namespace, *, out: Any) -> int: def _cmd_supersede(args: argparse.Namespace, *, out: Any) -> int:
"""Mark a stale question ``superseded`` (destructive; audit-logged).""" """Mark a stale question ``superseded`` (destructive; audit-logged)."""
_require_confirm("supersede", confirm=args.confirm) return _supersede_impl(args, out=out, action="supersede")
def _cmd_force_resume(args: argparse.Namespace, *, out: Any) -> int:
"""Force-resume a parked task's question (destructive; audit-logged).
The design-named operator verb (§3.3.1 "force-resume" / §6.6 "an operator
can force-resume ... a parked task via the CLI"). The resume worker proper
runs in a later phase; here we mark the stale question ``superseded`` via the
committed turn-guard helper so a redelivered/stale resume job is skipped
idempotently, and record the resume intent durably in the audit trail for
the worker's next sweep to consume.
"""
return _supersede_impl(
args, out=out, action="force-resume", detail={"resume_requested": True}
)
def _supersede_impl(
args: argparse.Namespace,
*,
out: Any,
action: str,
detail: dict[str, Any] | None = None,
) -> int:
"""Shared body for ``supersede`` and ``force-resume``.
Both flip a stale ``open``/``answered`` question to ``superseded`` via the
committed compare-and-set; ``force-resume`` additionally records resume
intent. Audits the attempt before mutating and the outcome after.
"""
_require_confirm(action, confirm=args.confirm)
_audit_attempt(
args.audit_log,
action,
question_id=args.question_id,
operator=args.operator,
detail=detail,
)
conn = connect(args.db) conn = connect(args.db)
try: try:
changed = supersede_question(conn, question_id=args.question_id) changed = supersede_question(conn, question_id=args.question_id)
finally: finally:
conn.close() conn.close()
_append_audit( _audit_outcome(
args.audit_log, args.audit_log,
{ action,
"ts": _utc_now_iso(), question_id=args.question_id,
"action": "supersede", operator=args.operator,
"question_id": args.question_id, applied=changed,
"operator": args.operator, detail=detail,
"applied": changed,
},
) )
if action == "force-resume":
# Force-resume succeeds as an operator intent even if there was no stale
# row to supersede (the task may already be past the question); the
# intent is recorded for the resume worker either way.
print(
f"recorded force-resume intent for {args.question_id}"
+ ("" if changed else " (no open/answered question to supersede)"),
file=out,
)
return 0
if not changed: if not changed:
print( print(
f"supersede no-op: question {args.question_id} was not " f"supersede no-op: question {args.question_id} was not "
@ -382,6 +559,13 @@ def build_parser() -> argparse.ArgumentParser:
p_show.add_argument("question_id", help="the question_id to show") p_show.add_argument("question_id", help="the question_id to show")
p_show.set_defaults(func=_cmd_show) p_show.set_defaults(func=_cmd_show)
p_redeliver = sub.add_parser(
"redeliver",
help="clear an open question's channel_ref so it is re-posted",
)
p_redeliver.add_argument("question_id", help="the question_id to re-deliver")
p_redeliver.set_defaults(func=_cmd_redeliver)
p_expire = sub.add_parser( p_expire = sub.add_parser(
"expire", help="force-expire an open question (destructive)" "expire", help="force-expire an open question (destructive)"
) )
@ -425,6 +609,18 @@ def build_parser() -> argparse.ArgumentParser:
) )
p_supersede.set_defaults(func=_cmd_supersede) p_supersede.set_defaults(func=_cmd_supersede)
p_resume = sub.add_parser(
"force-resume",
help="force-resume a parked task's question (destructive)",
)
p_resume.add_argument("question_id", help="the question_id to force-resume")
p_resume.add_argument(
"--confirm",
action="store_true",
help="required: confirm this destructive, audit-logged action",
)
p_resume.set_defaults(func=_cmd_force_resume)
return parser return parser
@ -442,8 +638,16 @@ def main(argv: Sequence[str] | None = None, *, out: Any = None) -> int:
try: try:
return int(args.func(args, out=out)) return int(args.func(args, out=out))
except PermissionError as exc: except PermissionError as exc:
# A refused destructive action (no --confirm) or an unwritable audit
# path. The attempt-before-mutate ordering means nothing was mutated.
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 1 return 1
except OSError as exc:
# Any other audit-log / filesystem failure (e.g. the audit append could
# not be written). Surfaced cleanly instead of as an uncaught traceback;
# if the attempt record was written, the action is on the trail.
print(f"error: audit/IO failure: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover

View file

@ -236,6 +236,70 @@ def test_rename_into_out_of_scope_is_flagged() -> None:
assert v[0].rename_from == "src/app.py" assert v[0].rename_from == "src/app.py"
# --------------------------------------------------------------------------- #
# Deletes / mode-changes / copies cannot bypass the scan
# (regression: header-only sections carry their path in ``diff --git``, not the
# ``+++ b/`` body line, so a ``+++``-only scan missed all four of these)
# --------------------------------------------------------------------------- #
def test_delete_of_denied_path_is_flagged() -> None:
diff = (
"diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n"
"deleted file mode 100644\n"
"--- a/.github/workflows/ci.yml\n"
"+++ /dev/null\n"
"@@ -1 +0,0 @@\n"
"-on: push\n"
)
v = scan_trust_control_surface(diff, scope=["src", ".github"])
assert len(v) == 1
assert v[0].path == ".github/workflows/ci.yml"
assert "workflow" in v[0].reason
def test_mode_change_only_on_denied_path_is_flagged() -> None:
# A chmod with no +++ line at all — only the diff --git header exists.
diff = (
"diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml\n"
"old mode 100644\n"
"new mode 100755\n"
)
v = scan_trust_control_surface(diff, scope=["src", ".github"])
assert len(v) == 1
assert v[0].path == ".github/workflows/ci.yml"
assert "workflow" in v[0].reason
def test_copy_into_denied_path_is_flagged() -> None:
# git ``copy to`` (not ``rename to``) must not launder a forbidden dest.
diff = (
"diff --git a/src/x.py b/.github/workflows/evil.yml\n"
"similarity index 100%\n"
"copy from src/x.py\n"
"copy to .github/workflows/evil.yml\n"
)
v = scan_trust_control_surface(diff, scope=["src", ".github"])
assert [x.path for x in v] == [".github/workflows/evil.yml"]
assert v[0].rename_from == "src/x.py"
assert "workflow" in v[0].reason
def test_out_of_scope_delete_is_flagged() -> None:
diff = (
"diff --git a/secret/key.py b/secret/key.py\n"
"deleted file mode 100644\n"
"--- a/secret/key.py\n"
"+++ /dev/null\n"
"@@ -1 +0,0 @@\n"
"-KEY = 1\n"
)
v = scan_trust_control_surface(diff, scope=["src"])
assert len(v) == 1
assert v[0].path == "secret/key.py"
assert "scope" in v[0].reason
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Unsafe paths (absolute / parent-escaping / indirection) # Unsafe paths (absolute / parent-escaping / indirection)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View file

@ -286,12 +286,17 @@ def test_expire_with_confirm_flips_status_and_audits(
assert code == 0 assert code == 0
assert _status_of(db_path, "q1") == "expired" assert _status_of(db_path, "q1") == "expired"
entries = [json.loads(line) for line in audit_log.read_text().splitlines()] entries = [json.loads(line) for line in audit_log.read_text().splitlines()]
assert len(entries) == 1 # Attempt is recorded BEFORE the mutation, outcome after, so a mutation can
assert entries[0]["action"] == "expire" # never land without a trail (§3.3.1).
assert entries[0]["question_id"] == "q1" assert len(entries) == 2
assert entries[0]["operator"] == "adam" assert entries[0]["phase"] == "attempt"
assert entries[0]["applied"] is True assert "applied" not in entries[0]
assert "ts" in entries[0] assert entries[-1]["phase"] == "outcome"
assert entries[-1]["action"] == "expire"
assert entries[-1]["question_id"] == "q1"
assert entries[-1]["operator"] == "adam"
assert entries[-1]["applied"] is True
assert "ts" in entries[-1]
def test_answer_with_confirm_flips_status_records_via( def test_answer_with_confirm_flips_status_records_via(
@ -426,10 +431,13 @@ def test_audit_log_appends_across_actions(
_insert_question(db_path, question_id="q2", status="open") _insert_question(db_path, question_id="q2", status="open")
_run(cli, db_path, audit_log, "expire", "q1", "--confirm") _run(cli, db_path, audit_log, "expire", "q1", "--confirm")
_run(cli, db_path, audit_log, "answer", "q2", "--answer", "y", "--confirm") _run(cli, db_path, audit_log, "answer", "q2", "--answer", "y", "--confirm")
lines = audit_log.read_text().splitlines() entries = [json.loads(line) for line in audit_log.read_text().splitlines()]
assert len(lines) == 2 # Each destructive action writes an attempt + an outcome record (append-only).
actions = [json.loads(line)["action"] for line in lines] assert len(entries) == 4
assert actions == ["expire", "answer"] actions = [e["action"] for e in entries]
assert actions == ["expire", "expire", "answer", "answer"]
outcomes = [e["action"] for e in entries if e["phase"] == "outcome"]
assert outcomes == ["expire", "answer"]
def test_audit_entries_are_valid_json_lines( def test_audit_entries_are_valid_json_lines(
@ -469,3 +477,115 @@ def test_answer_requires_answer_flag(cli: ModuleType) -> None:
def test_parked_states_derived_from_foundation(cli: ModuleType) -> None: def test_parked_states_derived_from_foundation(cli: ModuleType) -> None:
# The parked-context states are exactly the non-open foundation states. # The parked-context states are exactly the non-open foundation states.
assert set(cli._PARKED_STATES) == set(QUESTION_STATES) - {"open"} assert set(cli._PARKED_STATES) == set(QUESTION_STATES) - {"open"}
# --------------------------------------------------------------------------- #
# re-deliver + force-resume (design-named operator verbs, §3.3.1 / §6.6)
# --------------------------------------------------------------------------- #
def _set_channel_ref(db_path: Path, question_id: str, ref: str) -> None:
conn = connect(db_path)
try:
conn.execute(
"UPDATE pending_questions SET channel_ref = ? WHERE question_id = ?",
(ref, question_id),
)
finally:
conn.close()
def _channel_ref_of(db_path: Path, question_id: str) -> str | None:
conn = connect(db_path)
try:
row = conn.execute(
"SELECT channel_ref FROM pending_questions WHERE question_id = ?",
(question_id,),
).fetchone()
finally:
conn.close()
return None if row is None else row["channel_ref"]
def test_redeliver_clears_channel_ref_and_audits(
cli: ModuleType, db_path: Path, audit_log: Path
) -> None:
_insert_question(db_path, question_id="q1", status="open")
_set_channel_ref(db_path, "q1", "slack:123.456")
code, out = _run(cli, db_path, audit_log, "redeliver", "q1")
assert code == 0
assert _channel_ref_of(db_path, "q1") is None
entries = [json.loads(line) for line in audit_log.read_text().splitlines()]
# Non-destructive but audited: attempt + outcome, no --confirm needed.
assert [e["phase"] for e in entries] == ["attempt", "outcome"]
assert entries[-1]["action"] == "redeliver"
assert entries[-1]["applied"] is True
assert entries[-1]["prior_channel_ref"] == "slack:123.456"
def test_redeliver_needs_no_confirm(
cli: ModuleType, db_path: Path, audit_log: Path
) -> None:
# redeliver is not in the destructive set, so it runs without --confirm.
assert "redeliver" not in cli._DESTRUCTIVE_ACTIONS
def test_redeliver_non_open_is_noop_returns_1(
cli: ModuleType, db_path: Path, audit_log: Path
) -> None:
_insert_question(db_path, question_id="q1", status="answered")
code, _ = _run(cli, db_path, audit_log, "redeliver", "q1")
assert code == 1
entries = [json.loads(line) for line in audit_log.read_text().splitlines()]
assert entries[-1]["applied"] is False
def test_force_resume_supersedes_and_records_intent(
cli: ModuleType, db_path: Path, audit_log: Path
) -> None:
_insert_question(db_path, question_id="q1", status="open")
code, out = _run(
cli, db_path, audit_log, "--operator", "adam", "force-resume", "q1", "--confirm"
)
assert code == 0
assert _status_of(db_path, "q1") == "superseded"
entries = [json.loads(line) for line in audit_log.read_text().splitlines()]
assert [e["phase"] for e in entries] == ["attempt", "outcome"]
assert entries[-1]["action"] == "force-resume"
assert entries[-1]["resume_requested"] is True
assert entries[-1]["operator"] == "adam"
def test_force_resume_without_confirm_refuses(
cli: ModuleType, db_path: Path, audit_log: Path
) -> None:
_insert_question(db_path, question_id="q1", status="open")
code, _ = _run(cli, db_path, audit_log, "force-resume", "q1")
assert code == 1
assert _status_of(db_path, "q1") == "open" # unmutated
assert not audit_log.exists() # refused before any audit (confirm-check first)
def test_force_resume_is_in_destructive_set(cli: ModuleType) -> None:
assert "force-resume" in cli._DESTRUCTIVE_ACTIONS
# --------------------------------------------------------------------------- #
# Audit-before-mutate: an unwritable audit path aborts BEFORE the ledger mutates
# (regression: previously the row was mutated, then the audit append crashed,
# leaving a mutation with no record and an uncaught traceback)
# --------------------------------------------------------------------------- #
def test_unwritable_audit_path_aborts_before_mutation(
cli: ModuleType, db_path: Path, tmp_path: Path
) -> None:
_insert_question(db_path, question_id="q1", status="open")
# Point the audit log at a path whose parent is a FILE, so the atomic write
# of the attempt record fails with OSError before the mutation runs.
blocker = tmp_path / "not-a-dir"
blocker.write_text("x")
bad_audit = blocker / "audit.jsonl"
code, _ = _run(cli, db_path, bad_audit, "expire", "q1", "--confirm")
assert code == 1 # clean failure, not an uncaught traceback
assert _status_of(db_path, "q1") == "open" # NOT mutated — no trail, no change

View file

@ -226,3 +226,96 @@ def test_concurrent_answers_single_winner(tmp_path: Path) -> None:
t.join() t.join()
assert sorted(results) == [False, True] assert sorted(results) == [False, True]
def test_shared_connection_concurrent_distinct_questions(tmp_path: Path) -> None:
"""Regression: many threads share ONE connection, answering DISTINCT questions.
The responder and resume worker hold a single connection and call the CAS
helpers from different threads concurrently (``connect()`` sets
``check_same_thread=False``). A single connection cannot hold two explicit
``BEGIN IMMEDIATE`` transactions at once, so the previous implementation
raised "cannot start a transaction within a transaction" for all but one
thread. The CAS now runs each write on its own private connection, so every
distinct question is answered with no error.
"""
db = tmp_path / "db.sqlite"
init_db(db)
shared = connect(db)
n = 8
try:
for i in range(n):
_insert_open_question(shared, f"q{i}")
barrier = threading.Barrier(n)
lock = threading.Lock()
wins: list[bool] = []
errors: list[BaseException] = []
def worker(qid: str) -> None:
try:
barrier.wait()
won = answer_question(
shared, question_id=qid, answer_json='{"v":1}', answered_via="t"
)
with lock:
wins.append(won)
except BaseException as exc: # noqa: BLE001 - record for assertion
with lock:
errors.append(exc)
threads = [threading.Thread(target=worker, args=(f"q{i}",)) for i in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
finally:
shared.close()
assert errors == [], f"shared-connection CAS raised: {errors!r}"
assert wins == [True] * n
def test_shared_connection_concurrent_same_question_single_winner(
tmp_path: Path,
) -> None:
"""Regression: shared connection, many threads racing the SAME question.
Exactly one first-answer-wins, the rest no-op (rowcount 0), and no thread
raises a transaction-nesting or lock error.
"""
db = tmp_path / "db.sqlite"
init_db(db)
shared = connect(db)
n = 8
try:
_insert_open_question(shared, "race")
barrier = threading.Barrier(n)
lock = threading.Lock()
wins: list[bool] = []
errors: list[BaseException] = []
def worker(via: str) -> None:
try:
barrier.wait()
won = answer_question(
shared, question_id="race", answer_json='{"v":1}', answered_via=via
)
with lock:
wins.append(won)
except BaseException as exc: # noqa: BLE001 - record for assertion
with lock:
errors.append(exc)
threads = [threading.Thread(target=worker, args=(f"c{i}",)) for i in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
finally:
shared.close()
assert errors == [], f"shared-connection CAS raised: {errors!r}"
assert sum(wins) == 1
assert wins.count(False) == n - 1