antigravity-tui-supervisor.py: partial terminal redraw during a permission prompt can trip the fail-closed guard and halt agy delivery

Open
#1,263 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
28/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
python
Domain
cli, security, tooling

Research direction

Start in scripts/drivers/types/antigravity/antigravity-tui-supervisor.py at permission_input_rejection_reason() / permission_input_ready() (around line 315), which judge only the current self.screen snapshot. Reproduce a mid-redraw permission modal (incomplete footer/nav/body tokens) and confirm it trips fail() / durableAttention. Any fix must stay fail-closed: do not search raw PTY text, and do not keep a stale screen snapshot across later inputs. Done means a legitimate permission prompt still authorizes one stdin event while incomplete frames no longer halt delivery.

Written by the indexing model from the issue text.

Description

Summary

On our fork (joelmitz/agmsg) we hit a case where a real agy (Antigravity) session
stopped mid-delivery with a fail-closed halt, even though the child process was
legitimately showing a permission-approval modal (from whoami.sh). What we
confirmed: the captured frame was incomplete while the permission flow was
active, so permission_input_rejection_reason() returned a rejection reason
(e.g. permission-nav-missing / permission-body-incomplete) for that frame.
The supervisor then treated the input as an unexpected human interruption and
called fail(), which sets durableAttention and halts automatic delivery.

We believe this is not fork-specific: we diffed scripts/drivers/types/antigravity/antigravity-tui-supervisor.py
at the commit where we last merged upstream (e26815a8, which took your main
as-is for this file) against current origin/main and they are byte-identical.
permission_input_rejection_reason() only ever looks at the current screen
snapshot — there is no tolerance for a screen caught mid-redraw. So we'd expect
the same halt to be reproducible upstream under the right timing, even though we
haven't reproduced it there directly (our repro is fork-only, see below).

Root cause

permission_input_rejection_reason() (currently around line 315) makes a
purely structural judgement of self.screen at the instant it's called:
footer text, nav row position, and the three required tokens
(Requesting permission for:, Do you want to proceed?, > 1. Yes) all in
the current frame. There is no tolerance for a frame caught mid-redraw — if the
frame is incomplete at read time, permission_input_ready() returns False
for that read cycle even while the permission flow is genuinely active. (A
plausible mechanism for how a frame ends up incomplete is a redraw split
across two reads — e.g. footer visible but nav row not yet redrawn, or vice
versa — but we have not instrumented this to confirm it; what we observed
directly is the incomplete-frame rejection and the resulting halt, not the
underlying terminal I/O timing.)

What we changed (fork-only so far, not proposed as-is)

We went through three iterations before landing on something we're relatively
confident isn't a regression from a security standpoint (each iteration got an
independent adversarial review before merging):

  1. First attempt (5ca18bf2) — added a fallback that searched the raw
    accumulated PTY transcript (permission_raw_window, up to 64KB of
    unfiltered child output) for the three required tokens in order, regardless
    of screen structure. This was rejected in review: the accumulated
    transcript can contain agmsg message bodies echoed by the child CLI (agy
    surfaces the received message text on screen), so if a delivered message
    body happened to contain the right phrases in the right order, the
    fallback would authorize input even though no real permission modal was
    ever on screen. Structural position (footer → nav → body, within a bounded
    window) is what makes the existing check resistant to this; a flat
    substring search over raw output throws that away.

  2. Second attempt (1b21beb1) — replaced the raw-transcript search with a
    copy.deepcopy(self.screen) snapshot, captured only at the instant the
    structural check (permission_input_ready()) actually passes. Falling
    back to this snapshot when the live screen's structural check fails lets a
    momentarily-redrawing screen still authorize input, without trusting raw
    text. Also rejected: the snapshot was only cleared in inject(), so
    once captured it stayed valid for the rest of the turn — meaning if a
    second, unrelated human interruption happened after the first legitimate
    permission prompt, it would be silently authorized via the stale snapshot
    instead of tripping fail().

  3. Final (875785129 + 8403e766) — the snapshot is consumed exactly
    once per legitimate use (permission_snapshot_fallback_used flag, checked
    in permission_input_ready_with_snapshot()), and re-armed only when a
    new structurally-valid permission screen is captured (not on any other
    condition). So: the fallback authorizes at most one subsequent stdin event
    after a structurally-valid snapshot was captured — with no time limit or
    poll-cycle bound on how long that one use may be deferred — and is
    re-armed only by capturing a new structurally-valid snapshot; every input
    outside of that one use still goes through the normal fail-closed path.

Relevant commits (fork, joelmitz/agmsg main, all already pushed):

  • 5ca18bf2e7b3b368f002a7d2c1a380096fb1275c (superseded, kept for history)
  • 1b21beb19c9d1ac05fa6d66121f2dc3842cb6f7b (superseded, kept for history)
  • 875785129eb2202f916e9106f7323ae6d40438ac
  • 8403e76631fe63592b829cb5020fecb69792c16d (current state)

Net diff against e26815a8 (the last point where this file matched upstream)
is small — one new field pair, one new method, and a few lines wiring it into
the existing stdin-handling branch. Happy to open this as a PR against
origin/main if that's useful, or to just leave this as a heads-up in case
you've seen (or want to guard against) the same class of halt.

Why we're not just opening a PR outright

Our fix is tied to our fork's specific snapshot/consume/re-arm design, and we
haven't tried to reproduce the original halt against origin/main directly
(only against our fork, post-1.3.0-merge, where the file was identical to
yours at merge time). Wanted to flag the underlying issue and our approach
first in case you have a different preference for how to harden this check,
or already have something in flight.

Dominant language
Shell
Stars
1.5k
Forks
156
Avg merge
16h 23m
Merged PRs (30d)
197

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from fujibee/agmsg

All issues in fujibee/agmsg

Similar issues

More Shell/Bash issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.