Skip to content

fix: approve shows what the gate is actually waiting on - #366

Merged
saucam merged 3 commits into
mainfrom
fix/approve-gated-input-349
Oct 11, 2026
Merged

saucam merged 3 commits into
mainfrom
fix/approve-gated-input-349

Conversation

@saucam

@saucam saucam commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

What this fixes

codeoid approve now shows, and answers, the tool call the session is really waiting on.

#364 got the command working again by attaching to the session and scanning its history for calls marked "waiting for approval". That history records what the agent announced, which is not always what is waiting:

  • It can show the wrong call. One backend (qwen) matches an approval to its announcement by tool name only. With two shell calls in flight, approve could show the harmless one while the dangerous one is the call it approves. Since approve presents what it shows as exactly what will run, that is the worst place for a mismatch.
  • It can approve a ghost. After a daemon restart, an old "waiting" entry in the history still looks pending. Approving it printed "Approved." even though nothing was waiting.
  • It could miss calls. The scan gave up after 3 seconds on a long history, and attaching just to read had side effects.

Now the daemon itself lists only the calls it is actually parked on, each with the exact input that runs if approved.

Other fixes to codeoid approve

  • Exit codes. Exits 1 whenever it didn't answer: nothing waiting, refused, not confirmed, or already answered elsewhere. It used to exit 0 in all of these, so a script couldn't tell.
  • Already answered. If the call is answered elsewhere between showing it and answering, it says so instead of "Approved.".
  • --id <approvalId>. Picks a call by its id, which is stable. --pick N counts from the oldest, so N can point at a different call between two runs. --pick still works.
  • Long inputs. Input too long to show in full used to be cut off silently before the "Approve this?" prompt. Now it says how much is hidden and won't approve on that partial view without --yes.
  • Hidden characters. Invisible characters that can hide or reorder text on a terminal are now shown as visible escapes. This covers the 8-bit control codes and the bidi overrides.
  • Ctrl-D. At the prompt it now means "no" instead of hanging.

