Skip to content
Merged
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,12 @@ pending either way, and answering it always lands you back where you were. A
non-overridable deny floor (`rm -rf`, `sudo`, secrets, `.git`, `.env`, …) is
always enforced.

**No answer is a no.** A prompt waits two minutes, with the countdown in the
status line. Then the call is denied and the task goes on without it, instead
of sitting on the prompt until you come back. Only an explicit yes runs
anything. The risk model learns nothing from a prompt that ran out, because
nobody judged that call.

**Grant it for the session.** Press **`a`** on any prompt to stop asking about
that whole *kind* of action (file changes, shell, git, sub-agent spawns, MCP) for
the rest of the session — in memory only, never written to config, cleared by
Expand Down Expand Up @@ -528,7 +534,7 @@ it learns from:
```
⚙ run shell cat ~/.ssh/id_rsa ⚠ possible credential access (0.98)
⚠ approve run: cat ~/.ssh/id_rsa ⚠ possible credential access (0.98)
y approve · n deny · a allow all shell commands this session, flagged ones too
y approve · n deny · a allow all shell commands this session, flagged ones too · ↑ Yes/No · or keep typing · no answer in 2m is a no
```

Nothing is shown for a call it had nothing to say about — a number on every
Expand Down
213 changes: 213 additions & 0 deletions cmd/agent/approval_timeout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package main

import (
"bufio"
"context"
"io"
"strings"
"testing"
"time"

"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
)

// askThroughTheUI starts an approval on the bridge and shows it on m, the way
// the TUI does, returning where the asking tool's answer will arrive.
func askThroughTheUI(t *testing.T, m *tuiModel) <-chan [2]bool {
t.Helper()
got := make(chan [2]bool, 1)
go func() {
ok, answered := m.bridge.ApproveAnswered(context.Background(), "run", "make deploy")
got <- [2]bool{ok, answered}
}()
m.Update(approvalMsg(<-m.bridge.approvals))
if m.pending == nil {
t.Fatal("the approval was not shown")
}
return got
}

// A prompt nobody answers used to hold the task until someone came back. Now,
// past approvalTimeout, it is a no: the tool is told so and the run goes on —
// and since nobody judged the call, the risk model is not told anything.
func TestAnUnansweredApprovalIsANoAndTheTaskGoesOn(t *testing.T) {
m := &tuiModel{bridge: newBridge(), input: textarea.New(), state: stRunning, spin: spinner.New()}
got := askThroughTheUI(t, m)

m.Update(spinner.TickMsg{}) // still in time
if m.pending == nil {
t.Fatal("the approval was denied before its time ran out")
}

m.approveBy = time.Now().Add(-time.Second)
_, cmd := m.Update(spinner.TickMsg{})
select {
case r := <-got:
if r[0] || r[1] {
t.Fatalf("ApproveAnswered = (ok %v, answered %v), want (false, false) — a timeout is neither a yes nor anyone's answer", r[0], r[1])
}
case <-time.After(3 * time.Second):
t.Fatal("the tool was still waiting after the approval ran out")
}
if m.pending != nil || m.state != stRunning {
t.Errorf("pending = %v, state = %v; want none pending, back to running", m.pending, m.state)
}
// The next approval still reaches the screen: running out re-arms the reader.
go m.bridge.ApproveAnswered(context.Background(), "run", "make test")
msgs := make(chan tea.Msg, 4)
if batch, ok := cmd().(tea.BatchMsg); ok {
for _, c := range batch {
if c != nil {
go func() { msgs <- c() }()
}
}
}
for deadline := time.After(3 * time.Second); ; {
var msg tea.Msg
select {
case msg = <-msgs:
case <-deadline:
t.Fatal("the next approval never reached the UI — the expired one left nobody reading")
}
if _, ok := msg.(approvalMsg); ok {
break
}
}
if log := strings.Join(m.history, "\n"); !strings.Contains(log, "no answer in "+approvalWait()) {
t.Errorf("the log does not say why it was denied:\n%s", log)
}
}

// Running out is not an answer typed into a panel: a panel open over the prompt
// stays open — even when the task finished behind it, which is finalized when
// the panel closes, not when the prompt runs out.
func TestAnExpiredApprovalLeavesAPanelOpen(t *testing.T) {
m := &tuiModel{bridge: newBridge(), input: textarea.New(), state: stRewind, spin: spinner.New(), taskDoneAway: true}
got := askThroughTheUI(t, m)
m.approveBy = time.Now().Add(-time.Second)
m.Update(spinner.TickMsg{})
if r := <-got; r[0] {
t.Fatal("an expired approval ran the call")
}
if m.state != stRewind || !m.taskDoneAway {
t.Errorf("state = %v, taskDoneAway = %v; want the rewind panel still open and the finished task still waiting on it", m.state, m.taskDoneAway)
}
}

