Skip to content

fix(agentgate): collapse repeated approval prompts into one card - #169

Merged
radutopala merged 1 commit into
mainfrom
fix/gate-collapse-exec-burst
Jul 27, 2026
Merged

fix(agentgate): collapse repeated approval prompts into one card#169
radutopala merged 1 commit into
mainfrom
fix/gate-collapse-exec-burst

Conversation

@radutopala

Copy link
Copy Markdown
Owner

Problem

A single logical command can trap the gate many times with byte-identical argv, and each trap produced its own approval card.

The common case is a PATH search. Anything launched through a wrapper — timeout(1), env(1), nohup(1) — is resolved with execvp(3), which issues one execve(2) per PATH entry until one succeeds. Every probe carries identical argv, so every probe matched the same rule and prompted again. A bare git push traps once, because the shell stats its way to the absolute path first.

Observed in a gate audit log: one wrapped push, one pid, ten identical execve traps milliseconds apart, three of them answered by hand before the user reached for "Allow for session":

16:35:08.693 pid=1474 allow    (clicked, 34.9s)   git push origin <branch>:<branch> --force
16:35:08.694 pid=1474 request                     git push origin <branch>:<branch> --force
16:35:40.390 pid=1474 allow    (clicked, 31.7s)   ...
16:35:41.718 pid=1474 allow    (clicked,  1.3s)   ...
16:35:41.720-41.725  x7 request, no decision      <- silent once the session cache warmed

The reason each probe re-prompted is that applyResolution persisted to the dedup cache only for DecisionSession / DecisionDenySession"Allow once" cached nothing at all.

Change

Manager gains a short-lived burst memo alongside the session cache:

  • Request resolves a CacheKey through a new lookupCached: session cache first (Reason: "cache-hit"), then the memo (Reason: "burst-hit"). Expired entries are dropped on read.
  • applyResolution memoises once-scoped decisions — both "Allow once" and "Deny" — for burstCacheTTL. Denying a wrapped command would otherwise stack the same ten cards.
  • Session decisions still populate the permanent cache, and now also drop any stale memo for that key.
  • Writing a memo sweeps expired ones, so the map cannot grow unbounded.

Burst hits short-circuit before checkLimits, exactly like session cache hits, so a PATH walk no longer consumes ten slots of the per-minute prompt budget either. file and connect approvals route through the same Manager with a CacheKey, so they get the same collapse.

Net effect: the first probe prompts, the remaining nine resolve silently. One card instead of ten, with no session grant.

Trade-off

Documented at the constant: a script re-running the identical command inside the window rides the first decision. That is why the TTL is measured in seconds rather than minutes, and why "Allow for session" remains the only way to stop being asked at all.

Tests

Seven new cases in approval_test.go:

  • burst collapse for "Allow once" and for "Deny"
  • expiry exactly at the deadline re-prompts (movable clock, no sleeps)
  • a session decision supersedes a live memo, driven by two concurrent identical traps
  • burstTTL == 0 disables the memo entirely
  • writing a memo sweeps expired keys and leaves fresh ones
  • an empty CacheKey memoises nothing

lookupCached, rememberBurstLocked and applyResolution are all at 100%; the package's single uncovered line is the pre-existing DeadlineExceeded branch in Request. go test -race ./internal/... passes and make lint is clean.

Not included

The audit also shows counts that vary across containers (2, 3, 4, 5, 7, 8, 10), which a pure PATH walk does not fully explain. One suspect is server_linux.go treating ENOENT from SECCOMP_IOCTL_NOTIF_SEND as "tracee died": the kernel also returns ENOENT when a notification was invalidated because the tracee's syscall was signal-interrupted, and that tracee is alive and will re-trap. Left for a follow-up — this change collapses the duplicate cards either way.

A single logical command can trap the gate many times with byte-identical
argv. The common case is a PATH search: anything launched through a wrapper
such as timeout(1), env(1) or nohup(1) is resolved with execvp(3), which
issues one execve(2) per PATH entry until one succeeds. Since "Allow once"
persisted nothing, every probe prompted again — one wrapped `git push` could
stack ten approval cards, each needing its own click.

Manager now memoises once-scoped decisions (both "Allow once" and "Deny") for
a short TTL, so an immediately-repeated identical request reuses the answer
instead of re-prompting. Session decisions still populate the permanent cache
and additionally drop any stale memo for the same key.

The window only has to span the burst itself (probes arrive ~1ms apart), so
it is deliberately measured in seconds: a genuinely new invocation later still
prompts, and "Allow for session" remains the only way to stop being asked.
Burst hits short-circuit before the rate-limit check, like session cache hits,
so a PATH walk no longer consumes the per-minute prompt budget either. File
and connect approvals route through the same Manager, so they benefit too.
@radutopala
radutopala merged commit 65f39b0 into main Jul 27, 2026
17 checks passed
@radutopala
radutopala deleted the fix/gate-collapse-exec-burst branch July 27, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant