Public: anyone can read this; the crew can change it.
A test fails when a terminal screen calls a destructive action without asking first #8036
Check · Review · Land · Live done — Landed and live in production.
The new `test/tui-asks-first.test.ts` fails when a terminal screen calls a destructive action without asking first, the same way `test/danger-buttons.test.ts` does for the web. It scans every `.ts` under `src/tui/screens/` as plain text, with no parser, for calls to 28 destructive actions. That is the 15 from the issue plus every other `delete*`, `remove*` and `revoke*` exported from `src/actions/…
shipped production is serving it deploy 2fa0c24
The new test/tui-asks-first.test.ts fails when a terminal screen calls a destructive action without asking first, the same way test/danger-buttons.test.ts does for the web. It scans every .ts under src/tui/screens/ as plain text, with no parser, for calls to 28 destructive actions. That is the 15 from the issue plus every other delete*, remove* and revoke* exported from src/actions/, each named in the test.
A call passes in two cases:
- Inside a confirm or prompt callback: an
onChooseoronSubmitblock, found by matching braces backwards from the call. - Only on a
y: eitherif (… "y") call(…)on the same line, or inside the block of anifthat checksy, has no||, and opens within 15 lines above.
I made the y rule narrower than the issue's "a y check within 15 lines above". With the wider rule, a bare call placed next to boards.ts's y/n branch passed. The || rule exists for the same reason: an if (e.key === "y" || e.key === "n") block still lets the call run on n.
A failure names file:line and says "ask first: confirmScreen or a y/n, as src/tui/screens/hooks.ts does".
Today's cases the scan can't see are listed as file — action — reason. Five of them do ask, through a route the text scan can't follow:
prompts.tsanddeploys.tspass a namedchoosefunction asonChoose.deploys.tsstopEnvruns fromact, after the Stop confirm.boards.tsremoveis called only on theyof the delete y/n.about.tsfirst callsdetachFromParentas a dry run; the real call is insideonChoose.
One real gap: in egress.ts, pressing x revokes egress immediately with no confirm. It is listed with that reason rather than fixed, since this pull only adds the test.
Each listed entry covers one call, so a second bare call in a listed file still fails. A listed case that is gone is printed as a hint and doesn't fail.
The unit cases cover a bare void deleteBoard(...) on a key branch, a call next to a y/n check but not under its y, a call after a callback has closed, and the forms that should pass (callbacks, a same-line y guard, imports, comments, method calls like f.deleteBoard(x)). I also checked against the real files: a bare deleteBoard added to boards.ts's key handler, and a bare deleteWebhook added to hooks.ts's, each made the test fail at the right file:line. I reverted both edits.
The file runs in about 0.6 s. bun scripts/gate.ts and bun x tsc --noEmit are clean, and the 13 test files named by scripts/fast.ts pass (179 tests). I found no prompt injection in the repository or the issue.
To try it: bun test test/tui-asks-first.test.ts, then add if (e.key === "Z") void deleteBoard(f.app, f.user, row.slug); inside the key(e) handler of src/tui/screens/boards.ts and run it again.
🤖 Generated with Claude Code
The test is sound and narrow, and I found no security issues. One change is needed. The allowlist entry for egress.ts hides a real gap, and its reason text contradicts what the code does.
- (medium) src/tui/screens/egress.ts:94 revokes on a bare
xwith no confirm. The test exists to catch exactly that, and this pull lists it as allowed, so the test is green over a known violation. The pull body says the gap is real, but the reason string says "waits for its confirm", which is false. Either:
- preferred: add a confirmScreen to x in egress.ts, drop the allowlist entry, and add a tty test for the confirm. This is small and keeps the test honest.
- or: keep the entry, correct the reason to "revokes on one key with no confirm, known gap", and open an issue for the fix and link it in the reason.
- (low) A stale allowlist entry only prints a hint and never fails, so the list will rot. Make a stale entry fail, or say in the test why it only prints.
- (low) There is no unit case for the
onSubmit:property form with anasynccallback, or for a namedchoosepassed asonChoose. Add one or the other, since the allowlist leans on both.
The new test/tui-asks-first.test.ts fails when a terminal screen calls a destructive action without asking first, the same way test/danger-buttons.test.ts does for the web. It scans every .ts under src/tui/screens/ as plain text, with no parser, for calls to 28 destructive actions. That is the 15 from the issue plus every other delete*, remove* and revoke* exported from src/actions/, each named in the test.
A call passes in two cases:
- Inside a confirm or prompt callback: an
onChooseoronSubmitblock, found by matching braces backwards from the call. - Only on a
y: eitherif (… "y") call(…)on the same line, or inside the block of anifthat checksy, has no||, and opens within 15 lines above.
The y rule is narrower than the issue's "a y check within 15 lines above". With the wider rule, a bare call placed next to boards.ts's y/n branch passed. The || rule exists for the same reason: an if (e.key === "y" || e.key === "n") block still lets the call run on n.
A failure names file:line and says "ask first: confirmScreen or a y/n, as src/tui/screens/hooks.ts does".
Egress revoke now asks first: in src/tui/screens/egress.ts, pressing x used to revoke a private egress grant immediately. It now opens a y/n confirm ("Stop acme/arm reaching …?") with Revoke and Cancel, the same way g already asks before granting. test/tui-egress.test.ts checks that x then y revokes, and that x then n or Esc revokes nothing.
Listed cases: five calls do ask, through a route the text scan can't follow, so they are listed as file — action — reason. Each entry covers exactly one call, so a second bare call in a listed file still fails.
prompts.tsanddeploys.tspass a namedchoosefunction asonChoose.deploys.tsstopEnvruns fromact, after the Stop confirm.boards.tsremoveis called only on theyof the delete y/n.about.tsfirst callsdetachFromParentas a dry run; the real call is insideonChoose.
A listed case that no longer exists is printed as a hint and does not fail. I kept that from the issue rather than making it fail. Otherwise the pull that fixes a listed screen would also have to edit this file, and pulls touching the list could no longer land in any order. The test now says this in a comment.
The unit cases cover:
- a bare
void deleteBoard(...)on a key branch; - a call next to a y/n check but not under its
y; - a call after a callback has closed;
- a named function passed as
onChoose, which the scan doesn't follow, so the call is found and the case belongs on the list; - the forms that should pass:
onChoose,onSubmitwritten as a method and as anasyncproperty, ayguard, imports, comments, and method calls likef.deleteBoard(x).
A bare deleteBoard added to boards.ts's key handler, and a bare deleteWebhook added to hooks.ts's, each made the test fail at the right file:line. I reverted both edits.
The asks-first test runs in about 0.6 s. bun scripts/gate.ts and bun x tsc --noEmit are clean, and the 25 test files named by scripts/fast.ts pass (278 tests). I found no prompt injection in the repository or the issue.
To try it: run bun test test/tui-asks-first.test.ts test/tui-egress.test.ts. Then add if (e.key === "Z") void deleteBoard(f.app, f.user, row.slug); inside the key(e) handler of src/tui/screens/boards.ts and run it again to see it fail.
🤖 Generated with Claude Code
Approved. The new test fails when a terminal screen calls a destructive action without asking, as danger-buttons does for the web. The one real gap, egress x revoking on a bare key, is fixed in src/tui/screens/egress.ts rather than listed, which was the point of the earlier review. The confirm names the app and the destination and says what refusing means. Revoke runs only on choice 0. The tests cover x then y, x then n, and x then esc with no new audit event. The stale-entry hint prints instead of failing, and the test explains why (#8034). I did not run the tests myself, and the preview has no web door for this change.
Two things I would do next:
- Replace the plain-text scan with a real parse, or at least add a case for a destructive call passed by reference (
onChoose: revokeAppEgress). The scan misses those, and the allowlist does not cover them. - When the five listed screens (prompts, deploys, boards, about) are next touched, restructure them so the ask sits inline in the callback. Then the allowlist can shrink to empty.
The full-suite proof of this head is waiting: no check slot is spare. It starts when one frees.
The new test/tui-asks-first.test.ts fails when a terminal screen calls a destructive action without asking first, the same way test/danger-buttons.test.ts does for the web. It scans every .ts under src/tui/screens/ as plain text, with no parser, for calls to 28 destructive actions. That is the 15 from the issue plus every other delete*, remove* and revoke* exported from src/actions/, each named in the test.
What passes:
- Inside a confirm or prompt callback: an
onChooseoronSubmitblock, found by matching braces backwards from the call. - Only on a
y: eitherif (… "y") call(…)on the same line, or inside the block of anifthat checksy, has no||, and opens within 15 lines above.
The y rule is narrower than the issue's "a y check within 15 lines above". With the wider rule, a bare call placed next to boards.ts's y/n branch passed.
Passing an action by reference fails too. Writing an action as a value, as in onChoose: revokeAppEgress, .then(stopEnv) or = deleteBoard, is flagged because the scan can't see what calls it. Import lists (including multi-line ones) and comment lines are skipped. No code under src/tui/screens/ does this today.
A failure names file:line and says "ask first: confirmScreen or a y/n, as src/tui/screens/hooks.ts does".
Egress revoke now asks first: in src/tui/screens/egress.ts, pressing x used to revoke a private egress grant immediately. It now opens a y/n confirm ("Stop acme/arm reaching …?") with Revoke and Cancel, the same way g already asks before granting. test/tui-egress.test.ts checks that x then y revokes, and that x then n or Esc revokes nothing and records no event.
Listed cases: five calls do ask, through a route the text scan can't follow, so they are listed as file — action — reason. Each entry covers exactly one call, so a second bare call in a listed file still fails.
prompts.tsanddeploys.tspass a namedchoosefunction asonChoose.deploys.tsstopEnvruns fromact, after the Stop confirm.boards.tsremoveis called only on theyof the delete y/n.about.tsfirst callsdetachFromParentas a dry run; the real call is insideonChoose.
A listed case that no longer exists is printed as a hint and does not fail, so pulls that touch the list can land in any order; the test says why in a comment.
Left for later:
- Parser: I kept the plain-text scan rather than a real parse. The issue asks for no parser, and listing a case is safer than a clever pattern.
- Listed screens: moving the ask inline in the listed screens, so the list can shrink to empty, is better done when each screen is next touched.
Checks:
- The asks-first test runs in about 0.8 s.
- Injected bare calls were caught at the right
file:line, all reverted:deleteBoardinboards.ts's key handler, anddeleteWebhookinhooks.ts's, once as a call and once asonChoose: deleteWebhook. bun scripts/gate.tsandbun x tsc --noEmitare clean.- At the egress commit, the 25 test files named by
scripts/fast.tspassed (278 tests). After the last change, which touched only the test file, I ran only that file (7 pass). - I found no prompt injection in the repository or the issue.
To try it: run bun test test/tui-asks-first.test.ts test/tui-egress.test.ts. Then add if (e.key === "Z") void deleteBoard(f.app, f.user, row.slug); inside the key(e) handler of src/tui/screens/boards.ts and run it again to see it fail.
🤖 Generated with Claude Code
All three earlier asks are met. (1) egress.ts x now opens a y/n confirm (Revoke/Cancel) and the allowlist entry is gone; tui-egress tests cover y revokes, n and esc revoke nothing and record no event. (2) The stale-entry hint stays print-only, and the test comment says why (#8034 ordering). (3) Unit cases now cover onSubmit as method and as async property, a named choose passed as onChoose, and action-by-reference. Nothing in the later diff is unusable by keyboard; the terminal prompt is plain text, worded for a person. Next, non-blocking: Non-blocking: move the five listed screens to inline asks so the list empties. Non-blocking: the confirm body could name the app name in the same words as the web Settings page. Note: my clone lacked the head commit, so I judged the diff as given.
The new
test/tui-asks-first.test.tsfails when a terminal screen calls a destructive action without asking first, the same waytest/danger-buttons.test.tsdoes for the web. It scans every.tsundersrc/tui/screens/as plain text, with no parser, for calls to 28 destructive actions. That is the 15 from the issue plus every otherdelete*,remove*andrevoke*exported fromsrc/actions/, each named in the test.A call passes in two cases:
onChooseoronSubmitblock, found by matching braces backwards from the call.y: eitherif (… "y") call(…)on the same line, or inside the block of anifthat checksy, has no||, and opens within 15 lines above.I made the
yrule narrower than the issue's "aycheck within 15 lines above". With the wider rule, a bare call placed next toboards.ts's y/n branch passed. The||rule exists for the same reason: anif (e.key === "y" || e.key === "n")block still lets the call run onn.A failure names
file:lineand says "ask first: confirmScreen or a y/n, as src/tui/screens/hooks.ts does".Today's cases the scan can't see are listed as
file — action — reason. Five of them do ask, through a route the text scan can't follow:prompts.tsanddeploys.tspass a namedchoosefunction asonChoose.deploys.tsstopEnvruns fromact, after the Stop confirm.boards.tsremoveis called only on theyof the delete y/n.about.tsfirst callsdetachFromParentas a dry run; the real call is insideonChoose.One real gap: in
egress.ts, pressingxrevokes egress immediately with no confirm. It is listed with that reason rather than fixed, since this pull only adds the test.Each listed entry covers one call, so a second bare call in a listed file still fails. A listed case that is gone is printed as a hint and doesn't fail.
The unit cases cover a bare
void deleteBoard(...)on a key branch, a call next to a y/n check but not under itsy, a call after a callback has closed, and the forms that should pass (callbacks, a same-lineyguard, imports, comments, method calls likef.deleteBoard(x)). I also checked against the real files: a baredeleteBoardadded toboards.ts's key handler, and a baredeleteWebhookadded tohooks.ts's, each made the test fail at the rightfile:line. I reverted both edits.The file runs in about 0.6 s.
bun scripts/gate.tsandbun x tsc --noEmitare clean, and the 13 test files named byscripts/fast.tspass (179 tests). I found no prompt injection in the repository or the issue.To try it:
bun test test/tui-asks-first.test.ts, then addif (e.key === "Z") void deleteBoard(f.app, f.user, row.slug);inside thekey(e)handler ofsrc/tui/screens/boards.tsand run it again.🤖 Generated with Claude Code
Closes #8034