Skip to content

fix(exit-plan): harden permission metadata and UI for exit-plan-mode#413

Open
xyzhang626 wants to merge 8 commits intotiann:mainfrom
xyzhang626:fix/exit-plan-mode-main-pr
Open

fix(exit-plan): harden permission metadata and UI for exit-plan-mode#413
xyzhang626 wants to merge 8 commits intotiann:mainfrom
xyzhang626:fix/exit-plan-mode-main-pr

Conversation

@xyzhang626
Copy link
Copy Markdown
Contributor

Summary

  • Preserve exit-plan session continuity: inject PLAN_FAKE_RESTART with full mode snapshot (permissionMode, model, effort, appendSystemPrompt) so agent continues correctly after plan approval
  • Add implementationMode support (keep_context / clear_context): when user approves with "clear context", clear session and requeue the approved plan for a fresh-context restart
  • Normalize permission metadata: add decision field to permission responses, normalize invalid post-plan modes (e.g. plandefault), and propagate implementationMode through the full stack (CLI → Hub → Web)
  • Web UI for exit-plan-mode: new ExitPlanModeFooter component with mode selector and permission controls; polish tool card rendering for plan approval flow
  • Comprehensive test coverage: tests for PermissionHandler, Session.clearSessionId, toolResultPermissions, reducerTimeline, normalizeAgent, ExitPlanModeFooter, ToolCard, and ExitPlanModeView

Test plan

  • permissionHandler.test.ts: exit_plan_mode defaults to keep_context, clear_context clears session, invalid modes normalize to default
  • session.test.ts: clearSessionId resets session ID
  • toolResultPermissions.test.ts: permission metadata extraction from tool results
  • reducerTimeline.test.ts: implementationMode preserved through reducer
  • normalizeAgent.test.ts: parentUUID propagation
  • ExitPlanModeFooter.test.tsx: mode selector UI and approval flow
  • ToolCard.test.tsx: exit-plan-mode card rendering
  • ExitPlanModeView.test.tsx: view rendering with implementation mode
  • permissions.test.ts (hub): permission route metadata handling
  • sessionModel.test.ts (hub): agent state normalization

🤖 Generated with Claude Code

via HAPI

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] clear_context still carries over prior "allow for this session" grants into the restarted Claude session, so the fresh-context path can silently auto-approve later tools without asking again. The new branch only clears claudeSessionId, but the in-memory allowlists are still consulted by handleToolCall, evidence cli/src/claude/utils/permissionHandler.ts:366 and cli/src/claude/utils/permissionHandler.ts:416.

Summary
Review mode: initial

  • 1 major issue found in cli/src/claude/utils/permissionHandler.ts.

Testing

  • Not run (automation environment does not have bun)

HAPI Bot

this.permissionMode = permissionMode;
this.session.setPermissionMode(permissionMode);

if (implementationMode === 'clear_context') {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] clear_context only clears Claude's persisted session id here, but the handler still keeps the previous session-scoped allowlists in memory (allowedTools, allowedBashLiterals, allowedBashPrefixes). handleToolCall() keeps consulting those sets later in the restarted session, so the new "fresh context" path can silently inherit old allow for this session approvals.

Suggested fix:

if (implementationMode === 'clear_context') {
    this.allowedTools.clear()
    this.allowedBashLiterals.clear()
    this.allowedBashPrefixes.clear()
    this.session.clearSessionId()
}

In clear_context mode, only the persisted session ID was cleared but
the in-memory allowedTools, allowedBashLiterals, and allowedBashPrefixes
sets were preserved. This let stale "allow for this session" approvals
carry over into the fresh-context restart.

Clear all three sets alongside clearSessionId() so the new session
starts with a clean permission slate.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Exit-plan approvals from the web UI can no longer carry a non-default post-plan permission mode. The new footer only submits implementationMode, while the hub and CLI still accept and apply mode, so approving a plan from the web now always restarts in default instead of allowing acceptEdits or bypassPermissions. Evidence web/src/components/ToolCard/ExitPlanModeFooter.tsx:100, web/src/components/ToolCard/ToolCard.tsx:473, hub/src/web/routes/permissions.ts:69, cli/src/claude/utils/permissionHandler.ts:354.
    Suggested fix:
    const [selectedPermissionMode, setSelectedPermissionMode] =
        useState<'default' | 'acceptEdits' | 'bypassPermissions'>('default')
    
    await run(() => props.api.approvePermission(props.sessionId, permission.id, {
        mode: selectedPermissionMode,
        implementationMode: selectedMode
    }), 'success')

Summary
Review mode: follow-up after new commits

  • 1 major issue found in the exit-plan web approval flow.
  • Existing tests cover implementationMode, but not selecting or preserving the post-plan permission mode.

Testing

  • Not run (automation environment does not have bun)

HAPI Bot

}

