Skip to content

feat(freshclaude): approvals use Claude's own "don't ask again" rules, and plan exits show a plan card - #872

Merged
danshapiro merged 17 commits into
mainfrom
feat/freshclaude-native-approvals
Oct 23, 2026
Merged

danshapiro merged 17 commits into
mainfrom
feat/freshclaude-native-approvals

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

What users see

  • In a Freshclaude pane, when Claude asks to use a tool and offers to remember the answer, the approval card has a third button, "Allow and don't ask again". Above it, one plain line per rule says what will be remembered and where it is kept. For example: "Always allow Bash(npm test *) in this project (saved to .claude/settings.local.json)" or "Auto-accept file edits for the rest of this conversation". Claude itself then enforces that narrow rule. Before this change, Freshclaude's "Always allow Bash" let every Bash command through, and it was forgotten when the page reloaded.
  • When Claude finishes planning, the pane shows a "Plan ready for review" card instead of raw JSON. The card shows the plan as formatted text, the file the plan is saved in, and four choices: "Approve plan", "Approve and auto-accept edits", "Approve and stop asking for permission", and "Keep planning". "Keep planning" has an optional "What should change?" note. While a note is typed, the three Approve buttons are disabled and a line says the note is sent only with Keep planning, so the note cannot be lost by approving.
  • After a plan is approved, the pane's permission mode follows the mode Claude actually moved to, including after a restart and resume.
  • Freshcodex keeps its own "Always allow". It is now labelled "Always allow in this pane", with the tooltip "Until this pane reloads".

What changed

  1. Contract test against the pinned Claude CLI (D0). test/integration/providers/claude-permission-contract.{mjs,test.ts} runs the production sidecar against the local fake endpoint. It pins the CLI's permission suggestions, sending them back in both directions, and plan approvals in every mode.
  2. Rust check of Claude's permission updates (D2, D4). The new file is crates/freshell-freshagent/src/claude_permission_update.rs.
    • The suggestions Claude sends are cleaned to the documented shapes before they reach a client.
    • An approval whose updatedPermissions Claude would not accept is refused with INVALID_APPROVAL_DECISION. Nothing is sent to Claude, and the request stays open so the user can answer again.
    • The refusal names the bad update by its position and type only, so rule text such as a Bash command never reaches the log.
  3. Open approvals in the session snapshot now carry Claude's suggestions, and the shared client/server contract describes them (D3).
  4. The permission mode Claude moves to is saved with the session, so a resume after a restart starts in that mode (D5). The live mode tracking itself, D1, D6 and D7, had already landed in feat(freshclaude): live transcript — streamed text, tool rows, retries, notices and subagent activity while a turn runs #871 and is unchanged.
  5. Client side (D8, D9, D10).
    • Every approval answer is built in one place, src/lib/fresh-agent-permission-updates.ts, which also writes the plain-language rule lines.
    • The approval card gains Claude's own "Allow and don't ask again".
    • There is a new FreshAgentPlanApprovalCard.
    • Freshclaude's browser-only bypass is removed. Freshcodex keeps its own.
  6. The deck's approve button sends the same shared allow answer (D11).
  7. Browser tests (D12). The new freshclaude-approvals-rust.spec.ts drives the real sidecar against the CLI simulator. The live-transcript plan case now approves through the plan card.
  8. README (D13): one sentence about the new approvals and the plan card.

Tests (exact results)

  • cargo test -p freshell-freshagent --lib: 1510 passed.
  • cargo test -p freshell-ws --test freshagent_claude_approvals --test freshagent_claude_lifecycle --test freshagent_claude_live_rendering --test freshagent_control_reply: 2 + 12 + 2 + 17 passed. After the last fixes, freshagent_claude_approvals was rerun: 2 passed.
  • cargo clippy -p freshell-freshagent --all-targets -- -D warnings and cargo fmt --all -- --check: both clean.
  • pnpm run test:vitest run test/unit/client/components/fresh-agent test/unit/client/lib/fresh-agent-permission-updates.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/deck test/unit/shared/fresh-agent-contract.test.ts test/unit/claude-sidecar --config config/vitest/vitest.config.ts: 1481 passed.
  • pnpm run test:vitest run test/integration/providers/claude-permission-contract.test.ts --config config/vitest/vitest.config.ts (pinned CLI, local fake endpoint): 9 passed.
  • pnpm run typecheck:client: clean.
  • Cloud browser tests, pnpm run test:e2e test/e2e-browser/specs/freshclaude-approvals-rust.spec.ts test/e2e-browser/specs/fresh-agent-control-rust.spec.ts test/e2e-browser/specs/freshclaude-live-transcript-rust.spec.ts --project=chromium (cloud backend, commit f1c95da): 40 passed.

