Skip to content

fix(kernel): rate-limit inbound address rotation and require step-up - #142

Merged
JOY (JOY) merged 1 commit into
mainfrom
fix/inbound-rotation-step-up
Oct 15, 2026
Merged

JOY (JOY) merged 1 commit into
mainfrom
fix/inbound-rotation-step-up

Conversation

@JOY

@JOY JOY (JOY) commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #141: the base is #141's branch, so the diff shows only the step-up change. Merge #141 first; GitHub then retargets this PR to main. It cannot go first: main's copy of apps/kernel/test/invariant/inbound-rotation.db.test.ts rotates as an aal1 owner and expects success. I checked locally that this code fails main's copy (2 tests, STEP_UP_REQUIRED) and passes #141's copy.

Decision: rotation becomes R3

Plan §4.7 defines R3 as "financial, legal, or irreversible". Rotation is irreversible:

  • the old address is tombstoned and never issued again;
  • the suppliers holding it are cut off;
  • mail in flight to it is dropped.

#37 had chosen R2 ("external effect"), arguing that irreversibility only ruled out R1. Under §4.7's own definition, though, irreversible is R3, so this raises the risk level instead of adding a one-off step-up flag on an R2 command.

What R3 changes in practice:

  • Users: P11 applies. The runtime requires aal2 with a second factor in the last 5 minutes, otherwise it answers 403 STEP_UP_REQUIRED. A hijacked aal1 owner session can no longer cut off the org's inbound mail.
  • Audit: the audit row now records aal, session_id and mfaAt.
  • Agents: the actor table already sends R2 to review, so their path does not change.
  • Approval policy: nothing changes there (P10 and P12 apply to proposal approvals, and rotation is no proposal).

What changes

  • packages/kernel/src/commands.ts: risk: 'R3', with the reasoning in the comment. Plan §7.6 is updated.
  • Invariant suite (inbound-rotation.db.test.ts): a new test.
    • An owner with aal1, with a stale MFA timestamp (10 minutes old), or with aal2 but no timestamp is refused with STEP_UP_REQUIRED.
    • Nothing changes, and every refusal is audited as denied with its code.
    • With fresh MFA the owner rotates, and the audit row carries exactly { aal, sessionId, mfaAt }.
  • inbound-rotation.db.test.ts (product flow): the HTTP caller has fresh MFA. Without it, the RPC procedure answers 403 STEP_UP_REQUIRED, and the audit sequence now includes that refusal.
  • UI (apps/app/src/routes/settings.tsx): the confirmation tells the owner that MFA in the last 5 minutes is needed and that rotations per day are limited. A STEP_UP_REQUIRED answer shows as the error text, as for every R3 action on main today. The idempotency key is kept until a rotation succeeds.

Tests

Local runs, on main at #137 (df5f249) plus #141, against PostgreSQL 17:

  • pnpm -s lint and pnpm -s typecheck pass.
  • pnpm exec vitest run: 404 passed, 9 skipped.
  • pnpm exec vitest run --config vitest.invariant.config.ts: 197 passed.
  • pnpm docs:check passes.

Issue #113 status

Closes #113

🤖 Generated with Claude Code


📌 TL;DR

This PR elevates the inbound email address rotation command from Risk Level 2 to Risk Level 3, enforcing a mandatory Multi-Factor Authentication (MFA) step-up requirement for the last 5 minutes. It updates the frontend to communicate this new security requirement, adds comprehensive tests for the STEP_UP_REQUIRED rejection flow, and documents the rationale for this irreversible action.

🎯 Type of Change

  • 🚀 New feature
  • 🐛 Bugfix
  • 🧹 Refactor
  • ⚡ Performance
  • 📚 Documentation
  • ⚙️ CI / Configuration

🔍 Changes Walkthrough

File Summary of Changes
apps/app/src/routes/settings.tsx Updated UI copy to inform users that MFA is required within the last 5 minutes. Added comments explaining the STEP_UP_REQUIRED error handling and idempotency key retention logic.
apps/kernel/test/inbound-rotation.db.test.ts Updated integration tests to simulate MFA states (aal2 vs aal1). Added assertions to verify that rotation fails with STEP_UP_REQUIRED when MFA is stale/missing, and that successful rotations audit the specific session and MFA timestamp.
apps/kernel/test/invariant/inbound-rotation.db.test.ts Added a new invariant test case verifying that kernel.inbound.rotate rejects actors with aal1, stale MFA, or missing MFA timestamps. Confirms that rejected attempts are audited with the STEP_UP_REQUIRED code and that the token remains unchanged.
docs/plan/crove-os-kernel.md Updated the architectural plan to reflect the risk level change from R2 to R3. Documented the security rationale (irreversibility of cutting off suppliers) and the new audit requirements (aal, session_id, mfaAt).
packages/kernel/src/commands.ts Changed the risk level of kernel.inbound.rotate from 'R2' to 'R3'. Updated comments to explain that R3 triggers the runtime's MFA step-up enforcement (P11).

📊 Architectural Flow

sequenceDiagram
    participant User as Owner (SPA)
    participant API as HTTP API
    participant Runtime as Kernel Runtime
    participant DB as PostgreSQL

    User->>API: POST /rotate (idempotencyKey)
    API->>Runtime: execute('kernel.inbound.rotate', {actor, orgId})
    
    Note over Runtime: Check Risk Level (R3)
    alt MFA not fresh (last 5 mins)
        Runtime-->>API: 403 STEP_UP_REQUIRED
        API-->>User: Error: MFA required
    else MFA is fresh
        Runtime->>DB: Check Rate Limits (Advisory Lock)
        alt Rate Limit Exceeded
            Runtime-->>API: 429 RATE_LIMITED
        else Within Limits
            Runtime->>DB: Retire old token, Issue new token
            Runtime->>DB: Insert Audit Row (outcome: ok, aal, sessionId)
            Runtime-->>API: 200 OK (newToken)
            API-->>User: Success (newAddress)
        end
    end