setLoading('approve')
await run(() => props.api.approvePermission(props.sessionId, permission.id, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This new footer only posts implementationMode, so the web UI has lost the ability to send the post-plan permission mode (acceptEdits / bypassPermissions). The backend and CLI still accept and apply mode, which means every exit-plan approval from the web now restarts in default instead of the user-selected mode. Evidence: hub/src/web/routes/permissions.ts:69, cli/src/claude/utils/permissionHandler.ts:354.

Suggested fix:

const [selectedPermissionMode, setSelectedPermissionMode] =
    useState<'default' | 'acceptEdits' | 'bypassPermissions'>('default')

await run(() => props.api.approvePermission(props.sessionId, permission.id, {
    mode: selectedPermissionMode,
    implementationMode: selectedMode
}), 'success')

The footer only sent implementationMode, so every exit-plan approval
from the web restarted in default permission mode regardless of user
intent.

Derive mode from the implementation choice:
- keep_context  → default (conservative, approve tools individually)
- clear_context → acceptEdits (trust the plan, auto-approve edits)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] web/src/components/ToolCard/ExitPlanModeFooter.tsx:100 still couples the post-plan permission mode to implementationMode. Choosing clear_context now always submits mode: 'acceptEdits', so a user who only asked for a fresh Claude session will restart with all edit tools auto-approved (cli/src/claude/utils/permissionHandler.ts:354, cli/src/claude/utils/permissionHandler.ts:452). It also still leaves no way to choose bypassPermissions or acceptEdits with keep_context from the web UI.

Summary
Review mode: follow-up after new commits

  • 1 major issue remains in the exit-plan approval flow.
  • Residual test gap: web/src/components/ToolCard/ExitPlanModeFooter.test.tsx:56 only locks in the hardcoded mapping, so it would not catch permission mode and implementation mode drifting apart again.

Testing

  • Not run (security policy: PR code not executed in review automation)

HAPI Bot


setLoading('approve')
await run(() => props.api.approvePermission(props.sessionId, permission.id, {
mode: selectedMode === 'clear_context' ? 'acceptEdits' : 'default',
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] implementationMode and the post-plan permission level are still being treated as the same choice here. With this mapping, picking clear_context silently upgrades the next Claude session to acceptEdits, which means edit tools will auto-allow after the restart (cli/src/claude/utils/permissionHandler.ts:452). It also still leaves no path to bypassPermissions, or to acceptEdits while keeping context.

Suggested fix:

const [selectedPermissionMode, setSelectedPermissionMode] =
    useState<'default' | 'acceptEdits' | 'bypassPermissions'>('default')

await run(() => props.api.approvePermission(props.sessionId, permission.id, {
    mode: selectedPermissionMode,
    implementationMode: selectedMode
}), 'success')

Let users choose the post-plan permission mode (Normal / Accept edits /
YOLO) alongside the implementation mode, instead of hardcoding it.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] web/src/components/ToolCard/ExitPlanModeFooter.test.tsx:56 still asserts the old coupling between implementationMode and permission mode, but the component now sends the independently selected permission mode (web/src/components/ToolCard/ExitPlanModeFooter.tsx:72, web/src/components/ToolCard/ExitPlanModeFooter.tsx:107). The first test now expects clear_context -> acceptEdits, so it no longer matches the implementation and leaves the new selector behavior under-covered.
    Suggested fix:
    fireEvent.click(screen.getByText('Clear context'))
    fireEvent.click(screen.getByText('Accept edits'))
    fireEvent.click(screen.getByRole('button', { name: 'Start implementation' }))
    
    expect(api.approvePermission).toHaveBeenCalledWith('session-1', 'request-1', {
        mode: 'acceptEdits',
        implementationMode: 'clear_context'
    })

Summary
Review mode: follow-up after new commits

  • 1 major issue remains: the updated footer logic and its new web test file disagree, so the PR still carries a stale assertion and misses direct coverage for the new permission-mode selector.

Testing

  • Not run (automation; PR code not executed during review)

HAPI Bot

Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
})

it('submits the selected implementationMode with correct permission mode', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This test still encodes the old clear_context -> acceptEdits mapping, but the component now posts whatever selectedPermissionMode the user picked (web/src/components/ToolCard/ExitPlanModeFooter.tsx:72, web/src/components/ToolCard/ExitPlanModeFooter.tsx:107). As written, the first assertion no longer matches the implementation, and the new permission-mode selector is left without direct coverage.

Suggested fix:

fireEvent.click(screen.getByText('Clear context'))
fireEvent.click(screen.getByText('Accept edits'))
fireEvent.click(screen.getByRole('button', { name: 'Start implementation' }))

expect(api.approvePermission).toHaveBeenCalledWith('session-1', 'request-1', {
    mode: 'acceptEdits',
    implementationMode: 'clear_context'
})

… mode selector

Replace stale tests that assumed clear_context -> acceptEdits coupling.
Add coverage for all combinations: default (no selection), acceptEdits
with clear_context, and bypassPermissions with keep_context.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] clear_context restart still replays stale queued prompts — the new branch clears Claude's session id, but it enqueues the restart message with unshiftIsolate, which explicitly preserves the rest of the queue. Any user messages queued before approval will be replayed into the fresh session immediately after the restart prompt, so old-context instructions can still leak through. Evidence cli/src/claude/utils/permissionHandler.ts:366, cli/src/claude/utils/permissionHandler.ts:378, cli/src/utils/MessageQueue2.ts:181.
    Suggested fix:
    if (implementationMode === 'clear_context') {
        this.session.queue.pushIsolateAndClear(restartPrompt, restartMode)
    } else {
        this.session.queue.unshiftIsolate(restartPrompt, restartMode)
    }

Summary
Review mode: follow-up after new commits

  • 1 major issue remains: the new clear_context path clears Claude's session id, but it still preserves already-queued prompts, so stale pre-approval messages can bleed into the fresh-context restart.

Testing

  • Not run (automation; PR code not executed during review)

HAPI Bot

implementationMode,
permissionMode
});
this.session.queue.unshiftIsolate(restartPrompt, restartMode);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] clear_context is supposed to start from a fresh Claude session, but this branch still uses unshiftIsolate(), and that helper explicitly preserves the rest of the queue (cli/src/utils/MessageQueue2.ts:181). If the user sent anything while the plan approval was pending, those prompts will be replayed immediately after the restart prompt in the new session, so stale old-context instructions survive the restart.

Suggested fix:

if (implementationMode === 'clear_context') {
    this.session.queue.pushIsolateAndClear(restartPrompt, restartMode)
} else {
    this.session.queue.unshiftIsolate(restartPrompt, restartMode)
}

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