Follow-ups

  1. Existing bug: addPermissionRequest in src/store/freshAgentSlice.ts throws when a permission request arrives for a session the client has no record of.
  2. Already on main: eslint reports 5 errors in src/components/fresh-agent/FreshAgentView.tsx, none on lines this PR changed.
  3. The CLI simulator does not model a remembered rule stopping the next prompt. Only the real-CLI contract test (D0) covers that.
  4. A refused approval shows as the generic pane error banner. A dedicated message on the card would be clearer.
  5. docs/index.html (the static mock) does not show the plan card yet.
  6. Unrelated to this PR: freshell-session-host's provider_config_codex_linked_skills_and_discovery_survive_repeated_resume fails on any machine with a stray /tmp/node_modules folder, because its fixture lives under /tmp and node_modules folders above a resource are copied. Someone ran npm install ws in /tmp today. The local pre-push passed with FRESHELL_TEST_EXECUTABLE_TMP_DIR pointed at /var/tmp, and all 169 tests in that crate passed.

🤖 Generated with Claude Code

danshapiro and others added 17 commits October 23, 2026 08:41
… (D0)

A real-CLI contract over the production sidecar, the pinned SDK 0.3.237
and its CLI 2.1.237 against the local provider: the suggestion shapes for
Bash, Write, a Read outside the workspace, a compound command and ls /;
that a localSettings rule sent back as updatedPermissions writes
.claude/settings.local.json and stops the next ask for that command only;
that a session rule does the same with no file; that ExitPlanMode carries
the plan file's text and path when the model wrote one; and that a plain,
acceptEdits or bypassPermissions plan approval leaves plan mode for that
mode, stated once by the sidecar, with the next Write asking or not to
match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude_permission_update.rs keeps only well-formed updates of the SDK's
documented variants (addRules, replaceRules, removeRules, setMode,
addDirectories, removeDirectories), rebuilt with exactly their documented
keys, so a suggestion relayed into the strict snapshot can never fail the
client's parse. validate_decision accepts a decision whose
updatedPermissions is absent or a list of such updates, and refuses
anything else. The next commits wire both in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pending-approval fold keeps an sdk.permission.request's suggestions,
normalized at fold time, and the snapshot overlay emits them as
pendingApprovals[].suggestions (absent when none survive). The shared
contract gains FreshAgentPermissionUpdateSchema, a strict discriminated
union mirroring the server's normalizer, so a well-formed list parses and
one malformed suggestion fails the strict snapshot parse, which is why
the server normalizes first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d not accept (D4)

handle_approval_respond now checks the decision with validate_decision
before writing it to the sidecar. A malformed updatedPermissions list is
answered with freshAgent.error{code:'INVALID_APPROVAL_DECISION'} and a
warn log (freshagent.claude.approval_decision_refused); nothing reaches
the CLI and the request stays pending, so a retry with well-formed
updates lands verbatim. A new WS test over the real sidecar and the CLI
simulator pins the whole path: the CLI's suggestions reach the snapshot
normalized, a malformed list is refused with the card kept, and the
suggestions sent back reach the CLI exactly, whose accepted setMode then
reaches every client as the session's mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (D5)

When the CLI changes its own mode (an approved ExitPlanMode, or a setMode
suggestion the user accepted), the stdout consumer already folded it into
the session's live settings. It now also writes the session's binding
row, through the same adoption a settings apply uses, so a resume after
a server restart boots the conversation in the mode the CLI was in
rather than the mode the pane created it with. Pinned over the real
sidecar and the CLI simulator with a durable ledger and a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uage suggestion lines (D8)