Behaviour change: yes/no questions (#348) now get the same confirm prompt as tool calls, and need --yes when run without a terminal. Before, only tool calls asked first.

Verification

Ran on a real daemon (codeoid start --local, real Claude turns, the actual CLI):

  • Nothing waiting: says so in 0.44s and exits 1. It took 3s or more before.
  • Without a terminal and without --yes: refused, exit 1.
  • Ctrl-D at the prompt: not approved, exit 1, file kept.
  • y: approved, and the agent's rm a.txt ran.
  • --deny: denied, file kept.

Tests:

  • 20 new tests in src/tests/cli-approve.test.ts. They drive the real CLI over a real WebSocket into the daemon's request handling, against a session genuinely waiting on approval.
  • They include a backend that announces one input but asks approval for another.
  • With main's CLI swapped back in, the CLI tests fail (12 of the first 17).
  • Full suite: 2974 pass, 0 fail. Typecheck and lint are clean.

Not run: no end-to-end regression tier applies; codeoid has none, and this change is confined to codeoid.

Review: this code went through four review rounds on an earlier branch, written before #364 landed. Each round's security findings were fixed and retested. This PR re-applies those fixes on top of #364's command, which already had the full-input display, --pick and --yes. Review on this branch:

  • Round 1: general audit clean. Security review: no Critical or High. Three Low findings, all fixed: an empty --id fell back to the oldest call; some invisible characters weren't escaped; a newline in a tool name could fake lines.
  • Round 2: one Low regression from the round-1 change (line/paragraph separators no longer escaped), fixed with a test.
  • Round 3: clean.

Deferred to follow-up issues #367 and #368: two older backend bugs the review found. They also affect the web approval card:

  • Codex and the Gemini CLI ignore a hook's rewritten input; the rewrite is shown, but the original runs.
  • Codex matches an MCP approval prompt to its call by server name only.

This PR documents both limits rather than claiming "exactly what runs" on those backends.

Closes #349

For agents: implementation detail
  • Protocol (additive).
    • packages/protocol/src/approvals.ts: SessionApprovalsMsg / SessionApprovalsResultMsg (approvals: [{approvalId, toolName, input}]), and SessionApproveOutcome = "settled" | "early" | "stale".
    • Schema sessionApprovalsSchema is in the client union.
    • The response.ok to session.approve now carries data: { outcome }. It stays response.ok for stale ids, because the web ApprovalBar sends those to dismiss leftovers.
  • Daemon.
    • Session#approvalInputs: approvalId → {toolName, input}. It is set in the canUseTool closure immediately before #waitForApproval, with effectiveInput after hook rewrite, and cleared in finally.
    • Session.pendingToolApprovals() iterates #pendingApprovals and reads only that record. It has no fallback to the announced message.
    • Session.approve() returns the outcome.
    • SessionManager#sessionApprovals requires session:watch or session:attach plus a tenant check, the same as session.turns and attach.
  • CLI.
    • TerminalClient.approveSession uses session.approvals; the attach-and-scan #pendingToolApprovals is deleted.
    • #confirmApproval handles both the dialog and tool paths; EOF counts as "no".
    • plainText additionally escapes U+0080–009F, U+00AD, U+061C, U+180E, U+200B–200F, U+2028–2029, U+202A–202E, U+2060–2069, U+3164, U+FEFF and U+E0000–E007F as \u{..}.
    • Inputs longer than APPROVE_SHOW_MAX (8000) are refused unless --yes.
  • Consumers. Web and mobile ignore response.ok.data on approve, and the Rust TUI's serde ignores unknown keys, so no consumer change is needed.
  • Follow-ups, not in this PR.
    • The ACP backend shows the agent's own permission title as the tool input when rawInput is absent.
    • pendingDialog title/message in session.list is visible to session:list-only callers.

🤖 Generated with Claude Code

saucam and others added 3 commits October 11, 2026 11:40
#364 made `codeoid approve` work by attaching to the session and
scanning its scrollback for tool calls in `waiting_confirmation`. That
reads the ANNOUNCED tool message, which is not always what is parked:

- A backend that ties a gate to its announcement by tool name alone
  (qwen) can show one call while another is parked, and approve now
  presents what it shows as exactly what will run.
- A leftover `waiting_confirmation` row from a past run (after a daemon
  restart) looked pending; approving it printed "Approved." while
  nothing happened.
- The scan gave up after 3s on a long scrollback, and attaching just to
  read had side effects.

The daemon now answers `session.approvals {sessionId}` with only the
calls its approval gate is really waiting on, each with the input the
gate received (after hook rewrites). It needs session:watch or
session:attach, like the scrollback it replaces. `session.approve`
reports whether the answer took effect (`data.outcome`: settled / early
/ stale), still as response.ok so the web ApprovalBar's dismissal of
leftovers is unchanged.

The CLI:
- uses that list; adds --id (stable, unlike --pick, for scripts)
- exits 1 whenever it does not answer, and reports a stale answer
- refuses to approve on a partial view when the input is longer than
  it shows, instead of silently cutting it
- shows C1 controls, bidi and other invisible characters as visible
  escapes on the lines a decision rests on, dialogs included
- treats Ctrl-D at the prompt as "no" instead of hanging

Closes #349

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…limits

Security review of the session.approvals change:

- An empty `--id` (an unset $ID in a script) fell back to the oldest
  waiting call. It is refused now.
- Escape every Unicode default-ignorable and format character on the
  lines a decision rests on (variation selectors, Hangul fillers, ...),
  not a hand-picked list.
- Single-line fields (tool name, id, dialog title) escape newlines, so
  a crafted tool name can't print a fake input block above the real one.
- The protocol said the listed input is exactly what runs. Not on every
  backend: codex and the Gemini CLI ignore a hook rewrite, and codex
  ties an MCP elicitation to its call by server name. Say so; those are
  backend fixes, tracked separately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Unicode-property rewrite dropped the line and paragraph separators
(Zl/Zp, not Cf or default-ignorable), which most terminals draw as
nothing. Add them back, with a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saucam

saucam commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

@highflame-oracle review

@saucam
saucam merged commit d4fad33 into main Oct 11, 2026
4 checks passed
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.

codeoid approve <session> sends approvalId: "", which sessionApproveSchema rejects (min(1)) → the CLI subcommand never works

2 participants