// A task that finished behind the prompt is finalized when the prompt runs out,
// even after esc dropped back to typing: nothing else would, and the view sat
// on "running" with no task behind it.
func TestAnExpiredApprovalFinalizesTheTaskBehindIt(t *testing.T) {
m := &tuiModel{bridge: newBridge(), input: textarea.New(), state: stRunning, spin: spinner.New(), taskDoneAway: true}
got := askThroughTheUI(t, m)
m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) // back to typing; still pending
m.approveBy = time.Now().Add(-time.Second)
m.Update(spinner.TickMsg{})
<-got
if m.state != stIdle || m.taskDoneAway {
t.Errorf("state = %v, taskDoneAway = %v; want the finished task finalized to idle", m.state, m.taskDoneAway)
}
}

// esc denies the prompt it drops, whichever task asked it. One asked before the
// current task began holds an abort signal esc never closes.
func TestEscDeniesAPromptFromBeforeTheTask(t *testing.T) {
m := &tuiModel{bridge: newBridge(), input: textarea.New(), state: stIdle, spin: spinner.New()}
got := askThroughTheUI(t, m) // a background job asks while idle
m.bridge.arm() // the next task starts
m.state, m.cancel = stRunning, func() {}
m.handleKey(tea.KeyMsg{Type: tea.KeyEsc})
select {
case r := <-got:
if r[0] {
t.Fatal("esc approved the call")
}
case <-time.After(3 * time.Second):
t.Fatal("esc dropped the prompt, but its tool is still waiting for an answer")
}
}

// A prompt asked after esc was denied tool-side the moment it was asked: it is
// not shown, and the reader moves on to the next one.
func TestAPromptAskedAfterEscIsNotShown(t *testing.T) {
m := &tuiModel{bridge: newBridge(), input: textarea.New(), state: stRunning, spin: spinner.New()}
aborted := make(chan struct{})
close(aborted)
_, cmd := m.Update(approvalMsg(approvalReq{kind: "run", detail: "make deploy", reply: make(chan bool, 1), aborted: aborted}))
if m.pending != nil || m.state != stRunning {
t.Errorf("pending = %v, state = %v; want the dead prompt not shown", m.pending, m.state)
}
if cmd == nil {
t.Error("skipping a dead prompt must keep reading the next one")
}

// The same through the bridge: asked, then esc, then read by the UI.
got := make(chan bool, 1)
go func() { ok, _ := m.bridge.ApproveAnswered(context.Background(), "run", "make deploy"); got <- ok }()
req := <-m.bridge.approvals
m.bridge.Abort()
if <-got {
t.Fatal("an aborted approval ran the call")
}
m.Update(approvalMsg(req))
if m.pending != nil {
t.Error("the bridge's prompt was shown after esc had already denied it")
}
}

// A read that gave up returns, rather than waiting for a reply its abandoned
// request will never get.
func TestAReadThatGaveUpReturns(t *testing.T) {
pr, pw := io.Pipe()
t.Cleanup(func() { pr.Close(); pw.Close() })
o := newStdinOwner(bufio.NewReader(pr))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { _, _ = o.readApproveLineCtx(ctx); close(done) }()
time.Sleep(20 * time.Millisecond) // queued, waiting for a line
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the read is still waiting after its reader gave up — a goroutine leaked for good")
}
}

// The plain prompt runs out the same way, and its read gives up with it: the
// next line typed belongs to the REPL, not to the prompt that already expired.
func TestAnUnansweredPlainPromptIsANo(t *testing.T) {
defer func(d time.Duration) { approvalTimeout = d }(approvalTimeout)
approvalTimeout = 50 * time.Millisecond

pr, pw := io.Pipe()
t.Cleanup(func() { pr.Close(); pw.Close() })
s := &stdinApprover{stdin: newStdinOwner(bufio.NewReader(pr))}

done := make(chan [2]bool, 1)
go func() {
ok, answered := s.ApproveAnswered(context.Background(), "run", "make deploy")
done <- [2]bool{ok, answered}
}()
select {
case r := <-done:
if r[0] || r[1] {
t.Fatalf("ApproveAnswered = (ok %v, answered %v), want (false, false)", r[0], r[1])
}
case <-time.After(3 * time.Second):
t.Fatal("the plain prompt did not run out")
}

go func() { _, _ = io.WriteString(pw, "y\n") }()
line := make(chan string, 1)
go func() { l, _ := s.stdin.readCmdLine(); line <- l }()
select {
case l := <-line:
if strings.TrimSpace(l) != "y" {
t.Errorf("the REPL read %q, want the line typed after the prompt expired", l)
}
case <-time.After(3 * time.Second):
t.Fatal("the line typed after the expired prompt never reached the REPL — the dead prompt took it")
}
}
31 changes: 29 additions & 2 deletions cmd/agent/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package main