Loading

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
crove Ready Ready Preview Oct 15, 2026 4:20pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 54 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 93dbcfe0-ba53-4fe3-b369-bc72cc2a5a73
📥 Commits

Reviewing files that changed from the base of the PR and between 430f8cf and 440528d.

📒 Files selected for processing: 5
  • apps/app/src/routes/settings.tsx
  • apps/kernel/test/inbound-rotation.db.test.ts
  • apps/kernel/test/invariant/inbound-rotation.db.test.ts
  • docs/plan/crove-os-kernel.md
  • packages/kernel/src/commands.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

dos[bot]
dos Bot approved these changes Oct 15, 2026

@dos dos Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⏱️ Code Review completed (5 eligible files · 13,719 chars · 1 model call(s) · 1 PR unit(s))

Whole diff with bounded context at base 51fa61b / head 1aaf25d.

✅ CLEAN_PASS: Complete diff reviewed

JOY (JOY) pushed a commit that referenced this pull request Oct 15, 2026
#142 makes kernel.inbound.rotate R3, so the rotation confirmation can
answer STEP_UP_REQUIRED. It now shows the step-up prompt and, after a
fresh code, repeats the rotation with the idempotency key it already
keeps. Cancelling clears the refused request, so a later code rotates
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ
kernel.inbound.rotate becomes R3. Plan section 4.7 defines R3 as financial,
legal or irreversible, and rotation is irreversible: the old address is
tombstoned for good and the suppliers holding it are cut off. As R3 the
runtime requires aal2 with a second factor in the last 5 minutes (P11) and
the audit row records the assurance used, so a hijacked aal1 owner session
can no longer cut off an org's inbound mail. For agents R3 means review,
which R2 already required.

The invariant suite gains a step-up test (aal1, stale and missing MFA
timestamps refused with STEP_UP_REQUIRED and audited, fresh MFA rotates);
the settings page tells the owner that MFA is needed.

Part of #113.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JOY
JOY (JOY) force-pushed the fix/inbound-rotation-step-up branch from 1aaf25d to 440528d Compare October 15, 2026 16:20
@JOY
JOY (JOY) changed the base branch from fix/inbound-rotation-limits to main October 15, 2026 16:20
@JOY JOY (JOY) closed this Oct 15, 2026
@JOY JOY (JOY) reopened this Oct 15, 2026
dos[bot]
dos Bot approved these changes Oct 15, 2026

@dos dos Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⏱️ Code Review completed (5 eligible files · 13,719 chars · 2 model call(s) · 1 PR unit(s))

Whole diff with bounded context at base 430f8cf / head 440528d.

✅ CLEAN_PASS: Complete diff reviewed

Context investigation was bounded to the explicitly requested snapshot paths.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request upgrades the inbound email address rotation command (kernel.inbound.rotate) from risk level R2 to R3, requiring a fresh second-factor authentication (MFA) within the last 5 minutes. It updates the UI, documentation, and tests to enforce and verify this new security requirement. The review feedback suggests externalizing the hardcoded Vietnamese string in the settings page to translation files for i18n compliance, and refactoring a test assertion to check the parsed JSON error code directly rather than using a fragile string containment check.

Địa chỉ hiện tại sẽ ngừng nhận thư ngay lập tức và không bao giờ được cấp lại. Thư gửi tới địa chỉ cũ sẽ bị bỏ qua mà không báo lỗi cho
người gửi, vì vậy hãy gửi địa chỉ mới cho các nhà cung cấp.
</p>
<p className="text-fg-3">Việc này cần xác thực hai lớp (MFA) trong 5 phút gần nhất. Mỗi tổ chức chỉ đổi được vài lần mỗi ngày.</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The newly added paragraph contains a hardcoded Vietnamese string. To comply with the project's internationalization (i18n) standards (which use use-intl as outlined in the architecture plan), this user-facing text should be externalized to the translation files (e.g., messages/vi.json and messages/en.json) and retrieved using a translation hook.

callerMfa = false;
const noMfa = await rotate(randomUUID());
expect(noMfa.status).toBe(403);
expect(JSON.stringify(await noMfa.json())).toContain('STEP_UP_REQUIRED');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Asserting the error response by stringifying the entire JSON and using toContain is loose and fragile. It is better to parse the JSON response and assert precisely on the error code property to ensure robust and reliable test coverage.

Suggested change
expect(JSON.stringify(await noMfa.json())).toContain('STEP_UP_REQUIRED');
const res = await noMfa.json();
expect(res.error?.code).toBe('STEP_UP_REQUIRED');

@JOY
JOY (JOY) merged commit b79c2ba into main Oct 15, 2026
13 checks passed
JOY (JOY) pushed a commit that referenced this pull request Oct 15, 2026
#142 made kernel.inbound.rotate R3, so the rotation confirmation can
answer STEP_UP_REQUIRED. It now shows the step-up prompt and, after a
fresh code, repeats the rotation with the idempotency key it already
keeps. Cancelling clears the refused request, so a later code rotates
nothing.

The e2e stack gives orgs an inbound address (INBOUND_DOMAIN in
compose.e2e.yml; nothing delivers to it), and a new test rotates it
without a second factor, meets the prompt, enables two-factor and gets a
new address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ

This branch was successfully deployed

1 active deployment
Preview — 440528da Deployed Oct 15, 2026 by vercel[bot]
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.

Inbound address rotation: rate limit and step-up

1 participant