src/lib/fresh-agent-permission-updates.ts builds every approval answer:
a plain allow (never updatedInput), an allow that applies exactly the
suggestions shown, a deny that keeps the turn going, and the plan card's
four choices. describePermissionUpdate says in one line what accepting a
suggestion does and where it is kept (this conversation, the project's
.claude/settings.local.json or settings.json, or every project), and
rememberLabel names the remember button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the claude client-only bypass (D8, D10)

The approval card answers through one onRespond(decision) built by the
shared helpers. When the CLI suggested permission updates, it offers
"Allow and don't ask again" (or "Allow and auto-accept edits" for a
lone acceptEdits switch), spells each suggestion out underneath (the
button's description), and sends exactly those suggestions as
updatedPermissions, so Claude itself stops asking and enforces the
scoped rule. The client-only bypass renders only when the caller passes
onAlwaysAllow: the view does so for codex and opencode, relabelled
"Always allow <Tool> in this pane" with the tooltip "Until this pane
reloads" (decision D3), and its auto-answer effect never runs for a
claude pane, so no claude request is answered without a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExitPlanMode is now reviewed as a plan instead of a tool call showing raw
JSON. FreshAgentPlanApprovalCard (role=alert, "Plan approval request")
renders input.plan as markdown, shows planFilePath, says so when no plan
text came, and offers the four choices of decision D4: "Approve plan"
(a plain allow, so the CLI returns to its pre-plan mode), "Approve and
auto-accept edits" and "Approve and stop asking for permission" (a
session setMode), and "Keep planning" with an optional note sent as the
deny reason. Every choice is a 44 px touch target. The approval card
hands ExitPlanMode requests to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sendDeckApproval builds its decision with buildAllowDecision, so every
approval answer the client sends comes from one builder. The decision is
unchanged: a plain allow with no updatedInput.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecar (D12)

freshclaude-approvals-rust.spec.ts drives the real server, the production
sidecar and the CLI simulator: "Allow and don't ask again" sends the
CLI's suggestions as updatedPermissions and a later request waits for a
click; the plan card renders the plan, and "Approve and auto-accept
edits" moves the CLI to acceptEdits, the gear shows Accept edits and the
next send runs in it; kilroy gets the same remember button.

The claude lane's "Always Allow answers the second raise without a
click" is removed from fresh-agent-control-rust: it pinned the
client-only bypass this change retires for claude, and the new spec's
first case replaces it with the stronger opposite. Coverage of the kept
codex bypass moves to a new codex-lane case, "Always allow (pane)
auto-answers the second raise for that tool". The live-transcript plan
case now approves through the plan card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ClaudeSession::cli_session_id is read by the settings paths (a settings
apply, and now the CLI's own permission-mode change) to write the
session's binding row, so its doc no longer claims nothing reads it and
it drops the stale dead_code allow. The snapshot overlay's doc is
re-wrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ool card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan card's "Approve and stop asking for permission" also matches a
substring search for "Stop", so waitForIdle hit a strict-mode violation
while a plan card was up. The composer's button is named exactly
"Stop"; the lane helper and the live-transcript spec now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan card's "What should change?" note is sent only with "Keep
planning"; an approval carries no message. Clicking an approve button with
a note typed dropped the note and ran the plan unchanged, including
"Approve and stop asking for permission". While a note is typed the three
approve buttons are now disabled and described by a line saying the note
goes only with Keep planning; clearing it re-enables them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…by content

The `freshagent.claude.approval_decision_refused` warning and the
INVALID_APPROVAL_DECISION error frame carried the whole rejected permission
update, including rule content such as Bash commands and paths. Claude logs
carry ids, numbers and codes only. validate_decision now returns a typed
DecisionRefusal (not a list, or the failing update's index and its variant,
"unknown" for a type Claude does not define); the log records
update_index/update_type and the error frame says
"updatedPermissions[1] (addRules) is not a permission update Claude accepts".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kilroy is hidden unless the server sets KILROY_ENABLED and the README does
not otherwise mention it, so the sentence about Claude's own "don't ask
again" rules and the plan card names Freshclaude only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danshapiro
danshapiro merged commit 1216b86 into main Oct 23, 2026
7 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.

1 participant