From c4df7b2ea3ceb058bb80e36148321dd7aa7897b1 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 9 Jun 2026 16:22:23 +0200 Subject: [PATCH 1/4] docs(security-agent): add auto remediation spec --- .plans/security-agent-auto-remediation.md | 685 ++++++++++++++++++ .specs/security-agent.md | 412 +++++++++++ AGENTS.md | 1 + CONTEXT.md | 29 + ...rity-remediation-separate-from-auto-fix.md | 25 + 5 files changed, 1152 insertions(+) create mode 100644 .plans/security-agent-auto-remediation.md create mode 100644 .specs/security-agent.md create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-security-remediation-separate-from-auto-fix.md diff --git a/.plans/security-agent-auto-remediation.md b/.plans/security-agent-auto-remediation.md new file mode 100644 index 0000000000..14bc2dbd3f --- /dev/null +++ b/.plans/security-agent-auto-remediation.md @@ -0,0 +1,685 @@ +# Security Agent Auto Remediation Implementation Plan + +## Goal + +Add Security Agent Auto Remediation: an opt-in Security Agent feature that starts Cloud Agent remediation work for eligible Security Findings and records the pull request outcome. + +Auto Remediation must be separate from legacy Auto Fix. See [ADR 0001](../docs/adr/0001-security-remediation-separate-from-auto-fix.md). + +## Product Decisions + +- Feature name: **Auto Remediation**. +- Durable work item: **Security Remediation**. +- Per-run unit: **Security Remediation Attempt**. +- Auto Remediation is off by default. +- Manual action label: **Start remediation**. +- Security Agent does not use legacy `auto_fix_tickets`. +- Cloud Agent opens the pull request. Security Agent records, validates, and displays the result. +- No feature flag. +- No settings-time or launch-time GitHub write-permission preflight in Security Agent. +- Security Agent keeps findings `open` while remediations are queued, running, or PR-opened. + +## Current Integration Points + +- Security Agent config lives in `agent_configs` with `agent_type = 'security_scan'`. +- Auto Analysis admission currently lives in `apps/web/src/lib/security-agent/router/shared-handlers.ts` and `apps/web/src/lib/security-agent/db/security-analysis.ts`. +- Analysis callback finalization lives in `services/security-auto-analysis/src/callbacks.ts`. +- Cloud Agent sessions are launched through `prepareSession` plus `initiateFromPreparedSession`. +- Slack/Kilo Bot PR creation uses ordinary Cloud Agent sessions and prompt instructions, not a PR-specific API. + +## Data Model + +Update `packages/db/src/schema.ts` and generate migrations with `pnpm drizzle generate`. + +### Security Agent Config + +Extend `SecurityAgentConfigSchema`, defaults, router schemas, UI state, and worker config parsing: + +- `auto_remediation_enabled: boolean`, default `false` +- `auto_remediation_min_severity: 'critical' | 'high' | 'medium' | 'all'`, default `high` +- `auto_remediation_include_existing: boolean`, default `false` +- `auto_remediation_enabled_at: string | null` +- `remediation_model_slug: string` + +Behavior: + +- `remediation_model_slug` is always visible in settings. +- When unset, default it from the current analysis model on save/load, then treat it independently. +- When Auto Remediation toggles from off to on, set `auto_remediation_enabled_at` to now. +- Turning Auto Remediation off stops future automatic admission. It does not cancel running attempts. + +### New Table: `security_remediations` + +Parent row, one per Security Finding. + +Suggested fields: + +- `id` +- owner refs: `owned_by_organization_id`, `owned_by_user_id` +- `finding_id` unique, FK to `security_findings` +- `repo_full_name` +- `status`: `queued`, `running`, `pr_opened`, `failed`, `blocked`, `no_changes_needed`, `cancelled` +- latest outcome summary fields: + - `latest_attempt_id` + - `latest_analysis_fingerprint` + - `latest_analysis_completed_at` + - `pr_url` + - `pr_number` + - `pr_draft` + - `pr_head_branch` + - `pr_base_branch` + - `failure_code` + - `blocked_reason` + - `outcome_summary` +- timestamps: `created_at`, `updated_at`, `completed_at` + +Indexes: + +- owner + status +- finding id +- repo + status + +### New Table: `security_remediation_attempts` + +Queueable attempt row. A new attempt represents a new Cloud Agent remediation session. Launch retries before session creation stay on the same attempt. + +Suggested fields: + +- `id` +- `remediation_id`, FK +- `finding_id`, FK +- owner refs +- `repo_full_name` +- `origin`: `auto_policy`, `bulk_existing`, `manual` +- `status`: `queued`, `launching`, `running`, `pr_opened`, `failed`, `blocked`, `no_changes_needed`, `cancelled` +- `attempt_number` +- `retry_of_attempt_id` +- `requested_by_user_id` +- `analysis_fingerprint` +- `analysis_completed_at` +- `remediation_model_slug` +- `branch_name` +- Cloud Agent ids: + - `cloud_agent_session_id` + - `kilo_session_id` + - `execution_id` +- queue/claim fields: + - `priority` + - `claim_token` + - `claimed_at` + - `claimed_by_job_id` + - `launch_attempt_count` + - `next_retry_at` +- callback auth: + - `callback_attempt_token_hash` or equivalent safe token reference +- outcome fields: + - `failure_code` + - `blocked_reason` + - `last_error_redacted` + - `structured_result` + - `final_assistant_message` + - `validation_evidence` + - `risk_notes` + - `draft_reason` + - PR fields: `pr_url`, `pr_number`, `pr_draft`, `pr_head_branch`, `pr_base_branch` +- cancellation fields: + - `cancellation_requested_at` + - `cancellation_requested_by_user_id` +- timestamps: `queued_at`, `launched_at`, `completed_at`, `created_at`, `updated_at` + +Indexes: + +- owner claim path for `status = 'queued'` +- repo claim path for `status = 'queued'` +- owner in-flight path for `launching`/`running` +- repo in-flight path for `launching`/`running` +- remediation + attempt number unique +- finding + analysis fingerprint +- cloud agent session id + +Use guarded transactions to enforce: + +- one active queued/launching/running attempt per remediation +- one active running remediation per owner +- one active running remediation per repo +- no automatic duplicate for the same finding and analysis fingerprint after active or terminal semantic outcomes + +### Extend `security_agent_commands` + +Add: + +- `command_type: 'apply_auto_remediation'` +- `origin: 'settings_include_existing'` +- `result_metadata: jsonb` + +Use this only for include-existing backlog admission. Manual per-finding remediation returns remediation/attempt ids directly. + +### Soft Delete + +Update `softDeleteUser` for user-owned `security_remediations`, `security_remediation_attempts`, and any new command metadata if needed. Add tests in `apps/web/src/lib/user.test.ts`. + +## Eligibility Policy + +Implement one shared eligibility module used by web routers, Worker admission, reconciler, and UI summary. + +### Safety Gates + +Required for all origins: + +- finding belongs to the owner +- finding `status = 'open'` +- repo is currently selected/enabled for Security Agent based on local config +- analysis status is `completed` +- sandbox analysis exists +- `sandboxAnalysis.isExploitable === true` +- `sandboxAnalysis.suggestedAction === 'open_pr'` +- analysis is fresh relative to the latest finding sync +- latest analysis fingerprint is known +- there is no queued/launching/running remediation for the finding +- there is no known `pr_opened` remediation for the finding +- action is concrete enough: + - patched version plus package/manifest metadata, or + - concrete `sandboxAnalysis.suggestedFix`, or + - usage locations plus an actionable fix + +Do not allow remediation for: + +- `manual_review` +- `monitor` +- `isExploitable: 'unknown'` +- triage-only analysis + +### Automatic Gates + +For `auto_policy`: + +- Auto Remediation enabled +- severity meets `auto_remediation_min_severity` +- `analysis_completed_at >= auto_remediation_enabled_at` +- no active or terminal semantic outcome for the same analysis fingerprint + +For `bulk_existing`: + +- Auto Remediation enabled +- `auto_remediation_include_existing` enabled +- severity meets threshold +- older analyses may be admitted +- same fingerprint dedupe applies + +### Manual Gates + +Manual `Start remediation` bypasses only: + +- Auto Remediation enabled +- severity threshold +- enablement timestamp + +Manual still requires all safety gates. + +Manual retry is allowed for terminal `failed`, `blocked`, `no_changes_needed`, and `cancelled` attempts if safety gates still pass and no PR is open. It is not allowed for `pr_opened` in v1 because PR lifecycle is not synced. + +## Analysis Callback Admission + +In `services/security-auto-analysis/src/callbacks.ts`: + +1. Finalize completed sandbox analysis. +2. Run Auto Dismiss first. +3. If finding is still open, call idempotent Auto Remediation admission. +4. Do not wrap remediation admission in the same transaction as analysis finalization. +5. Do not fail or roll back completed analysis for expected remediation admission failures. + +Admission should: + +- compute analysis fingerprint +- check eligibility for `auto_policy` +- create/update parent remediation row +- create queued attempt +- enqueue attempt launch message +- write audit/analytics + +If queue admission fails after DB rows are created, either mark attempt failed with queue-admission failure or retry via callback queue if idempotent. + +## Include Existing Flow + +Product behavior should mirror Auto Analysis include-existing. + +Triggers during settings save: + +- `auto_remediation_include_existing` turns on while Auto Remediation is enabled +- Auto Remediation is re-enabled while include-existing is already on +- severity threshold changes while both Auto Remediation and include-existing are on + +Implementation: + +- settings save creates `security_agent_commands` row with `command_type = 'apply_auto_remediation'` +- enqueue command to a remediation command queue in `services/security-auto-analysis` +- command worker scans local eligible findings and admits `bulk_existing` remediation attempts idempotently +- command status stores counts in `result_metadata` + +Do not create a separate batch table. + +## Reconciler + +Add a narrow scheduled reconciler in `services/security-auto-analysis`. + +Purpose: + +- recover missed `auto_policy` admissions after callback/admission failures +- when include-existing is enabled, recover missed older eligible admissions too + +Rules: + +- create remediation attempts directly in small batches +- use the shared eligibility function +- never create duplicates for the same finding/fingerprint +- do not retry terminal semantic outcomes automatically + +## Attempt Launch Worker + +Add remediation attempt queue handling to `services/security-auto-analysis`. + +Claiming: + +- one active remediation per owner +- one active remediation per repo +- manual priority before bulk_existing before auto_policy +- launch retries use `launch_attempt_count` and `next_retry_at` + +Before launch, re-check local eligibility: + +- current finding status/config/selection +- current threshold for automatic origins +- current remediation state +- same repo/package/manifest open PR suppression based on existing `pr_opened` remediation records + +If a queued automatic attempt no longer qualifies: + +- mark `blocked`, not silently delete +- examples: `AUTO_REMEDIATION_DISABLED`, `BELOW_CURRENT_THRESHOLD`, `STALE_ANALYSIS`, `COVERED_BY_EXISTING_REMEDIATION_PR` + +## Cloud Agent Launch + +Use existing Cloud Agent session API: + +- `prepareSession` +- `initiateFromPreparedSession` + +Do not use legacy Auto Fix infra. +Do not use `autoCommit: true`. +Do not have Security Agent create the PR after callback. + +Prepare input: + +- `createdOnPlatform: 'security-remediation'` +- `mode: 'code'` +- `model: remediation_model_slug` +- `githubRepo: finding.repo_full_name` +- `upstreamBranch: deterministicBranchName` +- `callbackTarget` with scope `security-remediation-callback` +- remediation prompt + +Branch naming: + +- include package/advisory segment and stable ids +- include attempt number from the start +- example: `security-remediation/lodash-ghsa-xxxx/-1` +- cap length and sanitize aggressively + +Auth assumption to validate during implementation: + +- Security Agent may still need a Kilo API token to call Cloud Agent and own the session. +- That token must not imply the remediation actor or PR author. +- Cloud Agent should resolve repository write auth for `createdOnPlatform = 'security-remediation'`, preferably installation-only. +- Avoid passing a GitHub token from Security Agent if Cloud Agent can resolve repo auth from owner/session metadata. + +## Cloud Agent Guard + +Add a command guard for `createdOnPlatform = 'security-remediation'`. + +Allow: + +- repo inspection +- package manager install/update commands +- narrow test/build commands +- git branch/status/diff/add/commit/push as needed +- `gh pr create` +- `gh pr view` + +Deny: + +- PR merge/close/edit operations +- repo creation/forking/settings operations +- destructive shell commands +- arbitrary external network commands outside package manager and official metadata needs +- secrets access or credential exfiltration + +This guard should be tighter than Slack and less restrictive than Code Review. + +## Remediation Prompt + +Prompt must include: + +- finding metadata +- package/advisory metadata +- manifest path and package ecosystem +- patched version when present +- structured sandbox analysis +- raw sandbox analysis markdown in an untrusted context section +- stable Kilo finding URL +- required branch name +- required PR title/body guidance +- validation expectations +- prompt-injection warning for all finding/advisory/analysis text + +Prompt rules: + +- treat analysis as decision input; do not re-litigate exploitability +- make the smallest safe code change +- use the repo's actual package manager/lockfile workflow +- allow manifest, lockfile, Dockerfile, CI, and build/deploy changes only when directly required +- do not open no-change PRs +- open a draft PR if validation is incomplete or risk is nontrivial +- include Kilo finding backlink in PR body +- include validation and risk notes in PR body + +## Structured Result Contract + +Require final assistant response to contain a machine-readable block: + +```text +SECURITY_REMEDIATION_RESULT +{ ...json... } +END_SECURITY_REMEDIATION_RESULT +``` + +Suggested JSON: + +```json +{ + "status": "pr_opened", + "prUrl": "https://github.com/org/repo/pull/123", + "prNumber": 123, + "draft": false, + "headBranch": "security-remediation/pkg-ghsa/finding-1", + "baseBranch": "main", + "summary": "Updated vulnerable dependency and lockfile.", + "validation": [ + { + "command": "pnpm test -- package", + "outcome": "passed", + "summary": "Relevant tests passed." + } + ], + "riskNotes": "No breaking API changes expected.", + "draftReason": null, + "errorReason": null +} +``` + +Accepted statuses: + +- `pr_opened` +- `failed` +- `blocked` +- `no_changes_needed` +- `cancelled` + +Callback handling: + +- parse from `lastAssistantMessageText` +- if malformed, try PR recovery by expected branch +- if exactly one open PR exists for expected branch, mark `pr_opened` with warning +- if zero/multiple PRs, mark failed with `MISSING_REMEDIATION_RESULT` +- verify parsed/recovered PR exists and matches expected repo/branch before marking `pr_opened` + +`interrupted` callback mapping: + +- if cancellation requested, mark `cancelled` +- if no cancellation requested, mark `failed` with `CLOUD_AGENT_INTERRUPTED` + +If Cloud Agent returns `pr_opened` after cancellation was requested, persist the PR and mark `pr_opened`. + +## Cancellation + +Queued attempts: + +- cancel locally in a transaction +- parent becomes `cancelled` if this is the latest active attempt + +Running attempts: + +- set `cancellation_requested_at` and `cancellation_requested_by_user_id` +- keep status `running` +- call Cloud Agent `interruptSession` +- derive UI state as "cancelling" from running plus cancellation requested +- map interrupted callback to cancelled + +## API and Routers + +Extend shared Security Agent handlers. + +Settings: + +- personal: existing personal Security Agent mutation +- org: `organizationBillingMutationProcedure` + +Manual remediation actions: + +- `startRemediation` +- `retryRemediation` +- `cancelRemediation` +- personal: finding owner +- org: `organizationMemberMutationProcedure` + +Queries: + +- finding list returns latest remediation summary and capability reason enums +- finding detail returns remediation summary plus attempt history +- command status supports `apply_auto_remediation` + +Return from manual start: + +- `securityRemediationId` +- `securityRemediationAttemptId` + +## UI + +Update existing Security Agent UI. + +Config page: + +- Auto Remediation toggle +- severity threshold +- include existing analyzed findings toggle +- remediation model selector, always visible +- no GitHub write-permission preflight UI +- distinguish from legacy Auto Fix in copy + +Finding list: + +- remediation badge: queued, running, PR opened, failed, blocked, no changes, cancelled +- PR link when available +- disabled/action reason tooltip from server summary + +Finding detail: + +- Start remediation button when eligible +- Cancel for queued/running +- Retry for retryable terminal statuses +- attempt history with requester, model, status, PR, validation, failure/block reasons +- remediation audit events in finding timeline/history + +## Audit and Analytics + +Add Security Audit Log actions for user-visible milestones: + +- remediation requested/queued +- remediation started +- remediation PR opened +- remediation failed +- remediation blocked +- remediation no changes needed +- remediation cancelled +- remediation retried + +Audit resource: + +- `resource_type: 'security_remediation'` +- `resource_id: remediationId` +- metadata includes `findingId`, `attemptId`, `origin`, `requestedByUserId`, PR fields, analysis fingerprint + +Analytics: + +- config changed +- remediation admitted/requested +- launch started +- callback outcome +- PR opened +- failed/blocked/no changes +- manual retry/cancel +- include-existing command counts + +Automatic post-analysis remediation actor: + +- system/null actor +- preserve who enabled config via config audit +- preserve analysis trigger user only as analysis metadata + +Bulk/include-existing: + +- origin `bulk_existing` +- store settings user as `requested_by_user_id` + +## Failure Semantics + +Use separate remediation failure codes. Do not reuse Auto Analysis failure codes. + +Examples: + +- `LAUNCH_NETWORK_TIMEOUT` +- `LAUNCH_UPSTREAM_5XX` +- `CLOUD_AGENT_REPO_ACCESS_BLOCKED` +- `CLOUD_AGENT_INTERRUPTED` +- `MISSING_REMEDIATION_RESULT` +- `INVALID_REMEDIATION_RESULT` +- `INVALID_PR_OUTCOME` +- `PR_VERIFICATION_FAILED` +- `QUEUE_ADMISSION_FAILED` +- `AUTO_REMEDIATION_DISABLED` +- `BELOW_CURRENT_THRESHOLD` +- `STALE_ANALYSIS` +- `COVERED_BY_EXISTING_REMEDIATION_PR` + +Status semantics: + +- `blocked`: external/precondition blocker, not agent failure +- `failed`: execution or system failure +- `no_changes_needed`: terminal semantic outcome, not a failure code +- `pr_opened`: terminal for v1 + +## Testing + +Unit tests: + +- config schema defaults and save mapping +- eligibility policy for auto, bulk, manual, retry +- severity threshold helper +- analysis fingerprint helper +- branch naming +- result parser and malformed recovery +- failure/status mapping +- cancellation mapping + +DB/integration tests: + +- security_remediations owner constraints +- attempt status constraints +- one active attempt guard +- command ledger new type/origin/result metadata +- softDeleteUser cleanup + +Worker tests: + +- post-analysis admission after callback +- Auto Dismiss runs before Auto Remediation +- include-existing command scans and admits idempotently +- reconciler admits missed work without duplicating +- attempt claim ordering and owner/repo caps +- launch retry behavior +- Cloud Agent callback idempotency and stale token/session rejection + +Router tests: + +- settings save fields and include-existing command admission +- personal/org permissions +- manual start/retry/cancel +- list/detail remediation summary + +UI tests: + +- config controls render and save +- remediation badges and PR links +- button availability/reason mapping +- detail attempt history + +Targeted verification: + +- run relevant unit/integration tests only +- run `pnpm format` before committing +- generate migrations with `pnpm drizzle generate` + +## Implementation Phases + +### Phase 1: Schema and Config + +- add config fields/defaults +- add remediation tables +- extend command ledger +- update schema types, soft delete, migrations +- update context exports/types + +### Phase 2: Eligibility and Admission + +- implement shared eligibility module +- implement analysis fingerprinting +- add parent/attempt repository helpers +- add manual start/retry/cancel handlers +- add include-existing command admission + +### Phase 3: Worker Orchestration + +- add remediation attempt queue consumer +- add remediation command queue consumer +- add callback endpoint/queue handling +- add reconciler +- add Cloud Agent launch helper + +### Phase 4: Cloud Agent Contract + +- add `security-remediation` command guard +- build remediation prompt +- parse structured result +- verify/recover PR outcome +- wire cancellation via `interruptSession` + +### Phase 5: UI + +- config controls +- list/detail remediation summaries +- action buttons +- attempt history +- audit timeline inclusion + +### Phase 6: Observability and Polish + +- audit actions +- PostHog events +- operational logs +- failure reason text +- docs/readme updates if needed + +## Open Implementation Checks + +- Confirm Cloud Agent can launch a `security-remediation` session without Security Agent passing a GitHub token. +- Confirm how to generate the Kilo API token used only to authorize the Cloud Agent session for automatic/system-origin remediations. +- Confirm PR verification can use existing GitHub installation auth without reintroducing settings-time or launch-time preflight. diff --git a/.specs/security-agent.md b/.specs/security-agent.md new file mode 100644 index 0000000000..06de54af4e --- /dev/null +++ b/.specs/security-agent.md @@ -0,0 +1,412 @@ +# Security Agent + +## Role of This Document + +This spec defines the business rules and outcome guarantees for Security Agent Auto Remediation. It is the source of truth for what users should be able to rely on when Security Agent creates or manages remediation work for security findings. + +This document deliberately does not specify database tables, queue design, worker names, router names, UI layout, or prompt implementation details. Those belong in plans and code. + +## Status + +Draft -- created 2026-06-09. + +## Scope + +This spec covers the Auto Remediation capability of Security Agent. + +It does not backfill the complete Security Agent product spec. Existing Security Agent behavior such as finding sync, Auto Analysis, Auto Dismiss, dashboard statistics, SLA calculation, and Dependabot writeback is included only where it affects Auto Remediation outcomes. + +## Conventions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 RFC 2119 and RFC 8174 when, and only when, they appear in all capitals. + +BDD-style scenarios use "Given", "When", and "Then" to describe user-visible behavior. They are not intended to be exhaustive unit-test cases. + +## Definitions + +- **Security Finding**: A vulnerability item owned by a user or organization for a repository. +- **Auto Remediation**: The Security Agent feature that automatically starts Security Remediations for eligible Security Findings. +- **Security Remediation**: A Security Agent-owned remediation task for a Security Finding. +- **Security Remediation Attempt**: A single attempt to remediate a Security Finding through Cloud Agent. +- **Manual Remediation**: A user-triggered Security Remediation started from an eligible finding. +- **Automatic Remediation**: A Security Remediation started by Auto Remediation policy without a per-finding user click. +- **Bulk Existing Remediation**: Automatic remediation of already-analyzed findings included because the user enabled the include-existing setting. +- **Sandbox Analysis**: The completed codebase-level analysis that determines whether a finding is exploitable in the repository and whether opening a PR is the recommended action. + +## Configuration + +Auto Remediation MUST be off by default. + +Users MUST be able to configure: + +- whether Auto Remediation is enabled; +- the minimum severity threshold for automatic remediation; +- whether existing analyzed findings should be included; +- the model used for remediation. + +The severity threshold MUST use the same severity vocabulary as Auto Analysis: `critical`, `high`, `medium`, and `all`. The default threshold SHOULD be `high`. + +The remediation model setting MUST be visible even when Auto Remediation is disabled. If no remediation model has been chosen yet, the system SHOULD default it from the Security Agent analysis model and then treat it as independently configurable. + +### Scenario: Auto Remediation Is Explicitly Enabled + +Given a user has Security Agent configured +When the user has not enabled Auto Remediation +Then Security Agent MUST NOT automatically start remediation work for findings. + +Given a user enables Auto Remediation +When a future analysis produces an eligible finding at or above the configured severity threshold +Then Security Agent MUST automatically start a Security Remediation unless another safety, permission, or duplicate-suppression rule blocks it. + +### Scenario: Auto Remediation Is Disabled + +Given Auto Remediation was enabled +When the user disables Auto Remediation +Then Security Agent MUST stop starting new automatic remediations. + +Given a remediation attempt is already running +When Auto Remediation is disabled +Then the running attempt MUST NOT be stopped solely because the setting changed. + +Given Auto Remediation is disabled +When a user manually starts remediation for an eligible finding +Then the manual remediation MAY proceed. + +## Eligibility + +Security Agent MUST NOT start remediation unless the finding has a completed Sandbox Analysis. + +A finding is eligible for remediation only when all safety conditions are true: + +- the finding is still open; +- the finding belongs to the current user or organization context; +- the repository is currently in Security Agent scope; +- the latest relevant Sandbox Analysis is fresh enough for the current finding data; +- the Sandbox Analysis says the finding is exploitable; +- the Sandbox Analysis recommends opening a PR; +- the analysis provides a concrete enough remediation path. + +Findings whose analysis recommends `manual_review` or `monitor` MUST NOT be remediated automatically or manually through the one-click remediation flow. + +Findings whose exploitability is unknown MUST NOT be remediated automatically or manually through the one-click remediation flow, even if other analysis text appears to suggest a PR. + +Triage-only analysis MUST NOT be enough to start remediation. + +### Scenario: Eligible Finding + +Given a finding is open +And Sandbox Analysis says the finding is exploitable +And Sandbox Analysis recommends opening a PR +And the analysis describes an actionable remediation +When the finding is inside Security Agent scope +Then Security Agent MAY offer remediation for that finding. + +### Scenario: Ineligible Finding + +Given a finding is open +And the latest analysis says manual review is required +When the user views the finding +Then Security Agent MUST NOT offer "Start remediation" for that finding. + +Given a finding is open +And only triage analysis has completed +When the user views the finding +Then Security Agent MUST require Sandbox Analysis before remediation is available. + +### Scenario: Stale Analysis + +Given a finding was analyzed +And the finding's source data changed after that analysis +When a user or policy attempts remediation +Then Security Agent MUST require fresh analysis before starting remediation. + +## Automatic Remediation + +Automatic Remediation MUST respect the enabled setting and severity threshold. + +Automatic Remediation MUST act on every eligible finding that meets current policy unless duplicate suppression or another explicit exclusion applies. + +Automatic Remediation MUST only act on findings that become eligible through completed analysis after Auto Remediation was enabled, unless the include-existing setting applies. + +Automatic Remediation MUST NOT create duplicate remediation work for the same finding and same analysis result. + +### Scenario: Post-Analysis Automatic Remediation + +Given Auto Remediation is enabled +And the finding severity meets the configured threshold +When Sandbox Analysis completes and says the finding is exploitable and should be fixed with a PR +Then Security Agent MUST start a Security Remediation automatically unless duplicate suppression or another explicit exclusion applies. + +Given Auto Remediation is enabled +And the finding severity is below the configured threshold +When Sandbox Analysis completes and recommends opening a PR +Then Security Agent MUST NOT automatically start remediation. +And the user MAY still manually start remediation if the safety gates pass. + +### Scenario: Auto Dismiss Takes Precedence + +Given analysis determines a finding should be dismissed +When Auto Dismiss dismisses the finding +Then Auto Remediation MUST NOT start remediation for that finding. + +## Include Existing Findings + +Auto Remediation MUST support an include-existing setting that mirrors Auto Analysis behavior at the product level. + +When include-existing is enabled, Security Agent MUST apply Auto Remediation to all already-analyzed eligible findings under the current settings, including findings analyzed before Auto Remediation was enabled. + +Include-existing MUST be idempotent. Re-saving settings or re-enabling Auto Remediation MUST NOT create duplicate remediation attempts for a finding and analysis result that has already produced active or terminal remediation work. + +### Scenario: Include Existing Is Enabled + +Given Auto Remediation is enabled +And include-existing is turned on +When existing analyzed findings satisfy the Auto Remediation eligibility rules +Then Security Agent MUST start Security Remediations for all of those findings unless duplicate suppression or another explicit exclusion applies. + +### Scenario: Threshold Changes While Include Existing Is On + +Given Auto Remediation is enabled +And include-existing is enabled +When the user lowers the severity threshold +Then existing analyzed findings that newly meet the threshold MUST become eligible for Auto Remediation under include-existing. + +Given the user changes only the remediation model +When include-existing is enabled +Then Security Agent MUST NOT start new remediation work solely because the model changed. + +## Manual Remediation + +Users MUST be able to manually start remediation for eligible findings. + +Manual Remediation MUST bypass only automatic policy gates: + +- Auto Remediation does not need to be enabled; +- the finding does not need to meet the automatic severity threshold; +- the analysis does not need to have completed after Auto Remediation was enabled. + +Manual Remediation MUST still honor all safety gates. + +### Scenario: Manual Start Below Threshold + +Given a finding is eligible for remediation +And its severity is below the Auto Remediation threshold +When the user views the finding +Then Security Agent SHOULD offer "Start remediation". + +### Scenario: Manual Start While Auto Remediation Is Disabled + +Given Auto Remediation is disabled +And a finding is otherwise eligible for remediation +When the user clicks "Start remediation" +Then Security Agent SHOULD start a manual Security Remediation. + +### Scenario: Manual Start Is Unavailable + +Given a finding is not eligible for remediation +When the user views the finding +Then Security Agent SHOULD explain why remediation is unavailable. + +## Remediation Execution + +Cloud Agent is responsible for making the code changes and opening the PR. + +Security Agent MUST record the remediation outcome and expose it to the user. + +Cloud Agent SHOULD create the smallest safe change that addresses the finding. It MAY update manifests, lockfiles, Dockerfiles, CI configuration, or build/deploy files when those changes are directly required to remediate the vulnerable dependency. + +Cloud Agent SHOULD run the narrowest useful validation it can identify. Passing validation MUST NOT be required before a PR can be opened, but incomplete validation or meaningful risk SHOULD cause the PR to be opened as draft. + +Cloud Agent MUST NOT open a no-change PR. If no changes are needed, Security Agent MUST show a no-changes-needed outcome rather than a PR. + +### Scenario: PR Opened + +Given Cloud Agent successfully remediates a finding +When Cloud Agent opens a pull request +Then Security Agent MUST show the PR link on the finding. +And the finding MUST remain open until the source of truth reports it fixed or the user dismisses it. + +### Scenario: Draft PR + +Given Cloud Agent creates a concrete fix +And validation is incomplete or risk is nontrivial +When Cloud Agent opens the PR as draft +Then Security Agent MUST still treat the remediation as PR opened. +And Security Agent SHOULD label the PR as draft. + +### Scenario: No Changes Needed + +Given Cloud Agent determines no code changes are required +When no PR is opened +Then Security Agent MUST show a no-changes-needed outcome. +And Security Agent MUST NOT mark the finding fixed. +And automatic policy MUST NOT retry the same analysis result unless the finding is re-analyzed. + +### Scenario: Cloud Agent Cannot Remediate + +Given Cloud Agent cannot access the repository, cannot determine a safe change, or cannot open a PR +When the remediation attempt ends +Then Security Agent MUST show a failed or blocked outcome with a user-understandable reason. + +## PR Outcome Integrity + +Security Agent MUST NOT mark a remediation as PR opened unless it has a trustworthy PR outcome. + +The PR outcome MUST belong to the expected repository and remediation branch. If the structured result is malformed but Security Agent can unambiguously recover the PR from the expected branch, Security Agent MAY record the PR and show a warning internally. If no trustworthy PR can be identified, the attempt MUST be treated as failed. + +### Scenario: Malformed Result With Recoverable PR + +Given Cloud Agent opened a PR from the expected remediation branch +And the final result is malformed +When Security Agent can uniquely identify the PR +Then Security Agent MAY show the remediation as PR opened. + +### Scenario: Malformed Result Without Recoverable PR + +Given Cloud Agent's final result is missing or malformed +And Security Agent cannot uniquely identify the expected PR +When the attempt completes +Then Security Agent MUST show the attempt as failed rather than inventing a PR link. + +## Duplicate Suppression and Retry + +Security Agent MUST NOT run more than one active remediation attempt for the same finding. + +Security Agent MUST NOT create a second remediation PR for a finding when it already knows a PR has been opened for that finding. + +If another open remediation PR likely covers the same repository, package, and manifest, Security Agent SHOULD block later same-package remediation rather than opening a competing PR. + +Users MAY retry failed, blocked, no-changes-needed, or cancelled attempts if the finding still satisfies safety gates and no PR is already open. + +Automatic Remediation MUST NOT retry terminal semantic outcomes for the same analysis result. Manual retry MAY. + +### Scenario: Active Attempt Exists + +Given a finding already has a queued or running remediation attempt +When the user views the finding +Then Security Agent MUST NOT offer another "Start remediation" action. +And Security Agent SHOULD show the current remediation state. + +### Scenario: PR Already Opened + +Given Security Agent knows a remediation PR was opened for a finding +When the user views that finding +Then Security Agent MUST show the PR link. +And Security Agent MUST NOT offer retry in v1. + +### Scenario: Retry After Failure + +Given a remediation attempt failed +And the finding still satisfies safety gates +And no remediation PR is already open +When the user retries remediation +Then Security Agent SHOULD create a new attempt. + +## Cancellation + +Users SHOULD be able to cancel queued or running remediation attempts. + +Cancelling a queued attempt SHOULD stop it locally. + +Cancelling a running attempt SHOULD ask Cloud Agent to interrupt the running work. A cancellation request does not guarantee Cloud Agent stops before it creates a PR. + +### Scenario: Cancel Queued Attempt + +Given a remediation attempt is queued +When the user cancels it +Then Security Agent SHOULD mark the attempt as cancelled. +And Cloud Agent SHOULD NOT be launched for that attempt. + +### Scenario: Cancel Running Attempt + +Given a remediation attempt is running +When the user cancels it +Then Security Agent SHOULD show that cancellation has been requested. + +Given Cloud Agent confirms interruption after cancellation was requested +When the attempt finishes +Then Security Agent MUST mark the attempt as cancelled. + +Given Cloud Agent opens a PR after cancellation was requested +When Security Agent receives the PR outcome +Then Security Agent MUST show the PR as opened. + +## Permissions + +Personal remediation actions MUST be available only to the user who owns the finding. + +Organization Auto Remediation settings and include-existing policy changes MUST be restricted to users allowed to change Security Agent settings for that organization. + +Organization manual remediation actions MAY be available to organization members. Manual remediation MUST NOT require that the member is also a GitHub repository collaborator, because Cloud Agent uses the organization's configured integration path. + +### Scenario: Organization Member Starts Remediation + +Given a user is an organization member +And the user can access the organization's Security Agent findings +When the user starts remediation for an eligible finding +Then Security Agent MAY start a manual Security Remediation. + +### Scenario: Settings Permission + +Given a user is not allowed to change organization Security Agent settings +When the user tries to enable Auto Remediation or include existing findings +Then Security Agent MUST reject the settings change. + +## Finding Status and User Interface Outcomes + +Security Findings MUST remain open while remediation is queued, running, blocked, failed, no-changes-needed, cancelled, or PR-opened. A remediation PR is not the same as a fixed finding. + +Finding lists SHOULD show remediation state for open findings, including PR links when available. + +Finding detail SHOULD show remediation history, requester information for manual actions, validation evidence, risk notes, and failure/block reasons. + +The UI SHOULD derive action availability from server-provided capability state and reason codes rather than reimplementing eligibility rules client-side. + +### Scenario: PR Opened Finding Remains Open + +Given a remediation PR has been opened +When the user views open findings +Then the finding MUST still be counted as open. +And the finding SHOULD show a PR-opened remediation badge or link. + +### Scenario: Unavailable Remediation Reason + +Given a finding cannot currently be remediated +When the user views the finding +Then Security Agent SHOULD show a concise reason such as analysis required, sandbox analysis required, not exploitable, manual review required, stale analysis, remediation active, or PR already opened. + +## Audit and Traceability + +Security Agent MUST preserve enough history for users and operators to understand why a remediation happened and what outcome it produced. + +Manual start, retry, and cancel actions MUST record the requesting user. + +Bulk Existing Remediation MUST record the user who enabled or saved the include-existing setting that caused the work to be admitted. + +Automatic Remediation started after analysis SHOULD be attributed to system policy, while retaining analysis metadata separately. + +Remediation PRs SHOULD include a backlink to the Kilo Security Finding when a stable finding URL exists. + +### Scenario: Manual Remediation Audit + +Given a user manually starts remediation +When the remediation appears in history +Then Security Agent SHOULD show who requested it and when. + +### Scenario: Automatic Remediation Audit + +Given Auto Remediation starts a remediation after analysis +When the remediation appears in history +Then Security Agent SHOULD identify it as policy-driven automatic remediation. + +## V1 Exclusions + +The following are intentionally outside the guaranteed v1 behavior: + +- PR lifecycle sync after the PR is opened. +- Automatically marking findings fixed because a remediation PR exists. +- Retrying `pr_opened` attempts after the user manually closes a PR. +- Combining multiple Security Findings into one planned remediation. +- GitLab security finding remediation. +- Settings-time or launch-time repository write-permission preflight by Security Agent. +- Backfilling complete specifications for Security Agent features unrelated to Auto Remediation. diff --git a/AGENTS.md b/AGENTS.md index 2da4f024b8..b0e2e8b4b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,7 @@ Business-rule specs live in `.specs/`. Before making **any** changes to a domain | `.specs/kiloclaw-controller.md` | KiloClaw controller/machine lifecycle, bootstrap, Docker image | | `.specs/kiloclaw-datamodel.md` | KiloClaw data model — instance/subscription tables, invariants | | `.specs/model-experiments.md` | Model experiment routing, bucketing, lifecycle, prompt retention, and reporting rules | +| `.specs/security-agent.md` | Security Agent Auto Remediation business rules and user-facing outcomes | | `.specs/subscription-center.md` | Subscription Center ownership, states, and user-facing behavior | | `.specs/team-enterprise-seat-billing.md` | Team and Enterprise seat billing, subscription management | | `.specs/impact-affiliate-tracking.md` | Impact.com affiliate conversion tracking | diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..2bf2093375 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# Kilo Code Cloud + +Kilo Code Cloud is the platform for hosted Kilo Code agents, integrations, and automation. + +## Language + +**Security Agent**: +The agent that syncs, analyses, and helps resolve repository security findings. +_Avoid_: Security Reviews + +**Security Finding**: +A vulnerability item owned by a user or organization for a repository, usually synced from Dependabot. +_Avoid_: Security review, alert + +**Auto Remediation**: +The Security Agent feature that automatically starts Security Remediations for eligible Security Findings. +_Avoid_: Auto Fix + +**Security Remediation**: +A Security Agent-owned remediation task created from a Security Finding after analysis determines that a pull request is the right next step. +_Avoid_: Auto Fix ticket + +**Security Remediation Attempt**: +A single attempt to remediate a Security Finding through Cloud Agent, including its session and pull request outcome. +_Avoid_: Auto Fix run + +**Cloud Agent Write Identity**: +The identity Cloud Agent uses to push remediation branches and open pull requests for Security Remediations. +_Avoid_: Security Agent Bot diff --git a/docs/adr/0001-security-remediation-separate-from-auto-fix.md b/docs/adr/0001-security-remediation-separate-from-auto-fix.md new file mode 100644 index 0000000000..7380eebda7 --- /dev/null +++ b/docs/adr/0001-security-remediation-separate-from-auto-fix.md @@ -0,0 +1,25 @@ +# ADR 0001: Keep Security Remediation Separate From Legacy Auto Fix + +## Status + +Accepted + +## Context + +Security Agent needs an Auto Remediation feature that can create pull requests for eligible Security Findings after sandbox analysis determines that code remediation is the right next step. + +The repository already has an Auto Fix implementation, but that implementation is not trusted as a foundation for Security Agent remediation work. Reusing it would couple Security Agent to legacy Auto Fix tickets, prompts, states, and PR handling that do not match the Security Agent domain. + +## Decision + +Security Agent Auto Remediation will use Security Agent-owned remediation records instead of legacy Auto Fix tickets. + +Security Remediation is the durable unit of work for a Security Finding. Remediation attempts track individual Cloud Agent sessions and pull request outcomes. The legacy Auto Fix tables and orchestration are not the source of truth for Security Agent remediation. + +## Consequences + +Security Agent can define remediation eligibility, state, audit, retry, cancellation, and UI behavior in its own domain language. + +Cloud Agent remains responsible for performing the code changes and opening the pull request. Security Agent records and verifies the remediation outcome instead of delegating state ownership to legacy Auto Fix. + +This adds new schema and orchestration code, but avoids building a security workflow on top of an implementation that is not actively trusted or developed. From 525def1c12b5ae4ec2419ab49506ce6a2ad6f0aa Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 10 Jun 2026 21:01:43 +0200 Subject: [PATCH 2/4] feat(security-agent): add auto remediation --- .gitignore | 1 + .specs/security-agent.md | 29 +- .../security-agent/FindingDetailDialog.tsx | 423 +- .../security-agent/SecurityAgentContext.tsx | 295 +- .../security-agent/SecurityConfigForm.tsx | 10 + .../security-agent/SecurityConfigPage.tsx | 9 + .../security-agent/SecurityConfigSections.tsx | 99 +- .../security-agent/SecurityFindingRow.tsx | 147 +- .../security-agent/SecurityFindingsCard.tsx | 30 +- .../security-agent/SecurityFindingsPage.tsx | 28 +- .../remediation-unavailable-copy.ts | 32 + .../security-agent-command-copy.ts | 6 + ...ecurity-agent-command-invalidation.test.ts | 10 + .../security-agent-command-invalidation.ts | 15 +- .../security-agent/security-config-types.ts | 5 + .../src/lib/security-agent/core/constants.ts | 17 +- .../src/lib/security-agent/core/schemas.ts | 21 + apps/web/src/lib/security-agent/core/types.ts | 6 + .../security-agent/db/security-commands.ts | 19 + .../lib/security-agent/db/security-config.ts | 13 + .../security-agent/db/security-remediation.ts | 326 + .../lib/security-agent/posthog-tracking.ts | 4 + .../security-agent/router/shared-handlers.ts | 284 +- .../services/manual-analysis-client.test.ts | 2 + .../services/manual-analysis-client.ts | 2 + .../services/manual-remediation-client.ts | 167 + apps/web/src/lib/user/index.test.ts | 139 + apps/web/src/lib/user/index.ts | 20 +- .../organization-security-agent-router.ts | 9 + apps/web/src/routers/security-agent-router.ts | 9 + .../src/migrations/0162_sloppy_luke_cage.sql | 117 + .../db/src/migrations/meta/0162_snapshot.json | 30711 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema-types.ts | 8 + packages/db/src/schema.ts | 239 +- .../src/security-agent-command-repository.ts | 4 + packages/worker-utils/package.json | 1 + .../src/security-remediation-policy.test.ts | 217 + .../src/security-remediation-policy.ts | 313 + .../cloud-agent-next/src/session-service.ts | 119 +- .../security-auto-analysis/src/callbacks.ts | 20 + .../src/db/queries.test.ts | 1 + .../security-auto-analysis/src/db/queries.ts | 6 + .../security-auto-analysis/src/dispatcher.ts | 26 + .../security-auto-analysis/src/index.test.ts | 90 + services/security-auto-analysis/src/index.ts | 185 + .../security-auto-analysis/src/launch.test.ts | 49 + services/security-auto-analysis/src/launch.ts | 2 + .../src/manual-analysis.test.ts | 2 + .../src/manual-analysis.ts | 2 + .../src/remediation.test.ts | 59 + .../security-auto-analysis/src/remediation.ts | 1679 + .../security-auto-analysis/src/types.test.ts | 7 +- services/security-auto-analysis/src/types.ts | 32 +- .../worker-configuration.d.ts | 3 + .../security-auto-analysis/wrangler.jsonc | 66 + 56 files changed, 36072 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/components/security-agent/remediation-unavailable-copy.ts create mode 100644 apps/web/src/lib/security-agent/db/security-remediation.ts create mode 100644 apps/web/src/lib/security-agent/services/manual-remediation-client.ts create mode 100644 packages/db/src/migrations/0162_sloppy_luke_cage.sql create mode 100644 packages/db/src/migrations/meta/0162_snapshot.json create mode 100644 packages/worker-utils/src/security-remediation-policy.test.ts create mode 100644 packages/worker-utils/src/security-remediation-policy.ts create mode 100644 services/security-auto-analysis/src/remediation.test.ts create mode 100644 services/security-auto-analysis/src/remediation.ts diff --git a/.gitignore b/.gitignore index b54c998ca7..61d3f4708e 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ run-milvus-test.sh !.env.*.example !.env.test !.envrc +.env* diff --git a/.specs/security-agent.md b/.specs/security-agent.md index 06de54af4e..1485e18ff6 100644 --- a/.specs/security-agent.md +++ b/.specs/security-agent.md @@ -76,7 +76,7 @@ Then the manual remediation MAY proceed. Security Agent MUST NOT start remediation unless the finding has a completed Sandbox Analysis. -A finding is eligible for remediation only when all safety conditions are true: +A finding is eligible for automatic remediation only when all safety conditions are true: - the finding is still open; - the finding belongs to the current user or organization context; @@ -86,9 +86,11 @@ A finding is eligible for remediation only when all safety conditions are true: - the Sandbox Analysis recommends opening a PR; - the analysis provides a concrete enough remediation path. -Findings whose analysis recommends `manual_review` or `monitor` MUST NOT be remediated automatically or manually through the one-click remediation flow. +Findings whose analysis recommends `manual_review` MUST NOT be remediated automatically. Manual Remediation MAY proceed after the user reviews the finding when the analysis or source metadata still provides a concrete remediation path. -Findings whose exploitability is unknown MUST NOT be remediated automatically or manually through the one-click remediation flow, even if other analysis text appears to suggest a PR. +Findings whose analysis recommends `monitor` MUST NOT be remediated automatically or manually through the one-click remediation flow. + +Findings whose exploitability is unknown MUST NOT be remediated automatically. Manual Remediation MAY proceed after the user reviews the finding when the analysis recommends opening a PR or manual review and the analysis or source metadata provides a concrete remediation path. Triage-only analysis MUST NOT be enough to start remediation. @@ -106,7 +108,8 @@ Then Security Agent MAY offer remediation for that finding. Given a finding is open And the latest analysis says manual review is required When the user views the finding -Then Security Agent MUST NOT offer "Start remediation" for that finding. +Then Security Agent MUST NOT automatically start remediation for that finding. +And Security Agent MAY offer manual remediation if a concrete remediation path is available. Given a finding is open And only triage analysis has completed @@ -179,13 +182,14 @@ Then Security Agent MUST NOT start new remediation work solely because the model Users MUST be able to manually start remediation for eligible findings. -Manual Remediation MUST bypass only automatic policy gates: +Manual Remediation MUST bypass automatic policy gates and MAY use the reviewed-finding override described above: - Auto Remediation does not need to be enabled; - the finding does not need to meet the automatic severity threshold; - the analysis does not need to have completed after Auto Remediation was enabled. +- the analysis MAY have unknown exploitability or recommend manual review when the user chooses to proceed and a concrete remediation path exists. -Manual Remediation MUST still honor all safety gates. +Manual Remediation MUST still honor ownership, scope, freshness, concrete-fix, active-attempt, and duplicate-PR safety gates. ### Scenario: Manual Start Below Threshold @@ -207,6 +211,19 @@ Given a finding is not eligible for remediation When the user views the finding Then Security Agent SHOULD explain why remediation is unavailable. +### Scenario: Manual Start After Review + +Given a finding is open +And Sandbox Analysis completed with unknown exploitability or a manual-review recommendation +And the finding has a concrete dependency patch path or suggested fix +When the user reviews the finding and clicks "Start remediation" +Then Security Agent MAY start a manual Security Remediation. + +Given a finding has unknown exploitability or a manual-review recommendation +And the finding has no concrete dependency patch path or suggested fix +When the user views the finding +Then Security Agent SHOULD explain that remediation needs a concrete fix path. + ## Remediation Execution Cloud Agent is responsible for making the code changes and opening the PR. diff --git a/apps/web/src/components/security-agent/FindingDetailDialog.tsx b/apps/web/src/components/security-agent/FindingDetailDialog.tsx index 5baadc338b..2a157ad2c1 100644 --- a/apps/web/src/components/security-agent/FindingDetailDialog.tsx +++ b/apps/web/src/components/security-agent/FindingDetailDialog.tsx @@ -24,6 +24,9 @@ import { Brain, Loader2, Zap, + GitPullRequest, + RotateCw, + AlertCircle, } from 'lucide-react'; import type { SecurityFinding } from '@kilocode/db/schema'; import { useTRPC } from '@/lib/trpc/utils'; @@ -31,16 +34,39 @@ import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; import { useSecurityAgent } from './SecurityAgentContext'; import { securityAgentCommandAdmissionCopy } from './security-agent-command-copy'; +import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; +import type { SecurityFindingWithRemediation } from './SecurityFindingRow'; +import { getRemediationUnavailableCopy } from './remediation-unavailable-copy'; type Severity = 'critical' | 'high' | 'medium' | 'low'; type FindingAnalysis = SecurityFinding['analysis']; -type StartAnalysis = (options?: { retrySandboxOnly?: boolean }) => void; +type StartAnalysis = (options?: { forceSandbox?: boolean; retrySandboxOnly?: boolean }) => void; const ANALYSIS_POLL_INTERVAL_MS = 3000; const statusPanelClassName = 'rounded-lg border border-border bg-muted/40 p-3'; const linkClassName = 'text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none'; +type RemediationAttempt = { + id: string; + status: string; + origin: string; + attemptNumber: number; + remediationModelSlug: string; + branchName: string; + prUrl: string | null; + prDraft: boolean | null; + failureCode: string | null; + blockedReason: string | null; + lastErrorRedacted: string | null; + riskNotes: string | null; + draftReason: string | null; + queuedAt: string; + launchedAt: string | null; + completedAt: string | null; + updatedAt: string; +}; + function isSeverity(value: string): value is Severity { return ['critical', 'high', 'medium', 'low'].includes(value); } @@ -54,6 +80,30 @@ function LoadingSpinner({ className = 'size-4' }: { className?: string }) { ); } +function formatRemediationStatus(status: string | null | undefined): string { + if (!status) return 'Not started'; + if (status === 'pr_opened') return 'PR opened'; + if (status === 'no_changes_needed') return 'No changes needed'; + return status.replace(/_/g, ' '); +} + +function isActiveRemediationStatus(status: string | null | undefined): boolean { + return status === 'queued' || status === 'launching' || status === 'running'; +} + +function getRemediationFailureCopy(failureCode: string | null | undefined): string | null { + if (!failureCode) return null; + return 'Fix attempt failed. Check attempt details for next steps.'; +} + +function isCodebaseAnalysisRequiredReason(reason: string | null | undefined): boolean { + return ( + reason === 'analysis_required' || + reason === 'sandbox_analysis_required' || + reason === 'triage_only' + ); +} + function AnalysisStatusIcon({ status, fallback, @@ -75,7 +125,7 @@ function AnalysisStatusIcon({ } type FindingDetailDialogProps = { - finding: SecurityFinding | null; + finding: SecurityFindingWithRemediation | null; open: boolean; onOpenChange: (open: boolean) => void; onDismiss: () => void; @@ -318,6 +368,9 @@ function FindingTriage({ type FindingAnalysisProps = AnalysisPanelProps & { cliSessionId: string | null; organizationId?: string; + remediationNeedsCodebaseAnalysis: boolean; + codebaseAnalysisActionLabel: string; + onStartCodebaseAnalysis: () => void; }; function FindingAnalysis({ @@ -329,6 +382,9 @@ function FindingAnalysis({ onStartAnalysis, cliSessionId, organizationId, + remediationNeedsCodebaseAnalysis, + codebaseAnalysisActionLabel, + onStartCodebaseAnalysis, }: FindingAnalysisProps) { const sessionHref = cliSessionId ? organizationId @@ -430,7 +486,29 @@ function FindingAnalysis({ ); } else if (analysis?.triage?.needsSandboxAnalysis === false) { content = ( - + + {remediationNeedsCodebaseAnalysis && ( + + )} + ); } else if (!analysis) { content = ( @@ -438,11 +516,13 @@ function FindingAnalysis({ ); @@ -513,6 +593,126 @@ function EmptyPanel({ children, text }: { children?: React.ReactNode; text: stri ); } +type FindingRemediationProps = { + status: string | null; + prDraft: boolean | null; + outcomeSummary: string | null; + blockedReason: string | null; + updatedAt: string | null; + failureCopy: string | null; + unavailableCopy: string | null; + attempts: RemediationAttempt[]; + action: React.ReactNode; +}; + +function FindingRemediation({ + status, + prDraft, + outcomeSummary, + blockedReason, + updatedAt, + failureCopy, + unavailableCopy, + attempts, + action, +}: FindingRemediationProps) { + const isActive = isActiveRemediationStatus(status); + + return ( + +
+
+
+
+ {isActive ? ( + + ) : ( +
+ {outcomeSummary &&

{outcomeSummary}

} + {blockedReason &&

Blocked: {blockedReason}

} + {failureCopy &&

{failureCopy}

} + {updatedAt && ( +

+ Updated {formatDistanceToNow(new Date(updatedAt), { addSuffix: true })} +

+ )} +
+ + {action &&
{action}
} +
+ + {unavailableCopy && ( +
+
+ )} +
+ + {attempts.length > 0 ? ( +
+

Attempts

+
+ {attempts.map(attempt => ( +
+
+
+ #{attempt.attemptNumber} + + {formatRemediationStatus(attempt.status)} + + {attempt.origin} +
+ + {format(new Date(attempt.updatedAt), 'PPp')} + +
+
+
+ Branch:{' '} + {attempt.branchName} +
+
+ Model:{' '} + {attempt.remediationModelSlug} +
+
+ {attempt.lastErrorRedacted && ( +

{attempt.lastErrorRedacted}

+ )} + {attempt.blockedReason && ( +

{attempt.blockedReason}

+ )} + {attempt.riskNotes && ( +

{attempt.riskNotes}

+ )} + {attempt.prUrl && ( + + )} +
+ ))} +
+
+ ) : ( + + )} +
+ ); +} + function FindingFooter({ finding, canDismiss, @@ -551,12 +751,37 @@ export function FindingDetailDialog({ }: FindingDetailDialogProps) { const trpc = useTRPC(); const isOrg = Boolean(organizationId); - const { handleStartAnalysis: triggerStartAnalysis, startingAnalysisIds } = useSecurityAgent(); + const { + handleStartAnalysis: triggerStartAnalysis, + handleStartRemediation, + handleRetryRemediation, + handleCancelRemediation, + startingAnalysisIds, + startingRemediationIds, + cancellingRemediationAttemptIds, + } = useSecurityAgent(); const isAwaitingAnalysisStart = finding ? startingAnalysisIds.has(finding.id) : false; + const isAwaitingRemediationStart = finding ? startingRemediationIds.has(finding.id) : false; - const pollWhileActive = (query: { state: { data?: { status?: string | null } } }) => { + const pollWhileActive = (query: { + state: { + data?: { + status?: string | null; + remediationAttempts?: RemediationAttempt[]; + }; + }; + }) => { const status = query.state.data?.status; - if (isAwaitingAnalysisStart || status === 'pending' || status === 'running') { + const hasActiveRemediation = query.state.data?.remediationAttempts?.some(attempt => + isActiveRemediationStatus(attempt.status) + ); + if ( + isAwaitingAnalysisStart || + isAwaitingRemediationStart || + status === 'pending' || + status === 'running' || + hasActiveRemediation + ) { return ANALYSIS_POLL_INTERVAL_MS; } return false as const; @@ -584,11 +809,165 @@ export function FindingDetailDialog({ const analysis = analysisData?.analysis ?? finding.analysis; const analysisError = analysisData?.error ?? finding.analysis_error; const cliSessionId = analysisData?.cliSessionId ?? finding.cli_session_id; + const remediationSummary = analysisData?.remediationSummary ?? finding.remediationSummary ?? null; + const remediationCapability = + analysisData?.remediationCapability ?? finding.remediationCapability; + const remediationAttempts = analysisData?.remediationAttempts ?? []; + const latestHistoryAttempt = remediationAttempts[0] ?? null; + const effectiveRemediationStatus = + latestHistoryAttempt?.status ?? remediationSummary?.status ?? null; + const isEffectiveRemediationActive = isActiveRemediationStatus(effectiveRemediationStatus); + const effectiveRemediationPrUrl = + remediationSummary?.prUrl ?? latestHistoryAttempt?.prUrl ?? null; + const effectiveRemediationPrDraft = + remediationSummary?.prDraft ?? latestHistoryAttempt?.prDraft ?? null; + const effectiveRemediationOutcomeSummary = isEffectiveRemediationActive + ? null + : (remediationSummary?.outcomeSummary ?? null); + const effectiveRemediationBlockedReason = isEffectiveRemediationActive + ? null + : (latestHistoryAttempt?.blockedReason ?? remediationSummary?.blockedReason ?? null); + const effectiveRemediationUpdatedAt = + latestHistoryAttempt?.updatedAt ?? remediationSummary?.updatedAt ?? null; + const hasRegisteredRemediationAttempt = + remediationAttempts.length > 0 || + Boolean(remediationSummary?.latestAttemptId ?? remediationSummary?.latestAttempt?.id); + const activeRemediationAttemptId = isEffectiveRemediationActive + ? (remediationCapability?.cancelAttemptId ?? + latestHistoryAttempt?.id ?? + remediationSummary?.latestAttemptId ?? + null) + : null; + const isCancellingRemediation = + !!activeRemediationAttemptId && cancellingRemediationAttemptIds.has(activeRemediationAttemptId); + const remediationUnavailableCopy = + remediationCapability && + !remediationCapability.canStart && + !remediationCapability.canRetry && + !remediationCapability.canCancel + ? getRemediationUnavailableCopy(remediationCapability.startReason) + : null; + const effectiveRemediationUnavailableCopy = + effectiveRemediationStatus === 'pr_opened' + ? getRemediationUnavailableCopy('pr_already_opened') + : remediationUnavailableCopy; + const remediationNeedsAnalysisRefresh = + !hasRegisteredRemediationAttempt && + (remediationCapability?.startReason === 'stale_analysis' || + remediationCapability?.retryReason === 'stale_analysis'); + const remediationNeedsCodebaseAnalysis = + !hasRegisteredRemediationAttempt && + !isEffectiveRemediationActive && + (isCodebaseAnalysisRequiredReason(remediationCapability?.startReason) || + isCodebaseAnalysisRequiredReason(remediationCapability?.retryReason)); + const canStartRemediation = + Boolean(remediationCapability?.canStart) && + !hasRegisteredRemediationAttempt && + !isEffectiveRemediationActive; + const canCancelRemediation = Boolean(activeRemediationAttemptId); + const canRetryRemediation = + Boolean(remediationCapability?.canRetry) && + !isEffectiveRemediationActive && + effectiveRemediationStatus !== 'pr_opened'; + const remediationFailureCopy = isEffectiveRemediationActive + ? null + : getRemediationFailureCopy( + latestHistoryAttempt?.failureCode ?? remediationSummary?.failureCode + ); const isAnalyzing = isAwaitingAnalysisStart || analysisStatus === 'pending' || analysisStatus === 'running'; - const handleStartAnalysis: StartAnalysis = ({ retrySandboxOnly } = {}) => { - triggerStartAnalysis(finding.id, { retrySandboxOnly }); + const remediationAnalysisRefreshLabel = + isAwaitingAnalysisStart || analysisStatus === 'pending' + ? manualAnalysisAdmissionCopy.pendingLabel + : analysisStatus === 'running' + ? 'Analysis running' + : 'Rerun analysis'; + const codebaseAnalysisActionLabel = + isAwaitingAnalysisStart || analysisStatus === 'pending' + ? manualAnalysisAdmissionCopy.pendingLabel + : analysisStatus === 'running' + ? 'Analysis running' + : 'Run codebase analysis'; + + const handleStartAnalysis: StartAnalysis = ({ forceSandbox, retrySandboxOnly } = {}) => { + triggerStartAnalysis(finding.id, { forceSandbox, retrySandboxOnly }); + }; + const handleStartCodebaseAnalysis = () => { + handleStartAnalysis({ forceSandbox: true }); + }; + const handleCancelRemediationClick = () => { + if (activeRemediationAttemptId) handleCancelRemediation(activeRemediationAttemptId, finding.id); }; + const remediationAction = effectiveRemediationPrUrl ? ( + + ) : isAwaitingRemediationStart ? ( + + ) : canCancelRemediation ? ( + + ) : canRetryRemediation ? ( + + ) : canStartRemediation ? ( + + ) : remediationNeedsCodebaseAnalysis ? ( + + ) : remediationNeedsAnalysisRefresh ? ( + + ) : hasRegisteredRemediationAttempt || effectiveRemediationUnavailableCopy ? ( + + ) : null; const analysisPanelProps = { analysis, @@ -605,7 +984,7 @@ export function FindingDetailDialog({ - + Details Analysis + + {isEffectiveRemediationActive ? ( + + ) : ( + @@ -633,6 +1020,20 @@ export function FindingDetailDialog({ {...analysisPanelProps} cliSessionId={cliSessionId} organizationId={organizationId} + remediationNeedsCodebaseAnalysis={remediationNeedsCodebaseAnalysis} + codebaseAnalysisActionLabel={codebaseAnalysisActionLabel} + onStartCodebaseAnalysis={handleStartCodebaseAnalysis} + /> + Promise; @@ -83,6 +88,10 @@ type SecurityAgentContextValue = { autoAnalysisEnabled: boolean; autoAnalysisMinSeverity: 'critical' | 'high' | 'medium' | 'all'; autoAnalysisIncludeExisting: boolean; + autoRemediationEnabled: boolean; + autoRemediationMinSeverity: 'critical' | 'high' | 'medium' | 'all'; + autoRemediationIncludeExisting: boolean; + remediationModelSlug: string; } ) => void; handleToggleEnabled: ( @@ -92,7 +101,13 @@ type SecurityAgentContextValue = { selectedRepositoryIds: number[]; } ) => void; - handleStartAnalysis: (findingId: string, options?: { retrySandboxOnly?: boolean }) => void; + handleStartAnalysis: ( + findingId: string, + options?: { forceSandbox?: boolean; retrySandboxOnly?: boolean } + ) => void; + handleStartRemediation: (findingId: string) => void; + handleRetryRemediation: (findingId: string) => void; + handleCancelRemediation: (attemptId: string, findingId?: string) => void; handleDeleteFindings: (repoFullName: string, onSuccess?: () => void) => void; // Mutation states @@ -104,6 +119,8 @@ type SecurityAgentContextValue = { // Analysis tracking startingAnalysisIds: Set; + startingRemediationIds: Set; + cancellingRemediationAttemptIds: Set; // GitHub error gitHubError: string | null; @@ -137,7 +154,7 @@ const EMPTY_ORPHANED_REPOSITORIES: SecurityAgentContextValue['orphanedRepositori export type SecurityAgentCommand = { id: string; - commandType: 'sync' | 'dismiss_finding' | 'start_analysis'; + commandType: 'sync' | 'dismiss_finding' | 'start_analysis' | 'apply_auto_remediation'; findingId: string | null; status: 'accepted' | 'running' | 'succeeded' | 'failed' | 'no_op'; resultCode: string | null; @@ -203,6 +220,8 @@ export function shouldRunSecurityAgentCommandSuccessCallback( type SecurityAgentProviderState = { optimisticStartingAnalysisIds: Set; + optimisticStartingRemediationIds: Set; + optimisticCancellingRemediationAttemptIds: Set; trackedCommandIds: Set; processedTerminalCommandIds: Set; gitHubError: string | null; @@ -212,6 +231,10 @@ type SecurityAgentProviderAction = | { type: 'track-command'; commandId: string } | { type: 'add-optimistic-analysis'; findingId: string } | { type: 'remove-optimistic-analysis'; findingId: string } + | { type: 'add-optimistic-remediation'; findingId: string } + | { type: 'remove-optimistic-remediation'; findingId: string } + | { type: 'add-cancelling-remediation'; attemptId: string } + | { type: 'remove-cancelling-remediation'; attemptId: string } | { type: 'settle-commands'; commands: SecurityAgentCommand[]; gitHubError?: string } | { type: 'prune-processed-commands'; polledCommandIds: Set } | { type: 'set-github-error'; error: string | null }; @@ -219,6 +242,8 @@ type SecurityAgentProviderAction = function createSecurityAgentProviderState(): SecurityAgentProviderState { return { optimisticStartingAnalysisIds: new Set(), + optimisticStartingRemediationIds: new Set(), + optimisticCancellingRemediationAttemptIds: new Set(), trackedCommandIds: new Set(), processedTerminalCommandIds: new Set(), gitHubError: null, @@ -247,17 +272,49 @@ function securityAgentProviderReducer( optimisticStartingAnalysisIds.delete(action.findingId); return { ...state, optimisticStartingAnalysisIds }; } + case 'add-optimistic-remediation': + return { + ...state, + optimisticStartingRemediationIds: new Set(state.optimisticStartingRemediationIds).add( + action.findingId + ), + }; + case 'remove-optimistic-remediation': { + const optimisticStartingRemediationIds = new Set(state.optimisticStartingRemediationIds); + optimisticStartingRemediationIds.delete(action.findingId); + return { ...state, optimisticStartingRemediationIds }; + } + case 'add-cancelling-remediation': + return { + ...state, + optimisticCancellingRemediationAttemptIds: new Set( + state.optimisticCancellingRemediationAttemptIds + ).add(action.attemptId), + }; + case 'remove-cancelling-remediation': { + const optimisticCancellingRemediationAttemptIds = new Set( + state.optimisticCancellingRemediationAttemptIds + ); + optimisticCancellingRemediationAttemptIds.delete(action.attemptId); + return { ...state, optimisticCancellingRemediationAttemptIds }; + } case 'settle-commands': { const optimisticStartingAnalysisIds = new Set(state.optimisticStartingAnalysisIds); + const optimisticStartingRemediationIds = new Set(state.optimisticStartingRemediationIds); const trackedCommandIds = new Set(state.trackedCommandIds); const processedTerminalCommandIds = new Set(state.processedTerminalCommandIds); for (const command of action.commands) { - if (command.findingId) optimisticStartingAnalysisIds.delete(command.findingId); + if (command.findingId) { + optimisticStartingAnalysisIds.delete(command.findingId); + optimisticStartingRemediationIds.delete(command.findingId); + } trackedCommandIds.delete(command.id); processedTerminalCommandIds.add(command.id); } return { optimisticStartingAnalysisIds, + optimisticStartingRemediationIds, + optimisticCancellingRemediationAttemptIds: state.optimisticCancellingRemediationAttemptIds, trackedCommandIds, processedTerminalCommandIds, gitHubError: action.gitHubError ?? state.gitHubError, @@ -293,6 +350,8 @@ function commandFailureDescription(command: SecurityAgentCommand): string { return 'Finding cannot be dismissed because its Dependabot target is invalid.'; case 'COMMAND_STALLED': return 'Queued action did not finish in time. Retry action.'; + case 'QUEUE_ADMISSION_FAILED': + return command.lastErrorRedacted ?? 'Queued action could not be admitted. Retry action.'; default: return command.lastErrorRedacted ?? 'Queued action failed. Retry action.'; } @@ -499,6 +558,12 @@ function useSecurityAgentProviderValue( ); } + function invalidateRemediationQueries() { + invalidateSecurityAgentQueryScopes( + getSecurityAgentInvalidationScopesForCommand('apply_auto_remediation') + ); + } + // Permission status query const { data: permissionData, isLoading: isLoadingPermission } = useQuery( isOrg @@ -608,6 +673,12 @@ function useSecurityAgentProviderValue( successCallback?.(); if (command.commandType === 'dismiss_finding') { toast.success(getSecurityAgentDismissalTerminalTitle(command.status)); + } else if (command.commandType === 'apply_auto_remediation') { + toast.success( + command.status === 'no_op' + ? 'No existing findings queued' + : 'Existing remediations queued' + ); } } } @@ -671,6 +742,14 @@ function useSecurityAgentProviderValue( description: data.backlogAdmissionWarning, }); } + if (data.remediationBacklogAdmissionWarning) { + toast.warning('Existing remediations not queued', { + description: data.remediationBacklogAdmissionWarning, + }); + } + if (data.existingRemediationCommandId) { + trackCommand(data.existingRemediationCommandId); + } await refetchConfig(); invalidateSecurityAgentQueryScopes([ 'config', @@ -744,6 +823,66 @@ function useSecurityAgentProviderValue( }) ); + const { mutate: orgStartRemediationMutate } = useMutation( + trpc.organizations.securityAgent.startRemediation.mutationOptions({ + onSuccess: async (_data, variables) => { + toast.success('Remediation queued'); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + invalidateRemediationQueries(); + }, + onError: (error, variables) => { + toast.error('Failed to queue remediation', { description: error.message, duration: 8000 }); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + }, + }) + ); + + const { mutate: orgRetryRemediationMutate } = useMutation( + trpc.organizations.securityAgent.retryRemediation.mutationOptions({ + onSuccess: async (_data, variables) => { + toast.success('Remediation retry queued'); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + invalidateRemediationQueries(); + }, + onError: (error, variables) => { + toast.error('Failed to retry remediation', { description: error.message, duration: 8000 }); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + }, + }) + ); + + const { mutate: orgCancelRemediationMutate } = useMutation( + trpc.organizations.securityAgent.cancelRemediation.mutationOptions({ + onSuccess: async (_data, variables) => { + toast.success('Remediation cancellation requested'); + dispatchProviderState({ + type: 'remove-cancelling-remediation', + attemptId: variables.attemptId, + }); + invalidateRemediationQueries(); + }, + onError: (error, variables) => { + toast.error('Failed to cancel remediation', { description: error.message, duration: 8000 }); + dispatchProviderState({ + type: 'remove-cancelling-remediation', + attemptId: variables.attemptId, + }); + }, + }) + ); + const { mutate: orgDeleteFindingsMutate, isPending: isOrgDeleteFindingsPending } = useMutation( trpc.organizations.securityAgent.deleteFindingsByRepository.mutationOptions({ onSuccess: data => { @@ -803,6 +942,14 @@ function useSecurityAgentProviderValue( description: data.backlogAdmissionWarning, }); } + if (data.remediationBacklogAdmissionWarning) { + toast.warning('Existing remediations not queued', { + description: data.remediationBacklogAdmissionWarning, + }); + } + if (data.existingRemediationCommandId) { + trackCommand(data.existingRemediationCommandId); + } await refetchConfig(); invalidateSecurityAgentQueryScopes([ 'config', @@ -876,6 +1023,66 @@ function useSecurityAgentProviderValue( }) ); + const { mutate: personalStartRemediationMutate } = useMutation( + trpc.securityAgent.startRemediation.mutationOptions({ + onSuccess: async (_data, variables) => { + toast.success('Remediation queued'); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + invalidateRemediationQueries(); + }, + onError: (error, variables) => { + toast.error('Failed to queue remediation', { description: error.message, duration: 8000 }); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + }, + }) + ); + + const { mutate: personalRetryRemediationMutate } = useMutation( + trpc.securityAgent.retryRemediation.mutationOptions({ + onSuccess: async (_data, variables) => { + toast.success('Remediation retry queued'); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + invalidateRemediationQueries(); + }, + onError: (error, variables) => { + toast.error('Failed to retry remediation', { description: error.message, duration: 8000 }); + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId: variables.findingId, + }); + }, + }) + ); + + const { mutate: personalCancelRemediationMutate } = useMutation( + trpc.securityAgent.cancelRemediation.mutationOptions({ + onSuccess: async (_data, variables) => { + toast.success('Remediation cancellation requested'); + dispatchProviderState({ + type: 'remove-cancelling-remediation', + attemptId: variables.attemptId, + }); + invalidateRemediationQueries(); + }, + onError: (error, variables) => { + toast.error('Failed to cancel remediation', { description: error.message, duration: 8000 }); + dispatchProviderState({ + type: 'remove-cancelling-remediation', + attemptId: variables.attemptId, + }); + }, + }) + ); + const { mutate: personalDeleteFindingsMutate, isPending: isPersonalDeleteFindingsPending } = useMutation( trpc.securityAgent.deleteFindingsByRepository.mutationOptions({ @@ -934,11 +1141,16 @@ function useSecurityAgentProviderValue( autoAnalysisEnabled: boolean; autoAnalysisMinSeverity: 'critical' | 'high' | 'medium' | 'all'; autoAnalysisIncludeExisting: boolean; + autoRemediationEnabled: boolean; + autoRemediationMinSeverity: 'critical' | 'high' | 'medium' | 'all'; + autoRemediationIncludeExisting: boolean; + remediationModelSlug: string; } ) => { const modelConfigPayload = { triageModelSlug: config.triageModelSlug, analysisModelSlug: config.analysisModelSlug, + remediationModelSlug: config.remediationModelSlug, modelSlug: config.modelSlug, }; @@ -957,6 +1169,9 @@ function useSecurityAgentProviderValue( autoAnalysisEnabled: config.autoAnalysisEnabled, autoAnalysisMinSeverity: config.autoAnalysisMinSeverity, autoAnalysisIncludeExisting: config.autoAnalysisIncludeExisting, + autoRemediationEnabled: config.autoRemediationEnabled, + autoRemediationMinSeverity: config.autoRemediationMinSeverity, + autoRemediationIncludeExisting: config.autoRemediationIncludeExisting, ...modelConfigPayload, }); } else { @@ -973,6 +1188,9 @@ function useSecurityAgentProviderValue( autoAnalysisEnabled: config.autoAnalysisEnabled, autoAnalysisMinSeverity: config.autoAnalysisMinSeverity, autoAnalysisIncludeExisting: config.autoAnalysisIncludeExisting, + autoRemediationEnabled: config.autoRemediationEnabled, + autoRemediationMinSeverity: config.autoRemediationMinSeverity, + autoRemediationIncludeExisting: config.autoRemediationIncludeExisting, ...modelConfigPayload, }); } @@ -1003,17 +1221,65 @@ function useSecurityAgentProviderValue( ); const handleStartAnalysis = useCallback( - (findingId: string, { retrySandboxOnly }: { retrySandboxOnly?: boolean } = {}) => { + ( + findingId: string, + { + forceSandbox, + retrySandboxOnly, + }: { forceSandbox?: boolean; retrySandboxOnly?: boolean } = {} + ) => { dispatchProviderState({ type: 'add-optimistic-analysis', findingId }); if (isOrg && organizationId) { - orgStartAnalysisMutate({ organizationId, findingId, retrySandboxOnly }); + orgStartAnalysisMutate({ organizationId, findingId, forceSandbox, retrySandboxOnly }); } else { - personalStartAnalysisMutate({ findingId, retrySandboxOnly }); + personalStartAnalysisMutate({ findingId, forceSandbox, retrySandboxOnly }); } }, [isOrg, organizationId, orgStartAnalysisMutate, personalStartAnalysisMutate] ); + const handleStartRemediation = useCallback( + (findingId: string) => { + dispatchProviderState({ type: 'add-optimistic-remediation', findingId }); + if (isOrg && organizationId) { + orgStartRemediationMutate({ organizationId, findingId }); + } else { + personalStartRemediationMutate({ findingId }); + } + }, + [isOrg, organizationId, orgStartRemediationMutate, personalStartRemediationMutate] + ); + + const handleRetryRemediation = useCallback( + (findingId: string) => { + dispatchProviderState({ type: 'add-optimistic-remediation', findingId }); + if (isOrg && organizationId) { + orgRetryRemediationMutate({ organizationId, findingId }); + } else { + personalRetryRemediationMutate({ findingId }); + } + }, + [isOrg, organizationId, orgRetryRemediationMutate, personalRetryRemediationMutate] + ); + + const handleCancelRemediation = useCallback( + (attemptId: string, findingId?: string) => { + if (findingId) { + dispatchProviderState({ + type: 'remove-optimistic-remediation', + findingId, + }); + } + dispatchProviderState({ type: 'add-cancelling-remediation', attemptId }); + if (isOrg && organizationId) { + orgCancelRemediationMutate({ organizationId, attemptId }); + } else { + personalCancelRemediationMutate({ attemptId }); + } + }, + [isOrg, organizationId, orgCancelRemediationMutate, personalCancelRemediationMutate] + ); + const handleDeleteFindings = useCallback( (repoFullName: string, onSuccess?: () => void) => { if (isOrg && organizationId) { @@ -1043,6 +1309,7 @@ function useSecurityAgentProviderValue( const triageModelSlug = getOptionalStringField(configData, 'triageModelSlug'); const analysisModelSlug = getOptionalStringField(configData, 'analysisModelSlug'); + const remediationModelSlug = getOptionalStringField(configData, 'remediationModelSlug'); const value = useMemo( () => ({ @@ -1067,6 +1334,11 @@ function useSecurityAgentProviderValue( autoAnalysisEnabled: configData.autoAnalysisEnabled ?? false, autoAnalysisMinSeverity: configData.autoAnalysisMinSeverity ?? 'high', autoAnalysisIncludeExisting: configData.autoAnalysisIncludeExisting ?? false, + autoRemediationEnabled: configData.autoRemediationEnabled ?? false, + autoRemediationMinSeverity: configData.autoRemediationMinSeverity ?? 'high', + autoRemediationIncludeExisting: configData.autoRemediationIncludeExisting ?? false, + autoRemediationEnabledAt: configData.autoRemediationEnabledAt ?? null, + remediationModelSlug, } : undefined, refetchConfig, @@ -1077,6 +1349,9 @@ function useSecurityAgentProviderValue( handleSaveConfig, handleToggleEnabled, handleStartAnalysis, + handleStartRemediation, + handleRetryRemediation, + handleCancelRemediation, handleDeleteFindings, isSyncing: hasActiveSyncCommand || (isOrg ? isOrgSyncPending : isPersonalSyncPending), isDismissing: @@ -1085,6 +1360,8 @@ function useSecurityAgentProviderValue( isTogglingEnabled: isOrg ? isOrgSetEnabledPending : isPersonalSetEnabledPending, isDeletingFindings: isOrg ? isOrgDeleteFindingsPending : isPersonalDeleteFindingsPending, startingAnalysisIds, + startingRemediationIds: providerState.optimisticStartingRemediationIds, + cancellingRemediationAttemptIds: providerState.optimisticCancellingRemediationAttemptIds, gitHubError: providerState.gitHubError, orphanedRepositories: orphanedReposData ?? EMPTY_ORPHANED_REPOSITORIES, }), @@ -1106,6 +1383,9 @@ function useSecurityAgentProviderValue( handleSaveConfig, handleToggleEnabled, handleStartAnalysis, + handleStartRemediation, + handleRetryRemediation, + handleCancelRemediation, handleDeleteFindings, isOrgSyncPending, isPersonalSyncPending, @@ -1120,10 +1400,13 @@ function useSecurityAgentProviderValue( isOrgDeleteFindingsPending, isPersonalDeleteFindingsPending, startingAnalysisIds, + providerState.optimisticStartingRemediationIds, + providerState.optimisticCancellingRemediationAttemptIds, providerState.gitHubError, orphanedReposData, triageModelSlug, analysisModelSlug, + remediationModelSlug, ] ); diff --git a/apps/web/src/components/security-agent/SecurityConfigForm.tsx b/apps/web/src/components/security-agent/SecurityConfigForm.tsx index 120444b202..2420e4122f 100644 --- a/apps/web/src/components/security-agent/SecurityConfigForm.tsx +++ b/apps/web/src/components/security-agent/SecurityConfigForm.tsx @@ -9,6 +9,7 @@ import { AnalysisModeSection, AutoAnalysisSection, AutoDismissSection, + AutoRemediationSection, ModelSection, RepositorySection, SlaSection, @@ -67,6 +68,10 @@ function configFingerprint(config: SecurityConfigFormState) { config.autoAnalysisEnabled, config.autoAnalysisMinSeverity, config.autoAnalysisIncludeExisting, + config.autoRemediationEnabled, + config.autoRemediationMinSeverity, + config.autoRemediationIncludeExisting, + config.remediationModelSlug, ]); } @@ -137,6 +142,10 @@ export function SecurityConfigForm({ autoAnalysisEnabled: state.autoAnalysisEnabled, autoAnalysisMinSeverity: state.autoAnalysisMinSeverity, autoAnalysisIncludeExisting: state.autoAnalysisIncludeExisting, + autoRemediationEnabled: state.autoRemediationEnabled, + autoRemediationMinSeverity: state.autoRemediationMinSeverity, + autoRemediationIncludeExisting: state.autoRemediationIncludeExisting, + remediationModelSlug: state.remediationModelSlug, }); }; @@ -163,6 +172,7 @@ export function SecurityConfigForm({ +
diff --git a/apps/web/src/components/security-agent/SecurityConfigPage.tsx b/apps/web/src/components/security-agent/SecurityConfigPage.tsx index b6e324a374..63a93c3401 100644 --- a/apps/web/src/components/security-agent/SecurityConfigPage.tsx +++ b/apps/web/src/components/security-agent/SecurityConfigPage.tsx @@ -6,6 +6,7 @@ import { SecurityConfigForm } from './SecurityConfigForm'; import { useSecurityAgent } from './SecurityAgentContext'; import { DEFAULT_SECURITY_AGENT_ANALYSIS_MODEL, + DEFAULT_SECURITY_AGENT_REMEDIATION_MODEL, DEFAULT_SECURITY_AGENT_TRIAGE_MODEL, } from '@/lib/security-agent/core/constants'; import type { SecurityConfigFormState } from './security-config-types'; @@ -56,6 +57,14 @@ export function SecurityConfigPage() { autoAnalysisEnabled: configData?.autoAnalysisEnabled ?? false, autoAnalysisMinSeverity: configData?.autoAnalysisMinSeverity ?? 'high', autoAnalysisIncludeExisting: configData?.autoAnalysisIncludeExisting ?? false, + autoRemediationEnabled: configData?.autoRemediationEnabled ?? false, + autoRemediationMinSeverity: configData?.autoRemediationMinSeverity ?? 'high', + autoRemediationIncludeExisting: configData?.autoRemediationIncludeExisting ?? false, + remediationModelSlug: + configData?.remediationModelSlug ?? + configData?.analysisModelSlug ?? + configData?.modelSlug ?? + DEFAULT_SECURITY_AGENT_REMEDIATION_MODEL, } satisfies SecurityConfigFormState; return ( diff --git a/apps/web/src/components/security-agent/SecurityConfigSections.tsx b/apps/web/src/components/security-agent/SecurityConfigSections.tsx index 195dc5adf5..6b9b64921b 100644 --- a/apps/web/src/components/security-agent/SecurityConfigSections.tsx +++ b/apps/web/src/components/security-agent/SecurityConfigSections.tsx @@ -1,7 +1,16 @@ 'use client'; import type { Dispatch, SetStateAction } from 'react'; -import { AlertCircle, AlertTriangle, Bot, Clock, Info, ScanSearch, Settings } from 'lucide-react'; +import { + AlertCircle, + AlertTriangle, + Bot, + Clock, + GitPullRequest, + Info, + ScanSearch, + Settings, +} from 'lucide-react'; import { RepositoryMultiSelect } from '@/components/code-reviews/RepositoryMultiSelect'; import { ModelCombobox } from '@/components/shared/ModelCombobox'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,6 +23,7 @@ import type { AnalysisMode, AutoAnalysisMinSeverity, AutoDismissConfidenceThreshold, + AutoRemediationMinSeverity, SecurityConfigFormState, SecurityRepository, SlaConfig, @@ -160,6 +170,29 @@ const AUTO_ANALYSIS_OPTIONS: RadioOption[] = [ { value: 'all', label: 'All severities', description: 'Analyze every finding.' }, ]; +const AUTO_REMEDIATION_OPTIONS: RadioOption[] = [ + { + value: 'critical', + label: 'Critical only', + description: 'Open remediation PRs for critical exploitable findings.', + }, + { + value: 'high', + label: 'High and above', + description: 'Open remediation PRs for high and critical exploitable findings.', + }, + { + value: 'medium', + label: 'Medium and above', + description: 'Open remediation PRs for medium, high, and critical findings.', + }, + { + value: 'all', + label: 'All severities', + description: 'Open remediation PRs for every eligible exploitable finding.', + }, +]; + const DISMISS_OPTIONS: RadioOption[] = [ { value: 'high', @@ -297,9 +330,9 @@ export function ModelSection({ - +
+
+ + setState(current => ({ ...current, remediationModelSlug })) + } + isLoading={isLoading} + helperText="Used by Cloud Agent when creating remediation PRs." + /> +
); @@ -398,6 +443,54 @@ export function AutoAnalysisSection({ state, setState }: StateProps) { ); } +export function AutoRemediationSection({ state, setState }: StateProps) { + return ( + + + + + setState(current => ({ ...current, autoRemediationEnabled })) + } + /> + {state.autoRemediationEnabled && ( + <> +
+ Minimum severity + + setState(current => ({ ...current, autoRemediationMinSeverity })) + } + /> +
+ + setState(current => ({ ...current, autoRemediationIncludeExisting })) + } + /> + + )} +
+
+ ); +} + export function AutoDismissSection({ state, setState }: StateProps) { return ( diff --git a/apps/web/src/components/security-agent/SecurityFindingRow.tsx b/apps/web/src/components/security-agent/SecurityFindingRow.tsx index 294db29710..fba5e8959a 100644 --- a/apps/web/src/components/security-agent/SecurityFindingRow.tsx +++ b/apps/web/src/components/security-agent/SecurityFindingRow.tsx @@ -6,8 +6,11 @@ import { CheckCircle2, ChevronRight, Eye, + ExternalLink, + GitPullRequest, Loader2, Package, + RotateCw, Shield, ShieldAlert, ShieldCheck, @@ -144,11 +147,49 @@ function isSeverity(value: string): value is Severity { return ['critical', 'high', 'medium', 'low'].includes(value); } +export type RemediationSummary = { + status: string; + latestAttemptId: string | null; + prUrl: string | null; + prNumber: number | null; + prDraft: boolean | null; + prHeadBranch: string | null; + prBaseBranch: string | null; + outcomeSummary: string | null; + failureCode: string | null; + blockedReason: string | null; + completedAt: string | null; + updatedAt: string; + latestAttempt?: { id: string; status: string } | null; +}; + +export type RemediationCapability = { + canStart: boolean; + startReason: string; + canRetry: boolean; + retryReason: string; + canCancel: boolean; + cancelAttemptId: string | null; +}; + +export type SecurityFindingWithRemediation = SecurityFinding & { + remediationSummary?: RemediationSummary | null; + remediationCapability?: RemediationCapability; +}; + type SecurityFindingRowProps = { - finding: SecurityFinding; + finding: SecurityFindingWithRemediation; onClick: () => void; - onStartAnalysis?: (findingId: string, options?: { retrySandboxOnly?: boolean }) => void; + onStartAnalysis?: ( + findingId: string, + options?: { forceSandbox?: boolean; retrySandboxOnly?: boolean } + ) => void; isStartingAnalysis?: boolean; + onStartRemediation?: (findingId: string) => void; + onRetryRemediation?: (findingId: string) => void; + onCancelRemediation?: (attemptId: string, findingId?: string) => void; + isStartingRemediation?: boolean; + isCancellingRemediation?: boolean; }; function formatCompactDistance(date: Date) { @@ -160,11 +201,26 @@ function formatCompactDistance(date: Date) { return `${Math.abs(differenceInMinutes(now, date))}m`; } +function isActiveRemediationStatus(status: string | null | undefined) { + return status === 'queued' || status === 'launching' || status === 'running'; +} + +function formatRemediationStatus(status: string | null | undefined) { + if (status === 'pr_opened') return 'PR opened'; + if (status === 'no_changes_needed') return 'No changes'; + return status?.replace(/_/g, ' ') ?? null; +} + export function SecurityFindingRow({ finding, onClick, onStartAnalysis, isStartingAnalysis, + onStartRemediation, + onRetryRemediation, + onCancelRemediation, + isStartingRemediation, + isCancellingRemediation, }: SecurityFindingRowProps) { const severity: Severity = isSeverity(finding.severity) ? finding.severity : 'medium'; const canStartAnalysis = @@ -173,6 +229,14 @@ export function SecurityFindingRow({ Boolean(onStartAnalysis) && !isStartingAnalysis; const outcome = getOutcome(finding); + const remediation = finding.remediationSummary; + const capability = finding.remediationCapability; + const remediationStatus = remediation?.status ?? null; + const remediationAttemptId = capability?.cancelAttemptId ?? remediation?.latestAttemptId; + const remediationIsActive = isActiveRemediationStatus(remediationStatus) || isStartingRemediation; + const remediationStatusLabel = formatRemediationStatus(remediationStatus); + const openRemediationPrUrl = + remediationStatus === 'pr_opened' && remediation?.prUrl ? remediation.prUrl : null; const isHighlighted = finding.status === 'open' && finding.sla_due_at !== null && @@ -183,6 +247,11 @@ export function SecurityFindingRow({ Boolean(finding.analysis?.triage) && finding.analysis_status === 'failed'; onStartAnalysis?.(finding.id, { retrySandboxOnly }); }; + const startRemediation = () => onStartRemediation?.(finding.id); + const retryRemediation = () => onRetryRemediation?.(finding.id); + const cancelRemediation = () => { + if (remediationAttemptId) onCancelRemediation?.(remediationAttemptId, finding.id); + }; return (