import (
"context"
"strings"
"sync"
"time"

"github.com/ipsupport-llc/ipsupport-code/internal/risk"
)
Expand Down Expand Up @@ -34,6 +36,29 @@ type approvalReq struct {
// sees is a score nobody can judge.
risk string
reply chan bool
// expire is closed by the UI when nobody answered within approvalTimeout:
// the call is denied, and — like esc — that is nobody's judgement of it.
expire chan struct{}
// aborted is the task's abort signal as of the ask. Once it is closed the
// tool has already been told no, so the UI does not show the prompt: an
// answer to it would change nothing, and its countdown would run for a
// call that is gone.
aborted <-chan struct{}
}

// approvalTimeout is how long a prompt waits for a person. A run left alone
// used to sit on its first prompt until someone came back; now an unanswered
// prompt is a "no" and the task goes on without that call. Only an explicit
// yes runs anything. A var so tests can shorten it.
var approvalTimeout = 2 * time.Minute

// approvalWait is approvalTimeout as a prompt says it: "2m", not "2m0s".
func approvalWait() string {
s := approvalTimeout.String()
if strings.HasSuffix(s, "m0s") {
s = s[:len(s)-2]
}
return s
}

func newBridge() *uiBridge {
Expand Down Expand Up @@ -70,9 +95,9 @@ func (b *uiBridge) ApproveAnswered(ctx context.Context, kind, detail string) (ok
abort := b.abort
b.mu.Unlock()

reply := make(chan bool, 1)
reply, expire := make(chan bool, 1), make(chan struct{})
select {
case b.approvals <- approvalReq{kind: kind, detail: detail, risk: riskNote(ctx), reply: reply}:
case b.approvals <- approvalReq{kind: kind, detail: detail, risk: riskNote(ctx), reply: reply, expire: expire, aborted: abort}:
case <-abort:
return false, false
case <-ctx.Done():
Expand All @@ -81,6 +106,8 @@ func (b *uiBridge) ApproveAnswered(ctx context.Context, kind, detail string) (ok
select {
case ok := <-reply:
return ok, true
case <-expire:
return false, false
case <-abort:
return false, false
case <-ctx.Done():
Expand Down
30 changes: 24 additions & 6 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6391,18 +6391,25 @@ func (s *stdinApprover) Approve(ctx context.Context, kind, detail string) bool {
func (s *stdinApprover) ApproveAnswered(ctx context.Context, kind, detail string) (ok, answered bool) {
s.mu.Lock()
defer s.mu.Unlock()
fmt.Fprintf(os.Stderr, "\n[approve %s] %s%s\n allow? [y/N/a=all %s this session%s] ", kind, detail, riskSuffix(ctx), categoryLabel(approvalCategory(kind)), flaggedToo(riskNote(ctx)))
fmt.Fprintf(os.Stderr, "\n[approve %s] %s%s\n allow? [y/N/a=all %s this session%s] (no answer in %s is a no) ", kind, detail, riskSuffix(ctx), categoryLabel(approvalCategory(kind)), flaggedToo(riskNote(ctx)), approvalWait())

// The read gives up with the deadline too, so a line typed after it goes to
// whoever asks next, not to this expired prompt.
wait, cancel := context.WithTimeout(ctx, approvalTimeout)
defer cancel()
result := make(chan lineResult, 1) // buffered: the read goroutine must never block sending here, even if abandoned
go func() {
line, err := s.stdin.readApproveLineCtx(ctx)
line, err := s.stdin.readApproveLineCtx(wait)
result <- lineResult{line, err}
}()

var res lineResult
select {
case res = <-result:
case <-ctx.Done():
case <-wait.Done():
if ctx.Err() == nil {
fmt.Fprintf(os.Stderr, "\n no answer in %s — denied; the task goes on without it\n", approvalWait())
}
return false, false
}
if res.err != nil {
Expand Down Expand Up @@ -6544,9 +6551,20 @@ func (o *stdinOwner) readApproveLineCtx(ctx context.Context) (string, error) {
func (o *stdinOwner) readVia(req chan lineReq, done <-chan struct{}) (string, error) {
o.start()
reply := make(chan lineResult, 1)
req <- lineReq{reply: reply, done: done}
res := <-reply
return res.line, res.err
// A reader that gives up returns: pickRecipient skips its request, so
// waiting on for a reply would hold this goroutine for the life of the
// process — one more for every approval that ran out.
select {
case req <- lineReq{reply: reply, done: done}:
case <-done:
return "", context.Canceled
}
select {
case res := <-reply:
return res.line, res.err
case <-done:
return "", context.Canceled
}
}

// logLevel is the log threshold: warnings and up by default (so retries/errors
Expand Down
Loading
Loading