diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index fea40693e1..271e66327f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -25,6 +25,18 @@ that same result. Successful reads retain their content and execution provenance ## Purpose +### Permanent suppression rules remain reversible — issue #2528 + +The existing manual suppression API/store semantics remain unchanged. Patrol +Activity now exposes scoped list/confirm/remove/readback for explicitly manual +rules. Deleting a rule permits future matches but does not reopen historical +dismissals, erase findings, broaden creation scope or modify other rules. +Finding-specific Reopen and remembered decisions remain distinct. Frontend +API/component controls plus authenticated handler/store controls verify these +boundaries; source/browser proof does not establish installed or reporter +acceptance. + + ### Fully cached Patrol usage — issues #2118 / #2350 Zero ordinary input tokens do not mean a run was free or skipped. Any positive diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e459897ac5..29acf094e6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -20,6 +20,20 @@ ## Purpose +### Confirmed Patrol rule operations preserve tenant context — issue #2528 + +The frontend consumes the existing GET/DELETE suppression endpoints without a +wire or permission change. The optional client expectedOrgID pins the request +header and checks context before and after CSRF preparation; retry:false +disables auth, invalid-organisation, CSRF and rate-limit retries for this flow. +Other callers retain their defaults. Current auth, scope and CSRF checks remain +authoritative; an abort or mismatched context stops dispatch. The suppression +client rejects malformed or duplicate rows, accepts the existing empty nil +slice, and only deletes a rule_ identity with explicit manual origin and no +finding/dismissal attribution. Transport success alone is not removal: the UI +must read the same collection back and establish exact-ID absence. + + ### Organisation deletion retains data after incomplete monitoring shutdown Authenticated organisation-owner deletion waits for tenant-loop exit and sealed diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 34c5094087..e9bb4f5ae0 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -15,6 +15,18 @@ ## Purpose +### Opt-in confirmed-operation transport keeps paid contexts unchanged + +The shared frontend client adds expectedOrgID and retry:false for the existing +Patrol manual-rule reversal flow. It binds confirmed reads/deletion to the +selected organisation and stops automatic auth, invalid-org, CSRF and +rate-limit replay only when requested. Paid APIs retain their existing default +request behaviour, tenant enforcement and entitlement boundaries. The strict +consumer rechecks context after CSRF preparation and discards stale responses; +this adds no paid endpoint, cross-tenant fallback or scope expansion. Shared +client tests cover unchanged defaults and explicit one-attempt context binding. + + Own cloud plan/version semantics, entitlement limits, hosted billing/runtime agreement, the Pulse Cloud control plane, provider-hosted MSP account bootstrap/licensing, hosted tenant lifecycle, and cloud-specific enforcement diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1c380ea961..13d0941b34 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,15 @@ ## Purpose +### Confirmed operations opt out of silent transport fallback + +The shared API client accepts expectedOrgID and retry:false for confirmed +Patrol manual-rule removal. It pins and rechecks the selected organisation at +dispatch, respects cancellation after asynchronous CSRF preparation and makes +no automatic auth/tenant/CSRF/rate-limit replay. Existing caller defaults are +unchanged. Tests exercise these safeguards with real request options. + + All drawer History fallbacks must distinguish current observations from retained, unavailable or freshness-unknown guest readings, using original memory evidence and filesystem read reasons. ### Canonical drawer History preserves guest read provenance diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index dfbf50ed95..4753a5c328 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -15,6 +15,27 @@ ## Purpose +### Manual suppression reversal — issue #2528 + +Patrol Activity exposes the existing suppression collection independently of +active findings. It lists only explicitly manual rules with resource and +category scope, exact ID and reason; wildcard scopes are labelled both in the +list and confirmation. Create-rule controls link to this existing Activity +section. Finding dismissals, automatic/legacy rules and history are not removed +or reopened by manual deletion. Reopen finding remains a separate action. + +A confirmation is bound to its organisation, invalidates on organisation or +membership change/disposal, verifies the unchanged manual row with a fresh +authorised read, sends one exact-ID DELETE and requires readback absence before +claiming removal. Any failed or uncertain step hides stale controls and requires +explicit reload, not an automatic mutation retry. Other rules can still cover +the same scope. Component/API tests cover cancellation, stale scope/origin, +authorisation loss, malformed data, concurrent removal, late results and +readback failure. Browser evidence must cover creation through removal and the +actual existing links on desktop and narrow viewports. Synthetic proof is not +publication or the reporter's own removal. + + ### Canonical Patrol and Assistant continuation, 2026-09-07 Canonical action state and legacy approval state remain distinct in Assistant diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index d79af8c40e..1530edb78d 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6947,7 +6947,8 @@ "frontend-modern/src/utils/patrolRuntimeActions.ts", "frontend-modern/src/utils/patrolRuntimePresentation.ts", "frontend-modern/src/utils/patrolSummaryPresentation.ts", - "frontend-modern/src/utils/remediationPresentation.ts" + "frontend-modern/src/utils/remediationPresentation.ts", + "frontend-modern/src/features/patrol/PatrolSuppressionRules.tsx" ], "verification": { "allow_same_subsystem_tests": false, @@ -7098,6 +7099,19 @@ "frontend-modern/src/utils/__tests__/patrolRunPresentation.test.ts", "frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts" ] + }, + { + "id": "patrol-manual-rule-reversal", + "label": "Patrol manual-rule reversal proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/features/patrol/PatrolSuppressionRules.tsx" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/features/patrol/__tests__/PatrolSuppressionRules.test.tsx" + ] } ], "match_files": null diff --git a/frontend-modern/browser-tests/patrol-rule-removal.cjs b/frontend-modern/browser-tests/patrol-rule-removal.cjs new file mode 100644 index 0000000000..bc3569bf3a --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-removal.cjs @@ -0,0 +1,439 @@ +// Full production Patrol surface. Only local API responses are synthetic. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const files = [ + 'src/api/patrol.ts', + 'src/utils/apiClient.ts', + 'src/components/AI/FindingsPanel.tsx', + 'src/features/patrol/PatrolIntelligenceSurface.tsx', + 'src/features/patrol/PatrolAttentionWorkbench.tsx', + 'src/features/patrol/PatrolSuppressionRules.tsx', +]; +const output = '/workspace/tmp/patrol-rule-proof/browser'; +const report = { + playwright: require('playwright/package.json').version, + content_sha256: {}, + cases: [], + captures: [], + cleanup: {}, +}; +fs.mkdirSync(output, { recursive: true }); +for (const file of files) + report.content_sha256[`frontend-modern/${file}`] = hash(`/workspace/frontend-modern/${file}`); +assert.equal( + report.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, +); + +async function journey(root, engine, width, parent = false) { + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, `vite-${parent ? 'parent' : engine}-${width}`), + server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null }, + }); + let browser; + const result = { + parent, + engine, + width, + checks: [], + requests: [], + errors: [], + offOrigin: [], + cleanup: {}, + }; + report.cases.push(result); + const now = '2026-10-05T12:00:00Z'; + const broad = { + id: 'rule_any_any_2', + resource_id: '', + category: '', + description: 'Intentional maintenance', + created_from: 'manual', + created_at: now, + }; + const other = { + id: 'rule_keep_capacity_3', + resource_id: 'fixture-keep', + resource_name: 'Keep VM', + category: 'capacity', + description: 'Keep this unrelated rule', + created_from: 'manual', + created_at: now, + }; + const historical = { + id: 'finding_history', + finding_id: 'history', + resource_id: 'fixture-db', + resource_name: 'Database VM', + category: 'backup', + description: 'Retain this dismissal and note', + created_from: 'dismissed', + created_at: now, + }; + const legacy = { ...other, id: 'rule_legacy', created_from: 'suppress' }; + const rules = [broad, other, historical, legacy]; + const initialUntouched = JSON.stringify(rules); + const item = { + id: 'record-1', + operationalRecordId: 'record-1', + subjectResourceId: 'fixture-db', + subjectResourceName: 'Database VM', + subjectResourceType: 'vm', + kind: 'backup', + title: 'Backup age on Database VM', + plainLanguageSummary: 'The off-site backup is older than expected.', + severity: 'warning', + state: 'open', + firstObservedAt: now, + lastObservedAt: now, + evidenceFreshness: 'fresh', + evidenceCompleteness: 'complete', + impact: 'Check the backup schedule.', + relatedResources: [], + recommendedNextStep: 'Review the off-site backup.', + availableActions: [], + verificationState: 'not_available', + }; + const finding = { + id: 'fixture-finding', + resource_id: 'fixture-db', + resource_name: 'Database VM', + resource_type: 'vm', + category: 'backup', + severity: 'warning', + title: 'Off-site backup age', + description: 'The off-site backup is older than expected.', + detected_at: now, + last_seen_at: now, + times_raised: 1, + suppressed: false, + auto_resolved: false, + mirrors_alert_id: 'fixture-db::backup', + mirrors_alert_type: 'backup', + }; + const summary = { + activeCount: 1, + openCount: 1, + acknowledgedCount: 0, + suppressedCount: 0, + uncertainCount: 0, + resolvedCount: 0, + calm: false, + coverageState: 'current', + evaluatedAt: now, + }; + let loseResponse = false; + let denied = false; + let created; + try { + await server.listen(); + browser = + engine === 'webkit' + ? await webkit.launch({ headless: true }) + : await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] }); + result.browser = browser.version(); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + colorScheme: width === 320 ? 'dark' : 'light', + isMobile: width === 320, + hasTouch: width === 320, + }); + page.setDefaultTimeout(12000); + page.on('pageerror', (error) => result.errors.push(error.message)); + const origin = 'http://127.0.0.1:5297'; + await page.context().addCookies([ + { name: 'pulse_csrf', value: 'synthetic-csrf', url: origin }, + { name: 'pulse_session', value: 'synthetic-session', url: origin }, + ]); + await page.addInitScript(() => { + if (matchMedia('(prefers-color-scheme: dark)').matches) + document.documentElement.classList.add('dark'); + }); + await page.routeWebSocket('**/ws*', () => {}); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()), + method = request.method(), + p = url.pathname; + if (url.origin !== origin) { + result.offOrigin.push(url.origin); + return route.abort(); + } + if (!p.startsWith('/api/')) return route.continue(); + const org = request.headers()['x-pulse-org-id']; + result.requests.push({ path: p, method, org }); + if (p === '/api/ai/patrol/suppressions') { + assert.equal(org, 'fixture-tenant-a'); + if (method === 'GET') { + if (denied) + return route.fulfill({ + status: 403, + json: { error: 'access_revoked' }, + headers: { 'X-CSRF-Token': 'synthetic-replacement' }, + }); + return route.fulfill({ json: rules }); + } + assert.equal(method, 'POST'); + assert.equal(request.headers()['x-csrf-token'], 'synthetic-csrf'); + const input = request.postDataJSON(); + assert.equal(input.resource_id, 'fixture-db'); + assert.equal(input.category, 'backup'); + assert.equal(input.allow_broad_scope, undefined); + created = { + ...input, + id: 'rule_fixture-db_backup_1', + created_from: 'manual', + created_at: now, + }; + rules.push(created); + return route.fulfill({ json: { success: true, rule: created } }); + } + if (p.startsWith('/api/ai/patrol/suppressions/')) { + assert.equal(method, 'DELETE'); + assert.equal(org, 'fixture-tenant-a'); + assert.equal(request.headers()['x-csrf-token'], 'synthetic-csrf'); + const id = decodeURIComponent(p.slice('/api/ai/patrol/suppressions/'.length)); + const index = rules.findIndex((r) => r.id === id); + assert(index >= 0); + assert.equal(rules[index].created_from, 'manual'); + rules.splice(index, 1); + if (loseResponse) { + loseResponse = false; + return route.abort('failed'); + } + return route.fulfill({ json: { success: true } }); + } + assert.equal(method, 'GET', `unexpected mutation ${p}`); + let json = {}; + if (p === '/api/settings/ai') + json = { + patrol_enabled: true, + patrol_autonomy_level: 'monitor', + patrol_readiness: { status: 'ready', ready: true, cause: 'none' }, + }; + else if (p === '/api/ai/patrol/status') + json = { + enabled: true, + runtime_state: 'idle', + running: false, + readiness: { status: 'ready', ready: true, cause: 'none' }, + summary: { active: 1, critical: 0, warning: 1, info: 0 }, + }; + else if (p === '/api/ai/patrol/attention') + json = { data: [item], summary, meta: { page: 1, limit: 50, total: 1, totalPages: 1 } }; + else if (p === '/api/ai/patrol/attention/summary') json = summary; + else if (p === '/api/ai/patrol/attention/record-1') + json = { + item, + operationalRecord: { + id: item.id, + kind: item.kind, + state: item.state, + severity: item.severity, + title: item.title, + subjectResourceId: item.subjectResourceId, + subjectResourceName: item.subjectResourceName, + createdAt: now, + updatedAt: now, + }, + timeline: [], + evidence: [], + }; + else if (p === '/api/ai/patrol/autonomy') + json = { autonomy_level: 'monitor', full_mode_unlocked: false }; + else if (p === '/api/ai/patrol/findings') json = [finding]; + else if (p === '/api/ai/patrol/runs') json = []; + else if (p === '/api/ai/unified/findings') json = { findings: [] }; + else if (p === '/api/ai/approvals') json = { approvals: [] }; + else if (p === '/api/ai/models') json = { models: [] }; + else if (p === '/api/license/runtime-capabilities') + json = { + capabilities: ['ai_patrol'], + limits: [], + hosted_mode: false, + max_history_days: 7, + runtime: { build: 'community', label: 'Pulse Community runtime' }, + blocked_capabilities: [], + }; + return route.fulfill({ json }); + }); + const capture = async (name) => { + const file = path.join(output, `${parent ? 'parent' : engine}-${width}-${name}.png`); + await page.screenshot({ path: file, fullPage: true }); + report.captures.push({ path: file.replace('/workspace/', ''), sha256: hash(file) }); + const dimensions = await page.evaluate(() => ({ + inner: innerWidth, + scroll: document.documentElement.scrollWidth, + })); + assert(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions)); + }; + await page.goto(`${origin}/browser-tests/patrol-rule-removal.html`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.getByRole('button', { name: 'Open Database VM · Backup age' }).click(); + await page + .getByRole('list', { name: 'Lasting decisions' }) + .getByRole('button', { name: 'Create rule', exact: true }) + .click(); + const form = page.getByRole('form', { name: 'Confirm Create rule' }); + assert(await form.getByRole('button', { name: 'Confirm: Create rule' }).isDisabled()); + await form.getByLabel(/Why this rule/).fill('Backups are deliberately held off-site'); + await form.getByRole('button', { name: 'Confirm: Create rule' }).click(); + await page.getByRole('status').filter({ hasText: 'Rule created.' }).waitFor(); + assert(created); + result.checks.push( + 'Production Inbox Create rule sends one narrow scoped POST with a written reason.', + ); + if (parent) { + assert.equal(await page.getByRole('link', { name: 'Manage suppression rules' }).count(), 0); + await page.getByRole('tab', { name: 'Activity' }).click(); + assert.equal(await page.locator('#patrol-suppression-rules').count(), 0); + assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 0); + await capture('no-reversal'); + result.checks.push( + 'Exact assigned parent creates a permanent rule but has no manual-rule reversal control.', + ); + return; + } + await page.getByRole('link', { name: 'Manage suppression rules' }).click(); + await page.getByRole('button', { name: 'Remove rule for Database VM, backup' }).waitFor(); + assert.equal(await page.locator('#patrol-suppression-rules').getAttribute('open'), ''); + await capture('manual-list'); + const select = () => page.getByRole('button', { name: 'Remove rule for Database VM, backup' }); + await select().click(); + let dialog = page.getByRole('dialog', { name: 'Remove suppression rule?' }); + await dialog.getByText(created.id, { exact: true }).waitFor(); + assert( + await dialog + .getByRole('button', { name: 'Cancel' }) + .evaluate((e) => e === document.activeElement), + ); + await capture('exact-confirmation'); + await page.keyboard.press('Escape'); + await dialog.waitFor({ state: 'detached' }); + assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 0); + await select().click(); + await page.getByRole('dialog').getByRole('button', { name: 'Remove this rule' }).click(); + await page.getByRole('status').filter({ hasText: 'Rule removed.' }).waitFor(); + assert.equal(JSON.stringify(rules), initialUntouched); + assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 1); + assert.equal(await select().count(), 0); + await capture('removed-readback'); + result.checks.push( + 'Cancellation is non-mutating; exact manual deletion requires pre-read and post-read and preserves unrelated rules and finding-backed history.', + ); + + // The second existing creation control points at the same reversal flow. + await page.getByRole('button', { name: 'Finding options and history' }).click(); + await page.getByRole('button', { name: 'Finding options for Off-site backup age' }).click(); + await page.getByRole('button', { name: 'Create rule from this' }).click(); + await page.getByRole('textbox', { name: 'Reason for suppression rule' }).waitFor(); + assert.equal( + await page.getByRole('link', { name: 'Manage suppression rules' }).getAttribute('href'), + '/patrol/activity#patrol-suppression-rules', + ); + await page.getByText(/Reopen finding only undoes an individual dismissal/).waitFor(); + await capture('finding-create-link'); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + result.checks.push( + 'Production Finding options create control has an accessible reason field and the same Activity reversal link; no second POST.', + ); + + await page + .getByRole('button', { name: 'Remove rule for All resources, All categories' }) + .click(); + dialog = page.getByRole('dialog'); + await dialog.getByText('This broad rule covers all resources in all categories.').waitFor(); + await capture('wildcard-confirmation'); + loseResponse = true; + await dialog.getByRole('button', { name: 'Remove this rule' }).click(); + await page.getByRole('alert').filter({ hasText: 'Removal could not be confirmed.' }).waitFor(); + assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2); + await capture('uncertain-not-success'); + await page.getByRole('button', { name: 'Reload rules' }).click(); + await page.getByRole('button', { name: 'Remove rule for Keep VM, capacity' }).waitFor(); + assert.equal( + await page + .getByRole('button', { name: 'Remove rule for All resources, All categories' }) + .count(), + 0, + ); + assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2); + result.checks.push( + 'A lost DELETE response never retries or claims removal; explicit reload reconciles absence and keeps other decisions.', + ); + + await page.getByRole('button', { name: 'Remove rule for Keep VM, capacity' }).click(); + denied = true; + await page.getByRole('dialog').getByRole('button', { name: 'Remove this rule' }).click(); + await page.getByRole('alert').filter({ hasText: 'Removal could not be confirmed.' }).waitFor(); + assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2); + denied = false; + await page.getByRole('button', { name: 'Reload rules' }).click(); + await page.getByRole('button', { name: 'Remove rule for Keep VM, capacity' }).click(); + await page.evaluate(() => window.__patrolRuleRemoval.switchOrg()); + await page.getByRole('alert').filter({ hasText: 'Organisation or access changed.' }).waitFor(); + assert.equal(await page.getByRole('dialog').count(), 0); + assert.equal(await page.getByRole('button', { name: /^Remove rule for/ }).count(), 0); + assert.equal(result.requests.filter((r) => r.method === 'DELETE').length, 2); + await capture('scope-invalidated'); + result.checks.push( + 'Revoked access blocks before DELETE, then organisation switch discards pending confirmation and old rule data. No cross-tenant action or automatic retry.', + ); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.offOrigin, []); + } catch (error) { + result.failure = error.message; + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + result.cleanup = { browser_closed: Boolean(browser), server_closed: true }; + } +} +(async () => { + try { + await journey( + '/workspace/tmp/patrol-rule-proof/parent/frontend-modern', + 'chromium', + 1365, + true, + ); + await journey('/workspace/frontend-modern', 'chromium', 1365); + await journey('/workspace/frontend-modern', 'webkit', 320); + report.result = 'passed'; + } catch (error) { + report.result = 'failed'; + report.failure = error.message; + throw error; + } finally { + report.cleanup = { + all_cases_closed: report.cases.every( + (c) => c.cleanup.browser_closed && c.cleanup.server_closed, + ), + }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2)); + } + console.log( + JSON.stringify({ + result: report.result, + cases: report.cases.length, + captures: report.captures.length, + cleanup: report.cleanup, + }), + ); +})().catch((error) => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/patrol-rule-removal.html b/frontend-modern/browser-tests/patrol-rule-removal.html new file mode 100644 index 0000000000..f7a590a483 --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-removal.html @@ -0,0 +1,11 @@ + + +
+ + + + + + + + diff --git a/frontend-modern/browser-tests/patrol-rule-removal.tsx b/frontend-modern/browser-tests/patrol-rule-removal.tsx new file mode 100644 index 0000000000..d0f80b9ef4 --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-removal.tsx @@ -0,0 +1,33 @@ +// Mount the production Patrol surface under a local Router; the browser script +// supplies bounded API responses for the manual-rule creation and reversal states. +import { Route, Router } from '@solidjs/router'; +import { render } from 'solid-js/web'; +import { PatrolIntelligenceSurface } from '../src/features/patrol/PatrolIntelligenceSurface'; +import { eventBus } from '../src/stores/events'; +import { setOrgID } from '../src/utils/apiClient'; +import '../src/index.css'; + +setOrgID('fixture-tenant-a'); +(window as any).__patrolRuleRemoval = { + switchOrg: () => { + setOrgID('fixture-tenant-b'); + eventBus.emit('org_switched', 'fixture-tenant-b'); + }, + revoke: () => eventBus.emit('organizations_changed'), +}; + +render( + () => ( +Future findings matching this resource and category will be auto-dismissed by Patrol - without surfacing as new findings. You can list or remove rules later from the - suppressions management surface. + without surfacing as new findings. Remove this permanent rule under Activity → + Suppression rules; Reopen finding only undoes an individual dismissal.
+