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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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 onChoose or onSubmit block, found by matching braces backwards from the call.
  • Only on a y: either if (… "y") call(…) on the same line, or inside the block of an if that checks y, 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.ts and deploys.ts pass a named choose function as onChoose.
  • deploys.ts stopEnv runs from act, after the Stop confirm.
  • boards.ts remove is called only on the y of the delete y/n.
  • about.ts first calls detachFromParent as a dry run; the real call is inside onChoose.

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

Closes #8034

commented

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 onChoose or onSubmit block, found by matching braces backwards from the call.
  • Only on a y: either if (… "y") call(…) on the same line, or inside the block of an if that checks y, 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.ts and deploys.ts pass a named choose function as onChoose.
  • deploys.ts stopEnv runs from act, after the Stop confirm.
  • boards.ts remove is called only on the y of the delete y/n.
  • about.ts first calls detachFromParent as a dry run; the real call is inside onChoose.

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

Changes requested — before the latest push

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.

  1. (medium) src/tui/screens/egress.ts:94 revokes on a bare x with 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.

  1. (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.
  1. (low) There is no unit case for the onSubmit: property form with an async callback, or for a named choose passed as onChoose. Add one or the other, since the allowlist leans on both.
commented

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 onChoose or onSubmit block, found by matching braces backwards from the call.
  • Only on a y: either if (… "y") call(…) on the same line, or inside the block of an if that checks y, 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.ts and deploys.ts pass a named choose function as onChoose.
  • deploys.ts stopEnv runs from act, after the Stop confirm.
  • boards.ts remove is called only on the y of the delete y/n.
  • about.ts first calls detachFromParent as a dry run; the real call is inside onChoose.

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, onSubmit written as a method and as an async property, a y guard, imports, comments, and method calls like f.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 — before the latest push

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:

  1. 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.
  2. 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.
commented

The full-suite proof of this head is waiting: no check slot is spare. It starts when one frees.

commented

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 onChoose or onSubmit block, found by matching braces backwards from the call.
  • Only on a y: either if (… "y") call(…) on the same line, or inside the block of an if that checks y, 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.ts and deploys.ts pass a named choose function as onChoose.
  • deploys.ts stopEnv runs from act, after the Stop confirm.
  • boards.ts remove is called only on the y of the delete y/n.
  • about.ts first calls detachFromParent as a dry run; the real call is inside onChoose.

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: deleteBoard in boards.ts's key handler, and deleteWebhook in hooks.ts's, once as a call and once as onChoose: deleteWebhook.
  • bun scripts/gate.ts and bun x tsc --noEmit are clean.
  • At the egress commit, the 25 test files named by scripts/fast.ts passed (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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs