Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1fef11c
Add durable idempotency ledger for approved mutations
ruslanmv Sep 12, 2026
6ad93fb
Make issue mutations idempotent
ruslanmv Sep 12, 2026
7276b28
Make pull request mutations idempotent
ruslanmv Sep 12, 2026
dc6ea64
Make local file mutations replay safe
ruslanmv Sep 12, 2026
563523b
Require replay-safe keys for repository mutations
ruslanmv Sep 12, 2026
05a2370
Test durable mutation idempotency
ruslanmv Sep 12, 2026
652a0a5
Support async idempotent tool execution
ruslanmv Sep 12, 2026
bfb86e0
Protect canonical mutating tool retries
ruslanmv Sep 12, 2026
1a36644
Test canonical mutation replay protection
ruslanmv Sep 12, 2026
3db9ca8
Pass approval keys in issue tool tests
ruslanmv Sep 12, 2026
338de92
Pass approval keys in pull request tool tests
ruslanmv Sep 12, 2026
7472c4f
Keep external approvals one-shot
ruslanmv Sep 12, 2026
fc7f686
Test one-shot external approval scopes
ruslanmv Sep 12, 2026
e21f7da
Limit runtime replay protection to durable runs
ruslanmv Sep 12, 2026
eb749c8
Keep generic tool registry semantics stable
ruslanmv Sep 12, 2026
7324013
Add approval-bound runtime tool registry
ruslanmv Sep 12, 2026
a665a63
Use replay-safe registry for agent runs
ruslanmv Sep 12, 2026
8eecd10
Harden runtime idempotency boundary without changing toolkit semantics
ruslanmv Sep 12, 2026
5006fea
Preserve legacy tool compatibility while keeping runtime replay strict
ruslanmv Sep 12, 2026
2cfaea3
Keep legacy local mutators source-compatible
ruslanmv Sep 12, 2026
cd2454e
Preserve legacy issue tool argument compatibility
ruslanmv Sep 12, 2026
f502b06
Preserve legacy PR tool argument compatibility
ruslanmv Sep 12, 2026
9d377b7
Test idempotency at the production runtime boundary
ruslanmv Sep 12, 2026
ea84c0b
Pin compaction stress tests to their intended context window
ruslanmv Sep 12, 2026
f4d2297
Give legacy GitHub parity mutations stable request ids
ruslanmv Sep 12, 2026
f8719e6
Make git fixture commits independent of runner identity
ruslanmv Sep 12, 2026
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
63 changes: 59 additions & 4 deletions gitpilot/agent/approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Set

from ..toolkit.registry import Effect

logger = logging.getLogger(__name__)

#: How long a request waits before it is treated as a refusal. Per-topology in
Expand All @@ -40,6 +42,15 @@
SCOPE_ONCE = "once"
SCOPE_SESSION = "session"

#: Side effects that must always be approved one operation at a time. A session
#: grant is convenient for local, reversible edits; it is too broad for actions
#: another person can observe or that change an external system.
_ONE_SHOT_EFFECTS = frozenset({
Effect.GIT_REMOTE,
Effect.FORGE_WRITE,
Effect.EXTERNAL_WRITE,
})


@dataclass
class PendingApproval:
Expand All @@ -52,6 +63,7 @@ class PendingApproval:
risk: str
reason: str
command_class: str = ""
allow_session: bool = True
#: Always set by :meth:`ApprovalRegistry.register`; optional only so the
#: dataclass can be constructed in a test without an event loop.
future: Optional["asyncio.Future[Dict[str, Any]]"] = field(repr=False, default=None)
Expand All @@ -65,6 +77,8 @@ def to_dict(self) -> Dict[str, Any]:
"risk": self.risk,
"reason": self.reason,
"command_class": self.command_class,
"allow_session": self.allow_session,
"allowed_scopes": [SCOPE_ONCE, SCOPE_SESSION] if self.allow_session else [SCOPE_ONCE],
}


Expand Down Expand Up @@ -92,12 +106,13 @@ def register(
risk: str = "approval",
reason: str = "",
command_class: str = "",
allow_session: bool = True,
) -> PendingApproval:
future: "asyncio.Future[Dict[str, Any]]" = asyncio.get_running_loop().create_future()
pending = PendingApproval(
request_id=request_id, session_id=session_id, tool=tool,
arguments=dict(arguments or {}), risk=risk, reason=reason,
command_class=command_class, future=future,
command_class=command_class, allow_session=allow_session, future=future,
)
self._pending[request_id] = pending
self._by_session.setdefault(session_id, set()).add(request_id)
Expand Down Expand Up @@ -133,12 +148,19 @@ def resolve(self, request_id: str, approved: bool, scope: str = SCOPE_ONCE) -> b
"""Answer a pending request. Returns whether there was one.

Idempotent: a duplicate answer (the user double-clicks, or two transports
both deliver it) is dropped rather than raising.
both deliver it) is dropped rather than raising. A client cannot elevate
a one-shot external approval into a session-wide grant: unsupported
``session`` scope is deterministically reduced to ``once``.
"""
pending = self._pending.get(request_id)
if pending is None or pending.future is None or pending.future.done():
return False
pending.future.set_result({"approved": bool(approved), "scope": scope})
effective_scope = (
SCOPE_SESSION
if scope == SCOPE_SESSION and pending.allow_session
else SCOPE_ONCE
)
pending.future.set_result({"approved": bool(approved), "scope": effective_scope})
return True

def deny_session(self, session_id: str, *, reason: str = "session closed") -> int:
Expand All @@ -159,7 +181,7 @@ def discard(self, request_id: str) -> None:
if pending is not None:
requests = self._by_session.get(pending.session_id)
if requests is not None:
requests.discard(request_id)
requests.discard(pending.request_id)
if not requests:
self._by_session.pop(pending.session_id, None)

Expand Down Expand Up @@ -198,6 +220,38 @@ def reset_registry() -> None:
_registry = None


def _allows_session_scope(call: Any, ctx: Any) -> bool:
"""Session grants are only for local/reversible effects.

The registry is already attached to the execution context by the runner. If
anything about the spec cannot be established, fail closed to one-shot: a
missing registry must never widen an approval.
"""
tool_context = getattr(ctx, "tool_context", None)
extras = getattr(tool_context, "extras", {}) or {}
registry = extras.get("registry") if isinstance(extras, dict) else None
if registry is None:
return False
try:
spec = registry.spec(call.tool)
except Exception: # noqa: BLE001 - unknown ⇒ one-shot is the safe direction
return False

if spec.effects & _ONE_SHOT_EFFECTS:
return False

# ``fs.write``/``fs.edit`` share one spec between local and GitHub-backed
# workspaces. When the target is a remote repo rather than a local checkout,
# treat filesystem writes as external and keep them one-shot as well.
if Effect.WRITES_FS in spec.effects:
workspace = getattr(tool_context, "workspace", None)
repo = getattr(tool_context, "repo", None)
if workspace is None and repo is not None:
return False

return True


# ---------------------------------------------------------------------------
# The loop's approver
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -241,6 +295,7 @@ async def request(self, call: Any, decision: Any, ctx: Any) -> bool:
risk=getattr(decision, "risk", "approval"),
reason=getattr(decision, "reason", ""),
command_class=getattr(decision, "command_class", ""),
allow_session=_allows_session_scope(call, ctx),
)
if self.gate is not None:
# Let the gate's own pending map see it too, so a WS client calling
Expand Down
Loading
Loading