diff --git a/docs/AI.md b/docs/AI.md
index 520ac9525a..f23b965ece 100644
--- a/docs/AI.md
+++ b/docs/AI.md
@@ -230,10 +230,93 @@ Findings can be managed via the UI or API:
- **Get help**: Chat with AI to troubleshoot the issue
- **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces)
-- **Dismiss**: Mark as expected behavior (creates suppression rule)
+- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal
+- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category
Dismissed and resolved findings persist across Pulse restarts.
+#### Remove a rule created by mistake
+
+In v6.4.5, **Create rule** has no corresponding rule-list or delete control in
+the UI. **Reopen** undoes an individual finding's dismissal, not a separately
+created rule. The existing API can list rules and delete one by its exact ID.
+Deleting a created rule allows future matching findings to appear again; it
+does not resolve a problem or automatically reopen previously dismissed
+findings. It leaves the other rules and finding history intact.
+
+For a rule made with **Create rule**, sign in normally to Pulse in an
+authorised administrator's browser session, select the correct organisation,
+and open that Pulse tab's developer console. Review the following snippet
+before running it. It lists only manually created rules, asks for one exact
+ID, and confirms its resource, category and reason before deleting it. Cancel
+either prompt to leave the rules unchanged. Keep the list private: resource
+names and reasons can describe your infrastructure.
+
+No password, API token or session cookie needs to be copied or pasted. The
+browser supplies its existing session; the snippet reads only the normal
+CSRF and organisation cookies locally. Do not switch organisation while it
+runs, use it on another website, disable authentication/CSRF protection, or
+edit Pulse's data files. A token-only login or an expired session may not
+support this workaround: stop on an authentication or missing-CSRF error
+instead of inserting credentials into the snippet.
+
+```javascript
+(async () => {
+ const cookie = (name) => {
+ const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`));
+ return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : '';
+ };
+ const org = cookie('pulse_org_id') || 'default';
+ const headers = {
+ Accept: 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'X-Pulse-Org-ID': org,
+ };
+ const readRules = async () => {
+ if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
+ const response = await fetch('/api/ai/patrol/suppressions', {
+ credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers,
+ });
+ if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`);
+ const rules = await response.json();
+ if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.');
+ return rules.filter((rule) => rule && rule.created_from === 'manual');
+ };
+ const rules = await readRules();
+ console.table(rules.map((rule) => ({
+ id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource',
+ category: rule.category || 'Any category', reason: rule.description,
+ })));
+ const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.');
+ if (id === null || !id.trim()) return;
+ const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim());
+ if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.');
+ const rule = matches[0];
+ if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return;
+ if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
+ const csrf = cookie('pulse_csrf');
+ if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.');
+ const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, {
+ method: 'DELETE', credentials: 'same-origin', redirect: 'error',
+ headers: { ...headers, 'X-CSRF-Token': csrf },
+ });
+ if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`);
+ if ((await readRules()).some((remaining) => remaining.id === rule.id)) {
+ throw new Error('Rule is still listed; removal was not verified.');
+ }
+ console.info('Selected rule is no longer listed. Other rules were not deleted.');
+})().catch((error) => console.error(error.message));
+```
+
+A failed or lost response is not confirmation of removal. Do not blindly run
+the deletion again: refresh the rule list first (open
+`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the
+selected ID. If the rule is absent, it was already removed. If you cannot
+verify the result, stop and report only the HTTP status or error, not cookies,
+tokens or the full rule list. Entries whose `created_from` is `finding` or
+`dismissed` are individual finding decisions and are deliberately excluded
+from this snippet.
+
Every active finding shown or returned to a Patrol run must receive an
explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an
all-clear signal. `present` refreshes current evidence, `resolved` remains
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 21ebac450c..7269935c42 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -23,6 +23,30 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
+### Canonical Discovery honours VM guest-read deferrals
+
+The canonical resource drawer uses the same fixed PVE VM lock, backup and
+read-deferral signals as the Workloads drawer. Typed and legacy PVE facets are
+checked independently: an available flag in one cannot cancel a pause in the
+other. Only the VM's own provider evidence is used; parent-node locks, an agent
+version and a Discovery target are not guest-liveness evidence. Containers,
+ordinary agents and unrelated provider facets retain their existing behaviour.
+
+Both embedded Analysis and the dedicated Discovery entry pass this pause to the
+existing manual-run guard. Saved results remain readable; same-ID updates block
+or release the existing control without an automatic scan, invented timestamp
+or cancellation claim for already-dispatched work. The wrapping precaution and
+existing safety-guide link remain visible across drawer tabs. Clearance is not
+independent thaw, covered-filesystem writes or safe monitoring resumption.
+
+`ResourceDetailDrawer.discovery.test.ts` covers both facets, every fixed reason,
+precedence and platform isolation. `ResourceDetailDrawer.guest-safety.test.tsx`
+mounts the real Discovery client/control, checks same-ID updates and no dispatch
+while paused, and preserves normal agent actions. The production browser fixture
+`browser-tests/resource-guest-safety.cjs` checks desktop keyboard and narrow touch
+with synthetic local responses. No backend, permission, collector or native
+recovery guarantee is added.
+
### Broadcast bulk reads capture resources and history coordinates together
`ListWithMetricsTargets` and the monitor adapter's matching optional read return
diff --git a/frontend-modern/browser-tests/patrol-rule-help.cjs b/frontend-modern/browser-tests/patrol-rule-help.cjs
new file mode 100644
index 0000000000..1f5ff4f0fe
--- /dev/null
+++ b/frontend-modern/browser-tests/patrol-rule-help.cjs
@@ -0,0 +1,154 @@
+// Run from the Pulse workspace root with pulse-worker-browser.
+// This exercises the documented snippet against a synthetic same-origin API,
+// not a deployed Pulse installation or the still-missing rule-management UI.
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const fs = require('node:fs');
+const http = require('node:http');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+
+const guide = fs.readFileSync('docs/AI.md', 'utf8');
+const section = guide.split('#### Remove a rule created by mistake\n')[1];
+const snippet = section.match(/```javascript\n([\s\S]*?)\n```/)[1];
+const output = 'frontend-modern/test-results/patrol-rule-help';
+fs.mkdirSync(output, { recursive: true });
+const escape = (text) => text.replaceAll('&', '&').replaceAll('<', '<');
+const selected = { id: 'rule-one', created_from: 'manual', resource_name: 'Test VM',
+ resource_id: 'vm-1', category: 'backup', description: 'Created by mistake' };
+const other = { ...selected, id: 'rule-two', resource_id: 'vm-2', resource_name: 'Other VM' };
+let scenario;
+const server = http.createServer((request, response) => {
+ if (request.url === '/') {
+ response.setHeader('Content-Type', 'text/html; charset=utf-8');
+ response.setHeader('Set-Cookie', [
+ 'pulse_session=synthetic-session; Path=/; HttpOnly; SameSite=Lax',
+ 'pulse_org_id=org-a; Path=/; SameSite=Lax',
+ ...(scenario.noCSRF ? [] : ['pulse_csrf=synthetic-csrf; Path=/; SameSite=Lax']),
+ ]);
+ response.end(`
Rule removal help — synthetic browser check
+ Cancel keeps both rules. A confirmed removal targets one exact ID.
+ No removal confirmed
+ ${escape(snippet)}`);
+ return;
+ }
+ if (!request.url.startsWith('/api/ai/patrol/suppressions')) {
+ response.writeHead(404).end();
+ return;
+ }
+ const hasSession = (request.headers.cookie || '').includes('pulse_session=synthetic-session');
+ const expectedOrg = request.headers['x-pulse-org-id'] === 'org-a';
+ const validCSRF = request.headers['x-csrf-token'] === 'synthetic-csrf';
+ scenario.requests.push({ method: request.method, path: request.url,
+ hasSession, expectedOrg, validCSRF });
+ response.setHeader('Content-Type', 'application/json');
+ if (!hasSession || !expectedOrg) {
+ response.writeHead(403).end('{}');
+ return;
+ }
+ if (request.method === 'DELETE') {
+ assert.equal(request.url, '/api/ai/patrol/suppressions/rule-one');
+ if (!validCSRF || scenario.rejectDelete) {
+ response.writeHead(403).end('{}');
+ return;
+ }
+ scenario.removed = true;
+ response.end('{"success":true}');
+ return;
+ }
+ assert.equal(request.method, 'GET');
+ assert.equal(request.url, '/api/ai/patrol/suppressions');
+ if (scenario.removed && scenario.readbackFailure) {
+ response.writeHead(503).end('{}');
+ return;
+ }
+ response.end(JSON.stringify(scenario.removed ? [other] : [selected, other]));
+});
+
+(async () => {
+ let browser;
+ const receipts = [];
+ try {
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const url = `http://127.0.0.1:${server.address().port}/`;
+ for (const [engineName, engine] of [['chromium', chromium], ['webkit', webkit]]) {
+ browser = await engine.launch(engineName === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true });
+ const cases = engineName === 'chromium'
+ ? ['prompt-cancel', 'confirm-cancel', 'success', 'missing-csrf', 'changed-org',
+ 'delete-rejected', 'readback-failed']
+ : ['prompt-cancel', 'success'];
+ for (const name of cases) {
+ scenario = { name, requests: [], removed: false,
+ noCSRF: name === 'missing-csrf', rejectDelete: name === 'delete-rejected',
+ readbackFailure: name === 'readback-failed' };
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+ const messages = [];
+ const dialogs = [];
+ page.on('console', (message) => {
+ if (['info', 'error'].includes(message.type())) {
+ messages.push({ kind: message.type(), message: message.text() });
+ }
+ });
+ page.on('dialog', async (dialog) => {
+ dialogs.push({ type: dialog.type(), message: dialog.message() });
+ if (dialog.type() === 'prompt') {
+ if (name === 'changed-org') {
+ await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]);
+ }
+ if (name === 'prompt-cancel') await dialog.dismiss();
+ else await dialog.accept('rule-one');
+ } else {
+ if (name === 'confirm-cancel') await dialog.dismiss();
+ else await dialog.accept();
+ }
+ });
+ await page.goto(url);
+ await page.evaluate((code) => window.eval(code), snippet);
+ const deletes = scenario.requests.filter((request) => request.method === 'DELETE');
+ const success = messages.some((message) => message.kind === 'info');
+ if (name === 'success') {
+ assert.equal(deletes.length, 1);
+ assert.ok(deletes[0].validCSRF);
+ assert.equal(scenario.requests.length, 3);
+ assert.ok(scenario.removed);
+ assert.ok(success);
+ } else {
+ assert.equal(success, false);
+ if (['delete-rejected', 'readback-failed'].includes(name)) {
+ assert.equal(deletes.length, 1);
+ } else {
+ assert.equal(deletes.length, 0);
+ }
+ }
+ assert.ok(scenario.requests.every((request) => request.hasSession && request.expectedOrg));
+ assert.ok(messages.every((message) => !message.message.includes('synthetic-csrf')));
+ assert.ok(dialogs.every((dialog) => !dialog.message.includes('synthetic-csrf')));
+ await page.locator('#result').evaluate((element, text) => { element.textContent = text; },
+ success ? 'Selected rule absent after readback; other rule retained' : `${name}: no removal confirmed`);
+ let screenshot;
+ if (['success', 'confirm-cancel', 'delete-rejected'].includes(name)) {
+ screenshot = path.join(output, `${engineName}-${name}.png`);
+ await page.screenshot({ path: screenshot, fullPage: true });
+ }
+ receipts.push({ engine: engineName, browserVersion: browser.version(), name,
+ requests: scenario.requests, messages, dialogs, removed: scenario.removed, screenshot });
+ await context.close();
+ }
+ await browser.close();
+ browser = null;
+ }
+ const result = { guideSHA256: crypto.createHash('sha256').update(guide).digest('hex'),
+ playwrightVersion: require('playwright/package.json').version,
+ evidenceKind: 'synthetic same-origin browser/API workaround; no installed Pulse or rule-management UI',
+ scenarios: receipts };
+ fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
+ console.log(JSON.stringify({ passed: receipts.length, guideSHA256: result.guideSHA256,
+ playwrightVersion: result.playwrightVersion, result: path.join(output, 'result.json') }));
+ } finally {
+ if (browser) await browser.close();
+ await new Promise((resolve) => server.close(resolve));
+ }
+})().catch((error) => { console.error(error); process.exitCode = 1; });
diff --git a/frontend-modern/browser-tests/resource-guest-safety.cjs b/frontend-modern/browser-tests/resource-guest-safety.cjs
new file mode 100644
index 0000000000..d1d4a4a2b8
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-guest-safety.cjs
@@ -0,0 +1,312 @@
+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 output = '/workspace/tmp/resource-guest-safety-settled-proof';
+const paths = [
+ 'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
+ 'frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts',
+ 'frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts',
+ 'frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx',
+ 'frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx',
+];
+const reasons = [
+ 'vm-locked',
+ 'lock-unverified',
+ 'agent-busy',
+ 'agent-cooldown',
+ 'agent-response-incomplete',
+ 'agent-capacity',
+ 'invalid-guest-key',
+ 'agent-timeout',
+];
+const saved = {
+ id: 'vm:fixture-node-agent:101',
+ resource_type: 'vm',
+ resource_id: '101',
+ target_id: 'fixture-node-agent',
+ hostname: 'Backup guest',
+ service_type: 'home-assistant',
+ service_name: 'Saved Home Assistant',
+ service_version: 'test',
+ category: 'home_automation',
+ cli_access: '',
+ facts: [],
+ config_paths: [],
+ data_paths: [],
+ log_paths: [],
+ ports: [],
+ user_notes: '',
+ user_secrets: {},
+ confidence: 0.9,
+ ai_reasoning: 'Synthetic saved evidence',
+ discovered_at: '2026-10-04T12:00:00Z',
+ updated_at: '2026-10-04T12:00:00Z',
+ discovery_engine_version: 1,
+};
+const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const report = {
+ runtime_sha256: Object.fromEntries(paths.map((p) => [p, hash(path.join('/workspace', p))])),
+ playwright: require('playwright/package.json').version,
+ groups: [],
+ cleanup: {},
+ limits:
+ 'Synthetic local snapshots and HTTP only. No native QGA, diagnostics, backup, thaw, filesystem write, service resumption, physical phone, publication or release acceptance.',
+ };
+ assert.equal(
+ report.playwright,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const { createServer } =
+ await import('/workspace/frontend-modern/node_modules/vite/dist/node/index.js');
+ let server, browser, phase;
+ try {
+ for (const [name, engine, width, dark] of [
+ ['final-chromium', chromium, 1365, false],
+ ['final-webkit-phone', webkit, 320, true],
+ ]) {
+ phase = name;
+ const module = '/workspace/frontend-modern';
+ process.chdir(module);
+ server = await createServer({
+ root: module,
+ configFile: path.join(module, 'vite.config.ts'),
+ cacheDir: path.join(output, name, 'vite'),
+ server: {
+ host: '127.0.0.1',
+ port: 5299,
+ strictPort: true,
+ watch: null,
+ fs: { allow: ['/workspace'] },
+ },
+ });
+ await server.listen();
+ const origin = 'http://127.0.0.1:5299';
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width, height: width < 500 ? 844 : 900 },
+ isMobile: width < 500,
+ hasTouch: width < 500,
+ locale: 'en-GB',
+ });
+ page.setDefaultTimeout(10000);
+ const group = {
+ name,
+ width,
+ dark,
+ browser: browser.version(),
+ requests: [],
+ errors: [],
+ offOrigin: [],
+ checks: [],
+ screenshots: [],
+ };
+ report.groups.push(group);
+ const check = (label) => group.checks.push(label);
+ const capture = async (label) => {
+ const file = path.join(output, `${name}-${label}.png`);
+ await page.screenshot({ path: file, fullPage: true });
+ group.screenshots.push({
+ path: file.replace('/workspace/', ''),
+ sha256: hash(file),
+ bytes: fs.statSync(file).size,
+ });
+ };
+ page.on('pageerror', (e) => group.errors.push(e.message));
+ await page.routeWebSocket('**/ws*', () => {});
+ await page.route('**/*', async (route) => {
+ const req = route.request(),
+ url = new URL(req.url());
+ if (url.origin !== origin) {
+ group.offOrigin.push(url.origin);
+ return route.abort();
+ }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ group.requests.push({
+ path: url.pathname,
+ query: url.search,
+ method: req.method(),
+ body: req.postData(),
+ });
+ if (
+ url.pathname === '/api/discovery/vm/fixture-node-agent/101' ||
+ url.pathname === '/api/discovery/agent/fixture-node-agent/101'
+ )
+ return route.fulfill({ json: saved });
+ assert.equal(req.method(), 'GET');
+ if (url.pathname.startsWith('/api/discovery/info/'))
+ return route.fulfill({
+ json: {
+ ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
+ commands: [],
+ command_categories: [],
+ },
+ });
+ if (url.pathname === '/api/ai/agents')
+ return route.fulfill({
+ json: {
+ count: 1,
+ agents: [
+ {
+ agent_id: 'fixture-node-agent',
+ hostname: 'pve-a',
+ commands_enabled: true,
+ connected: true,
+ },
+ ],
+ },
+ });
+ if (url.pathname === '/api/license/runtime-capabilities')
+ return route.fulfill({
+ json: {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community' },
+ blocked_capabilities: [],
+ },
+ });
+ return route.fulfill({
+ json: {
+ capabilities: [],
+ relationships: [],
+ recentChanges: [],
+ counts: {},
+ audits: [],
+ count: 0,
+ data: [],
+ enabled: false,
+ },
+ });
+ });
+ await page.goto(`${origin}/browser-tests/resource-guest-safety.html`, {
+ waitUntil: 'domcontentloaded',
+ timeout: 60000,
+ });
+ await page.evaluate((d) => document.documentElement.classList.toggle('dark', d), dark);
+ const activate = async (locator) => (width < 500 ? locator.tap() : locator.press('Enter'));
+ await activate(page.getByRole('tab', { name: 'Manage', exact: true }));
+ const analysis = page.getByTestId('resource-access-analysis');
+ await activate(analysis.getByRole('button', { name: 'Open analysis' }));
+ await analysis.getByText('Saved Home Assistant', { exact: true }).waitFor();
+ const button = analysis.getByRole('button', { name: 'Update Discovery' });
+ await button.waitFor();
+ const posts = () => group.requests.filter((r) => r.method === 'POST');
+ const notice = page.getByTestId('resource-guest-read-precaution');
+ await notice.waitFor();
+ assert(await button.isDisabled());
+ assert.match(await notice.innerText(), /An OK backup or a running VM does not prove thaw/);
+ assert.equal(
+ await notice.getByRole('link', { name: 'Backup safety guidance' }).getAttribute('href'),
+ '/docs/VM_DISK_MONITORING',
+ );
+ const control = await button.elementHandle();
+ const update = (evidence, legacy = false, type = 'vm') =>
+ page.evaluate(
+ ({ evidence, legacy, type }) =>
+ window.__resourceGuestSafety.update(evidence, legacy, type),
+ { evidence, legacy, type },
+ );
+ for (const legacy of [false, true]) {
+ for (const reason of reasons) {
+ for (const diskStatusReason of [reason, `prev-${reason}`]) {
+ await update({ diskStatusReason, guestAgentStatus: 'available' }, legacy);
+ assert(await button.isDisabled());
+ assert(await notice.isVisible());
+ assert.equal(posts().length, 0);
+ }
+ }
+ for (const evidence of [
+ { lock: 'backup' },
+ { backupInProgress: true },
+ { guestAgentStatus: 'deferred' },
+ ]) {
+ await update(evidence, legacy);
+ assert(await button.isDisabled());
+ }
+ }
+ assert(await button.evaluate((node, old) => node === old, control));
+ assert.equal(await analysis.getByText('Saved Home Assistant', { exact: true }).count(), 1);
+ check(
+ 'All 32 reason/facet variants and six operation variants block the same mounted rescan; saved evidence remains readable; zero POST.',
+ );
+ await update({ diskStatusReason: 'prev-agent-timeout' }, true);
+ const geometry = await notice.boundingBox();
+ assert(geometry && geometry.x >= 0 && geometry.x + geometry.width <= width);
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
+ const describedBy = await button.getAttribute('aria-describedby');
+ assert(describedBy && (await page.locator(`#${describedBy}`).isVisible()));
+ await capture('paused-saved');
+ check('Disabled rescan is described and narrow geometry has no horizontal overflow.');
+ await update({ guestAgentStatus: 'available' });
+ assert(await button.isEnabled());
+ assert.equal(await notice.count(), 0);
+ assert(await button.evaluate((node, old) => node === old, control));
+ assert.equal(posts().length, 0);
+ check(
+ 'Healthy same-VM evidence clears the guard without an automatic scan or remount; no thaw claim.',
+ );
+ await capture('cleared');
+ const response = page.waitForResponse(
+ (r) =>
+ r.url() === `${origin}/api/discovery/vm/fixture-node-agent/101` &&
+ r.request().method() === 'POST',
+ );
+ await activate(button);
+ await response;
+ assert.equal(posts().length, 1);
+ assert.equal(posts()[0].path, '/api/discovery/vm/fixture-node-agent/101');
+ check(
+ 'An explicit permitted keyboard/touch action sends exactly one synthetic local request to the unchanged VM target.',
+ );
+ await update({ lock: 'backup' });
+ assert(await button.isDisabled());
+ await activate(page.getByRole('tab', { name: 'Overview', exact: true }));
+ assert(await notice.isVisible());
+ check('Precaution and guide stay visible on Overview after leaving Analysis.');
+ await update({ lock: 'backup' }, false, 'agent');
+ await activate(page.getByRole('tab', { name: 'Discovery', exact: true }));
+ const agentButton = page.getByRole('button', { name: 'Run Discovery', exact: true });
+ await agentButton.waitFor();
+ assert(await agentButton.isEnabled());
+ assert.equal(await notice.count(), 0);
+ check('Ordinary agent Discovery tab is not paused by parent-side PVE evidence.');
+ assert.deepEqual(group.errors, []);
+ assert.deepEqual(group.offOrigin, []);
+ check('No page error, off-origin request, native target or background POST.');
+ await browser.close();
+ browser = null;
+ await server.close();
+ server = null;
+ }
+ } catch (error) {
+ report.failure = { phase, message: error.message };
+ throw error;
+ } finally {
+ if (browser) await browser.close();
+ if (server) await server.close();
+ report.cleanup = { browser_closed: true, server_closed: true };
+ fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2));
+ }
+ console.log(
+ JSON.stringify({
+ groups: report.groups.length,
+ checks: report.groups.reduce((n, g) => n + g.checks.length, 0),
+ screenshots: report.groups.reduce((n, g) => n + g.screenshots.length, 0),
+ cleanup: report.cleanup,
+ }),
+ );
+})().catch((e) => {
+ console.error(e.stack);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/resource-guest-safety.html b/frontend-modern/browser-tests/resource-guest-safety.html
new file mode 100644
index 0000000000..b641a56e4b
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-guest-safety.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Canonical guest safety evidence
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/resource-guest-safety.tsx b/frontend-modern/browser-tests/resource-guest-safety.tsx
new file mode 100644
index 0000000000..b307a6fd2e
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-guest-safety.tsx
@@ -0,0 +1,65 @@
+// Real canonical drawer/state/Discovery clients; synthetic provider snapshots.
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
+import { DarkModeContext } from '../src/contexts/appRuntime';
+import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
+import type { Resource, ResourceProxmoxMeta } from '../src/types/resource';
+import '../src/index.css';
+
+syncAIRuntimeSettings({ discovery_enabled: true });
+const healthy: ResourceProxmoxMeta = {
+ nodeName: 'pve-a',
+ vmid: 101,
+ guestAgentStatus: 'available',
+ lock: '',
+ diskStatusReason: '',
+};
+const resource = (
+ evidence: ResourceProxmoxMeta,
+ legacy = false,
+ type: Resource['type'] = 'vm',
+): Resource => ({
+ id: 'fixture:pve-a:101',
+ type,
+ name: 'Backup guest',
+ displayName: 'Backup guest',
+ platformId: 'fixture',
+ platformType: type === 'agent' ? 'agent' : 'proxmox-pve',
+ platformScopes: type === 'agent' ? ['agent'] : ['proxmox-pve'],
+ sources: type === 'agent' ? ['agent'] : ['proxmox'],
+ sourceType: type === 'agent' ? 'agent' : 'api',
+ status: 'online',
+ lastSeen: Date.now(),
+ cpu: { current: 10 },
+ memory: { current: 25, used: 1024, total: 4096 },
+ discoveryTarget: {
+ resourceType: type === 'agent' ? 'agent' : 'vm',
+ agentId: 'fixture-node-agent',
+ resourceId: '101',
+ },
+ proxmox: legacy ? healthy : { ...healthy, ...evidence },
+ platformData: legacy ? { proxmox: evidence } : undefined,
+});
+function Fixture() {
+ const [value, setValue] = createSignal(resource({ lock: 'backup' }));
+ (window as any).__resourceGuestSafety = {
+ update: (evidence: ResourceProxmoxMeta, legacy = false, type: Resource['type'] = 'vm') =>
+ setValue(resource(evidence, legacy, type)),
+ };
+ return (
+
+ Canonical guest safety evidence
+ Synthetic local responses. No guest check or backup is sent.
+
+
+ );
+}
+render(
+ () => (
+ document.documentElement.classList.contains('dark')}>
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification/cd3f858e636899f4.json b/frontend-modern/browser-verification/cd3f858e636899f4.json
new file mode 100644
index 0000000000..77cb78f9e7
--- /dev/null
+++ b/frontend-modern/browser-verification/cd3f858e636899f4.json
@@ -0,0 +1,45 @@
+{
+ "version": 1,
+ "base_sha": "90a7d2e0ad08109132bed4747690868861f67e23",
+ "verified_at": "2026-10-05T16:15:22.197327Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
+ "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts",
+ "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "3d89b12c95fc35bb93fa703a7d10beb06296fb4ed9b9f0c313f22afc0ff0b6e5",
+ "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "969e4051af443b50e45443241a8b4e0f9e66a8f7b51c43d64ab4fd9e5c3f9a84",
+ "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts": "7958a719302571e07dde329231554c59f2fa2d3d49852486b54e929df2856af8",
+ "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "8abfb347ac32bdd4cb88676999b523f69d8c8f1ea5febd7736a64ba7b57159b5",
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "53dc922a8cc70d3e39f51554c581707cd496fa1086290c8ebd6f20fc226933ba"
+ },
+ "routes": [
+ "/browser-tests/resource-guest-safety.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 320,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Real canonical resource drawer/state/Discovery client and CSS, synthetic local snapshots and HTTP only. Parent at the receipt base exposes enabled Update Discovery under backup lock; no parent POST was sent.",
+ "Desktop Chromium and dark 320px touch WebKit cover every eight fixed reasons, retained variants and typed/legacy facets (32 each), plus backup/lock/deferred signals. A healthy alternate facet never cancels the pause. Same-ID updates retain the mounted control and saved service evidence.",
+ "First browser attempt exited one on an early dispatch-count assertion after desktop pause/clearance checks; it did not establish dispatch or phone acceptance. Remaining dispatch checks synchronise on the actual POST response; the complete phone matrix and both explicit synthetic actions pass with zero page errors/off-origin requests and closed browser/server.",
+ "All five captures inspected: parent gap, desktop paused/cleared and phone paused/cleared. Wrapping precaution/guide and saved results fit without horizontal overflow; the disabled rescan has a visible description. Agent Discovery remains usable outside the VM safety condition.",
+ "This is not native QGA, diagnostics, backup, thaw, covered-filesystem writes, liveness, safe resumption, physical-phone, installation, publication or release acceptance."
+ ],
+ "interactions": [
+ "Desktop keyboard activation and phone taps use the actual Manage -> Access -> Analysis controls. All paused variants send zero POST; same-ID clearance neither remounts the control nor sends an automatic scan.",
+ "An explicit healthy VM action sends exactly one synthetic local POST per engine to the existing VM/agent/101 identity. Pause remains visible when leaving Analysis for Overview; ordinary agent Discovery is not paused by host-side PVE evidence.",
+ "Original desktop matrix/parent observations are retained rather than repeated in the remaining-proof run. Exact executed scripts, results, client exits and screenshot hashes are retained in the lane dossier; production content matches this receipt."
+ ]
+}
diff --git a/frontend-modern/browser-verification/e1e985a3fee492c0.json b/frontend-modern/browser-verification/e1e985a3fee492c0.json
new file mode 100644
index 0000000000..f58aa5fdaf
--- /dev/null
+++ b/frontend-modern/browser-verification/e1e985a3fee492c0.json
@@ -0,0 +1,37 @@
+{
+ "version": 1,
+ "base_sha": "ce2111c089b451fb59f410f180b979ba95619b1c",
+ "verified_at": "2026-10-05T16:59:02.182220Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "0b5db44ee2a4f393ebac64cc9900721a3dac880b10f0b0cf1fd43b9eb07f0304"
+ },
+ "routes": [
+ "/browser-tests/resource-guest-safety.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 320,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Fresh final-content checks after the type-only shared input correction. Real canonical drawer/Discovery client and CSS with synthetic local HTTP only; Chromium 141.0.7390.37 and dark touch WebKit 26.0, Playwright 1.56.1 matches the integration lock.",
+ "Both viewports cover empty saved data during backup, same-ID clearance with no automatic scan, then retained legacy timeout with reported availability re-pausing the same control. Three checks per engine, no POST, page errors or off-origin requests; browser/server closure confirmed.",
+ "Both captures inspected: backup-safety notice and guide wrap beside a visible, disabled Discovery control; the 320px page has no horizontal overflow.",
+ "Two earlier phone admissions returned gateway restarting before execution and no operation started. This successful fresh admission followed the completed corrected-source proof; it does not diagnose the restart or clear earlier stopped/unknown browser operations.",
+ "The earlier own-parent receipt remains valid for the four unchanged canonical production files and their complete saved-result/explicit-action matrix. This receipt verifies only the changed shared helper final content.",
+ "No native QGA, backup, thaw, every-covered-filesystem writes, liveness, prior-active-only resumption, physical phone, installation, publication or release acceptance."
+ ],
+ "interactions": [
+ "Keyboard Manage -> Open analysis; reactive same-ID provider snapshots retain the actual Discovery control and revoke/restore its disabled state without dispatch.",
+ "Desktop and phone capture/DOM checks use the exact optional-evidence helper content hashed here. No earlier completed matrix or unavailable executed operation was replayed."
+ ]
+}
diff --git a/frontend-modern/public/docs/AI.md b/frontend-modern/public/docs/AI.md
index 520ac9525a..f23b965ece 100644
--- a/frontend-modern/public/docs/AI.md
+++ b/frontend-modern/public/docs/AI.md
@@ -230,10 +230,93 @@ Findings can be managed via the UI or API:
- **Get help**: Chat with AI to troubleshoot the issue
- **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces)
-- **Dismiss**: Mark as expected behavior (creates suppression rule)
+- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal
+- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category
Dismissed and resolved findings persist across Pulse restarts.
+#### Remove a rule created by mistake
+
+In v6.4.5, **Create rule** has no corresponding rule-list or delete control in
+the UI. **Reopen** undoes an individual finding's dismissal, not a separately
+created rule. The existing API can list rules and delete one by its exact ID.
+Deleting a created rule allows future matching findings to appear again; it
+does not resolve a problem or automatically reopen previously dismissed
+findings. It leaves the other rules and finding history intact.
+
+For a rule made with **Create rule**, sign in normally to Pulse in an
+authorised administrator's browser session, select the correct organisation,
+and open that Pulse tab's developer console. Review the following snippet
+before running it. It lists only manually created rules, asks for one exact
+ID, and confirms its resource, category and reason before deleting it. Cancel
+either prompt to leave the rules unchanged. Keep the list private: resource
+names and reasons can describe your infrastructure.
+
+No password, API token or session cookie needs to be copied or pasted. The
+browser supplies its existing session; the snippet reads only the normal
+CSRF and organisation cookies locally. Do not switch organisation while it
+runs, use it on another website, disable authentication/CSRF protection, or
+edit Pulse's data files. A token-only login or an expired session may not
+support this workaround: stop on an authentication or missing-CSRF error
+instead of inserting credentials into the snippet.
+
+```javascript
+(async () => {
+ const cookie = (name) => {
+ const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`));
+ return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : '';
+ };
+ const org = cookie('pulse_org_id') || 'default';
+ const headers = {
+ Accept: 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'X-Pulse-Org-ID': org,
+ };
+ const readRules = async () => {
+ if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
+ const response = await fetch('/api/ai/patrol/suppressions', {
+ credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers,
+ });
+ if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`);
+ const rules = await response.json();
+ if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.');
+ return rules.filter((rule) => rule && rule.created_from === 'manual');
+ };
+ const rules = await readRules();
+ console.table(rules.map((rule) => ({
+ id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource',
+ category: rule.category || 'Any category', reason: rule.description,
+ })));
+ const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.');
+ if (id === null || !id.trim()) return;
+ const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim());
+ if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.');
+ const rule = matches[0];
+ if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return;
+ if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.');
+ const csrf = cookie('pulse_csrf');
+ if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.');
+ const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, {
+ method: 'DELETE', credentials: 'same-origin', redirect: 'error',
+ headers: { ...headers, 'X-CSRF-Token': csrf },
+ });
+ if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`);
+ if ((await readRules()).some((remaining) => remaining.id === rule.id)) {
+ throw new Error('Rule is still listed; removal was not verified.');
+ }
+ console.info('Selected rule is no longer listed. Other rules were not deleted.');
+})().catch((error) => console.error(error.message));
+```
+
+A failed or lost response is not confirmation of removal. Do not blindly run
+the deletion again: refresh the rule list first (open
+`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the
+selected ID. If the rule is absent, it was already removed. If you cannot
+verify the result, stop and report only the HTTP status or error, not cookies,
+tokens or the full rule list. Entries whose `created_from` is `finding` or
+`dismissed` are individual finding decisions and are deliberately excluded
+from this snippet.
+
Every active finding shown or returned to a Patrol run must receive an
explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an
all-clear signal. `present` refreshes current evidence, `resolved` remains
diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx
index 23ea1a0de7..243092e9dd 100644
--- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx
+++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx
@@ -31,6 +31,9 @@ import { ResourceOperatorStateSection } from './ResourceOperatorStateSection';
import { MaintenanceVerificationSection } from './MaintenanceVerificationSection';
import { ResourceActionHistory } from './ResourceActionHistory';
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from '@/features/docker/dockerCpuPresentation';
+import { InlineNotice } from '@/components/shared/InlineNotice';
+import { GUEST_DRAWER_BACKUP_PRECAUTION } from '@/components/Workloads/guestDrawerModel';
+import { getShippedDocUrl } from '@/utils/docsLinks';
interface ResourceDetailDrawerProps {
resource: Resource;
@@ -159,6 +162,21 @@ const DrawerContent: Component = (props) => {
+
+ {(message) => (
+
+ {message()}
+ {GUEST_DRAWER_BACKUP_PRECAUTION}
+
+ )}
+
+
= (props) => {
canonicalResourceId={props.resource.id}
commandsEnabled={drawer.agentMeta()?.commandsEnabled}
showManualRunAction
+ runBlockReason={drawer.guestReadPrecaution()}
/>
)}
diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx
index d58b0d7cb3..75ac3bb823 100644
--- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx
+++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx
@@ -337,6 +337,7 @@ export const ResourceAccessDisclosure: Component<{
hostname={config().hostname}
canonicalResourceId={props.resource.id}
commandsEnabled={props.drawer.agentMeta()?.commandsEnabled}
+ runBlockReason={props.drawer.guestReadPrecaution()}
/>
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts
index bfa830187a..f13a976d3e 100644
--- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts
+++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts
@@ -5,7 +5,11 @@ import type { ResourceDiscovery } from '@/types/discovery';
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
import discoveryReadinessSource from '@/components/Discovery/discoveryReadiness.ts?raw';
import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw';
-import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
+import {
+ getResourceGuestReadPrecaution,
+ toDiscoveryConfig,
+} from '@/components/Infrastructure/resourceDetailDiscoveryModel';
+import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
import { hasMeaningfulDiscoveryContext } from '@/utils/discoveryPresentation';
const baseResource = (): Resource => ({
@@ -28,6 +32,75 @@ const baseResource = (): Resource => ({
},
});
+describe('canonical VM guest-read safety', () => {
+ const vm = (): Resource => ({ ...baseResource(), type: 'vm' });
+
+ it.each(guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]))(
+ 'honours %s from typed and legacy PVE evidence independently',
+ (diskStatusReason) => {
+ for (const facet of ['typed', 'legacy']) {
+ const evidence = { diskStatusReason, guestAgentStatus: 'available' };
+ const value = vm();
+ if (facet === 'typed') value.proxmox = evidence;
+ else value.platformData = { proxmox: evidence };
+ expect(getResourceGuestReadPrecaution(value)).toBeTruthy();
+ expect(getResourceGuestReadPrecaution(value)).not.toContain(diskStatusReason);
+ }
+ },
+ );
+
+ it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])(
+ 'does not let a healthy alternate facet cancel %j',
+ (paused) => {
+ const healthy = { guestAgentStatus: 'available', diskStatusReason: '', lock: '' };
+ expect(
+ getResourceGuestReadPrecaution({
+ ...vm(),
+ proxmox: healthy,
+ platformData: { proxmox: paused },
+ }),
+ ).toBeTruthy();
+ expect(
+ getResourceGuestReadPrecaution({
+ ...vm(),
+ proxmox: paused,
+ platformData: { proxmox: healthy },
+ }),
+ ).toBeTruthy();
+ },
+ );
+
+ it.each(['agent', 'system-container', 'app-container'] as const)(
+ 'does not turn parent or container PVE evidence into a %s QGA pause',
+ (type) => {
+ expect(
+ getResourceGuestReadPrecaution({ ...vm(), type, proxmox: { lock: 'backup' } }),
+ ).toBeNull();
+ },
+ );
+
+ it('leaves healthy/unrelated VMs alone and withholds unknown raw provider text', () => {
+ expect(getResourceGuestReadPrecaution(vm())).toBeNull();
+ expect(
+ getResourceGuestReadPrecaution({ ...vm(), proxmox: { guestAgentStatus: 'available' } }),
+ ).toBeNull();
+ expect(
+ getResourceGuestReadPrecaution({
+ ...vm(),
+ platformType: 'vmware-vsphere',
+ platformData: { vmware: { lock: 'backup' } },
+ }),
+ ).toBeNull();
+ expect(
+ getResourceGuestReadPrecaution({
+ ...vm(),
+ proxmox: { lock: 'private provider text', diskStatusReason: 'private read reason' },
+ }),
+ ).not.toMatch(/private provider text|private read reason/);
+ expect(getResourceGuestReadPrecaution({ ...vm(), platformData: { proxmox: [] } })).toBeNull();
+ });
+});
+
describe('toDiscoveryConfig', () => {
it('prefers backend discoveryTarget over heuristic IDs', () => {
const resource: Resource = {
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx
new file mode 100644
index 0000000000..6651b5f197
--- /dev/null
+++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx
@@ -0,0 +1,137 @@
+import { createSignal, Suspense } from 'solid-js';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Resource } from '@/types/resource';
+import { ResourceDetailDrawer } from '../ResourceDetailDrawer';
+import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
+import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
+import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
+import * as discoveryApi from '@/api/discovery';
+import { getShippedDocUrl } from '@/utils/docsLinks';
+
+vi.mock('@/api/discovery', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getDiscovery: vi.fn(async () => null),
+ getDiscoveryInfo: vi.fn(async () => ({
+ ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
+ commands: [],
+ command_categories: [],
+ })),
+ getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
+ triggerDiscovery: vi.fn(async () => null),
+}));
+vi.mock('@/api/resources', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ResourceAPI: {
+ getFacetBundle: vi.fn(async () => ({
+ capabilities: [],
+ relationships: [],
+ recentChanges: [],
+ counts: {},
+ })),
+ },
+}));
+vi.mock('@/api/ai', () => ({ AIAPI: { getResourceIntelligence: vi.fn(async () => null) } }));
+vi.mock('@/api/actionAudit', () => ({
+ ActionAuditAPI: {
+ listActionAudits: vi.fn(async () => ({ audits: [], count: 0, available: false })),
+ },
+}));
+
+const vm = (patch: Partial = {}): Resource => ({
+ id: 'fixture:pve1:101',
+ type: 'vm',
+ name: 'Backup guest',
+ displayName: 'Backup guest',
+ platformId: 'fixture',
+ status: 'online',
+ sourceType: 'api',
+ platformType: 'proxmox-pve',
+ lastSeen: 1,
+ proxmox: { nodeName: 'pve1', vmid: 101, guestAgentStatus: 'available' },
+ discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '101' },
+ ...patch,
+});
+const mount = (value: () => Resource) =>
+ render(() => (
+
+
+
+ ));
+const openAnalysis = async () => {
+ fireEvent.click(await screen.findByRole('tab', { name: 'Manage' }));
+ fireEvent.click(await screen.findByRole('button', { name: 'Open analysis' }));
+ return within(screen.getByTestId('resource-access-analysis')).findByRole('button', {
+ name: 'Run Discovery',
+ });
+};
+
+beforeEach(() => {
+ resetAIRuntimeState();
+ syncAIRuntimeSettings({ discovery_enabled: true } as Parameters[0]);
+ resetCreateNonSuspendingQueryCacheForTest();
+});
+afterEach(() => {
+ cleanup();
+ resetAIRuntimeState();
+ vi.clearAllMocks();
+});
+
+describe('canonical drawer real Discovery safety wiring', () => {
+ it('blocks every retained/current deferral reactively without remounting or dispatching a scan', async () => {
+ const [value, setValue] = createSignal(vm({ proxmox: { lock: 'backup' } }));
+ mount(value);
+ const button = await openAnalysis();
+ await waitFor(() => expect(button).toBeDisabled());
+ expect(screen.getByTestId('resource-guest-read-precaution')).toBeVisible();
+ expect(
+ within(screen.getByTestId('resource-guest-read-precaution')).getByRole('link', {
+ name: 'Backup safety guidance',
+ }),
+ ).toHaveAttribute('href', getShippedDocUrl('VM_DISK_MONITORING.md'));
+ for (const [reason] of guestDiskDeferrals) {
+ for (const diskStatusReason of [reason, `prev-${reason}`]) {
+ setValue(vm({ proxmox: { diskStatusReason, guestAgentStatus: 'available' } }));
+ await waitFor(() => expect(button).toBeDisabled());
+ fireEvent.click(button);
+ }
+ }
+ setValue(vm({ platformData: { proxmox: { backupInProgress: true } } }));
+ await waitFor(() => expect(button).toBeDisabled());
+ setValue(vm());
+ await waitFor(() => expect(button).toBeEnabled());
+ expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument();
+ expect(
+ within(screen.getByTestId('resource-access-analysis')).getByRole('button', {
+ name: 'Run Discovery',
+ }),
+ ).toBe(button);
+ expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
+ fireEvent.click(button);
+ await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
+ expect(discoveryApi.triggerDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '101', {
+ force: true,
+ hostname: 'Backup guest',
+ });
+ });
+
+ it('keeps ordinary agent Discovery tab actions usable despite host-side PVE locks', async () => {
+ mount(() =>
+ vm({
+ id: 'node-agent',
+ type: 'agent',
+ sourceType: 'agent',
+ platformType: 'agent',
+ sources: ['agent'],
+ proxmox: { lock: 'backup' },
+ discoveryTarget: { resourceType: 'agent', agentId: 'node-agent', resourceId: 'node-agent' },
+ }),
+ );
+ fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
+ const button = await screen.findByRole('button', { name: 'Run Discovery' });
+ await waitFor(() => expect(button).toBeEnabled());
+ expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument();
+ fireEvent.click(button);
+ await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts
index c7c915a6e7..d9a5dbd404 100644
--- a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts
+++ b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts
@@ -1,4 +1,5 @@
import type { Resource } from '@/types/resource';
+import { getGuestDrawerGuestReadPrecaution } from '@/components/Workloads/guestDrawerModel';
import type { ResourceType as DiscoveryResourceType } from '@/types/discovery';
import {
canonicalDiscoveryResourceType,
@@ -64,6 +65,33 @@ type PlatformData = {
const asString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
+// The canonical drawer has both typed and legacy provider facets. A healthy
+// facet cannot cancel a deferral still reported by the other one. Inspect only
+// this VM's own PVE evidence, not its parent, Discovery target or cached version.
+export const getResourceGuestReadPrecaution = (resource: Resource): string | null => {
+ if (resource.type !== 'vm') return null;
+ const legacy = resource.platformData?.proxmox;
+ const facets = [
+ resource.proxmox,
+ legacy && typeof legacy === 'object' && !Array.isArray(legacy)
+ ? (legacy as Record)
+ : undefined,
+ ];
+ for (const facet of facets) {
+ if (!facet) continue;
+ const precaution = getGuestDrawerGuestReadPrecaution({
+ type: 'vm',
+ platformScopes: ['proxmox-pve'],
+ guestAgentStatus: asString(facet.guestAgentStatus),
+ diskStatusReason: asString(facet.diskStatusReason),
+ lock: asString(facet.lock),
+ backupInProgress: facet.backupInProgress === true,
+ });
+ if (precaution) return precaution;
+ }
+ return null;
+};
+
const asNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts
index 0131b0df70..66e329b8e3 100644
--- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts
+++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts
@@ -41,7 +41,10 @@ import {
type KubernetesPlatformData,
type PlatformData,
} from '@/components/Infrastructure/resourceDetailMappers';
-import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
+import {
+ getResourceGuestReadPrecaution,
+ toDiscoveryConfig,
+} from '@/components/Infrastructure/resourceDetailDiscoveryModel';
import { getDiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
import {
buildPbsActiveTasks,
@@ -298,6 +301,7 @@ export const useResourceDetailDrawerDerivedState = (
const hasAliasOverflow = createMemo(() => identityView().hasAliasOverflow);
const hasMergedSources = createMemo(() => mergedSources().length > 1);
const discoveryConfig = createMemo(() => toDiscoveryConfig(resource()));
+ const guestReadPrecaution = createMemo(() => getResourceGuestReadPrecaution(resource()));
const discoveryContextSummary = createMemo(() => buildDiscoveryContextSummary(discoveryConfig()));
const discoverySourceKey = createMemo(() => {
if (!discoveryFeatureEnabled()) return null;
@@ -517,6 +521,7 @@ export const useResourceDetailDrawerDerivedState = (
metricsHistoryGroups,
hasMetricsHistory,
hasDiscoveryTab,
+ guestReadPrecaution,
hasVMwareDetails,
vmwareDetailsSummary,
vmwareDetailSections,
diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
index 974a0b8f84..f78290928c 100644
--- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts
+++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
@@ -439,6 +439,18 @@ export interface GuestDrawerGuestReadPresentation {
precaution: boolean;
}
+// Both drawer shapes use the same provider-owned signals. A canonical resource
+// need not fabricate a complete workload (or infer liveness from metrics).
+export interface GuestDrawerGuestReadEvidence extends Pick<
+ Guest,
+ 'type' | 'workloadType' | 'agentKind' | 'platformType' | 'platformScopes'
+> {
+ diskStatusReason?: VM['diskStatusReason'];
+ guestAgentStatus?: VM['guestAgentStatus'];
+ lock?: VM['lock'];
+ backupInProgress?: VM['backupInProgress'];
+}
+
export const GUEST_DRAWER_BACKUP_PRECAUTION =
'Do not run live diagnostics or restart the guest agent during a backup, freeze/thaw or an unresponsive-guest incident. An OK backup or a running VM does not prove thaw. Confirm thaw and writes to the filesystems covered by the backup independently.';
@@ -457,9 +469,9 @@ const guestReadDeferrals = new Set([
// parent-agent action target establishes QGA liveness. Use only the current
// provider state and fixed read reasons; never print an unknown raw value.
export const getGuestDrawerGuestReadPresentation = (
- guest: Guest,
+ guest: GuestDrawerGuestReadEvidence,
): GuestDrawerGuestReadPresentation | null => {
- if (!isGuestDrawerVM(guest)) return null;
+ if (resolveWorkloadType(guest) !== 'vm') return null;
const hasProxmoxEvidence =
guest.type === 'qemu' ||
guest.agentKind === 'qemu-guest' ||
@@ -545,8 +557,9 @@ export const getGuestDrawerGuestReadPresentation = (
// A read-specific precaution is independent of the reported availability flag.
// For example Proxmox can report QGA enabled while its last request timed out.
-export const getGuestDrawerGuestReadPrecaution = (guest: Guest): string | null => {
- if (!isGuestDrawerVM(guest)) return null;
+export const getGuestDrawerGuestReadPrecaution = (
+ guest: GuestDrawerGuestReadEvidence,
+): string | null => {
const presentation = getGuestDrawerGuestReadPresentation(guest);
if (!presentation?.precaution) return null;
const reason = (guest.diskStatusReason || '').replace(/^prev-/, '');
diff --git a/internal/api/patrol_suppression_management_test.go b/internal/api/patrol_suppression_management_test.go
new file mode 100644
index 0000000000..1add39ed89
--- /dev/null
+++ b/internal/api/patrol_suppression_management_test.go
@@ -0,0 +1,108 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/ai"
+ "github.com/rcourtman/pulse-go-rewrite/internal/config"
+)
+
+// The rule-removal help uses existing session and CSRF protection, not a token
+// pasted into a command. Exercise the actual handlers/store and Router middleware.
+func TestPatrolSuppressionManagementBrowserSession(t *testing.T) {
+ router, session := newRouterWithSession(t)
+ handler := newTestAISettingsHandler(router.config, config.NewConfigPersistence(router.config.DataPath), nil)
+ handler.defaultAIService.SetStateProvider(&stubStateProvider{})
+ t.Cleanup(handler.defaultAIService.Stop)
+ patrol := handler.defaultAIService.GetPatrolService()
+ if patrol == nil {
+ t.Fatal("expected Patrol service")
+ }
+ store := patrol.GetFindings()
+ selected := store.AddSuppressionRule("vm-1", "Test VM", ai.FindingCategoryBackup, "Test reason")
+ other := store.AddSuppressionRule("vm-2", "Other VM", ai.FindingCategoryCapacity, "Keep this rule")
+ router.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleGetSuppressionRules)))
+ router.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleDeleteSuppressionRule)))
+
+ request := func(method, path, csrf string, authenticated bool) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, path, nil)
+ req.RemoteAddr = "127.0.0.1:12345"
+ req.Header.Set("User-Agent", "test-agent")
+ req.Header.Set("X-Requested-With", "XMLHttpRequest")
+ req.Header.Set("X-Pulse-Org-ID", "default")
+ if authenticated {
+ req.AddCookie(&http.Cookie{Name: "pulse_session", Value: session})
+ }
+ if csrf != "" {
+ req.Header.Set("X-CSRF-Token", csrf)
+ }
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+ }
+
+ unauthenticated := request(http.MethodGet, "/api/ai/patrol/suppressions", "", false)
+ if unauthenticated.Code != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated list status = %d, want 401", unauthenticated.Code)
+ }
+ list := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true)
+ if list.Code != http.StatusOK {
+ t.Fatalf("authenticated list status = %d, want 200", list.Code)
+ }
+ var rules []ai.SuppressionRule
+ if err := json.Unmarshal(list.Body.Bytes(), &rules); err != nil {
+ t.Fatal(err)
+ }
+ if len(rules) != 2 {
+ t.Fatalf("listed %d rules, want 2", len(rules))
+ }
+ for _, rule := range rules {
+ if rule.CreatedFrom != "manual" {
+ t.Fatalf("created_from = %q, want manual", rule.CreatedFrom)
+ }
+ }
+ csrf := ""
+ for _, cookie := range list.Result().Cookies() {
+ if cookie.Name == "pulse_csrf" {
+ csrf = cookie.Value
+ }
+ }
+ if csrf == "" {
+ t.Fatal("authenticated GET must issue a browser CSRF cookie")
+ }
+ path := "/api/ai/patrol/suppressions/" + selected.ID
+ for _, token := range []string{"", "invalid-csrf"} {
+ rec := request(http.MethodDelete, path, token, true)
+ if rec.Code != http.StatusForbidden || len(store.GetSuppressionRules()) != 2 {
+ t.Fatalf("invalid-CSRF deletion status = %d; both rules must remain", rec.Code)
+ }
+ }
+ removed := request(http.MethodDelete, path, csrf, true)
+ if removed.Code != http.StatusOK {
+ t.Fatalf("deletion status = %d, want 200", removed.Code)
+ }
+ if store.MatchesSuppressionRule("vm-1", ai.FindingCategoryBackup) {
+ t.Fatal("selected rule still suppresses future matching findings")
+ }
+ if !store.MatchesSuppressionRule("vm-2", ai.FindingCategoryCapacity) {
+ t.Fatal("unselected rule must remain effective")
+ }
+ readback := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true)
+ if readback.Code != http.StatusOK {
+ t.Fatalf("readback status = %d, want 200", readback.Code)
+ }
+ if err := json.Unmarshal(readback.Body.Bytes(), &rules); err != nil {
+ t.Fatal(err)
+ }
+ if len(rules) != 1 || rules[0].ID != other.ID {
+ t.Fatal("readback must retain only the unselected rule")
+ }
+ notFound := request(http.MethodDelete, path, csrf, true)
+ if notFound.Code != http.StatusNotFound || len(store.GetSuppressionRules()) != 1 {
+ t.Fatalf("already-removed deletion status = %d; remaining rule must survive", notFound.Code)
+ }
+}
diff --git a/scripts/tests/test_patrol_rule_help.py b/scripts/tests/test_patrol_rule_help.py
new file mode 100644
index 0000000000..2db8927b59
--- /dev/null
+++ b/scripts/tests/test_patrol_rule_help.py
@@ -0,0 +1,158 @@
+"""Exercise the exact documented browser workaround with synthetic state only."""
+
+import json
+from pathlib import Path
+import re
+import subprocess
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+GUIDE = ROOT / "docs" / "AI.md"
+SHIPPED_GUIDE = ROOT / "frontend-modern" / "public" / "docs" / "AI.md"
+
+
+def removal_snippet():
+ section = GUIDE.read_text().split("#### Remove a rule created by mistake\n", 1)[1]
+ return re.search(r"```javascript\n(.*?)\n```", section, re.S).group(1)
+
+
+HARNESS = r"""
+const vm = require('node:vm');
+let input = '';
+process.stdin.on('data', chunk => input += chunk);
+process.stdin.on('end', async () => {
+ const options = JSON.parse(input);
+ const calls = [], messages = [], tables = [], confirmations = [];
+ let cookie = options.cookie ?? 'pulse_org_id=org-a; pulse_csrf=synthetic-csrf';
+ const context = {
+ document: { get cookie() { return cookie; } },
+ prompt: () => options.id === undefined ? null : options.id,
+ confirm: message => {
+ confirmations.push(message);
+ if (options.changedOrg) cookie = 'pulse_org_id=org-b; pulse_csrf=synthetic-csrf';
+ return options.confirm ?? true;
+ },
+ fetch: async (path, init) => {
+ calls.push({path, ...init});
+ const result = options.responses[calls.length - 1];
+ if (!result) throw new Error('Unexpected additional request');
+ if (result.networkFailure) throw new Error('Synthetic network failure');
+ return {ok: result.status >= 200 && result.status < 300,
+ status: result.status, json: async () => result.body};
+ },
+ console: {
+ table: rows => tables.push(rows),
+ info: message => messages.push({kind: 'info', message}),
+ error: message => messages.push({kind: 'error', message}),
+ },
+ };
+ await vm.runInNewContext(options.snippet, context, {timeout: 1000});
+ process.stdout.write(JSON.stringify({calls, messages, tables, confirmations}));
+});
+"""
+
+
+RULE = {"id": "rule-one", "created_from": "manual", "resource_id": "vm-1",
+ "resource_name": "Test VM", "category": "backup", "description": "Test reason"}
+OTHER_RULE = {**RULE, "id": "rule-two", "resource_id": "vm-2"}
+DISMISSAL = {**RULE, "id": "finding_one", "created_from": "dismissed"}
+
+
+class PatrolRuleHelpTests(unittest.TestCase):
+ def run_snippet(self, **options):
+ options.setdefault("responses", [{"status": 200, "body": [RULE, OTHER_RULE, DISMISSAL]}])
+ options["snippet"] = removal_snippet()
+ result = subprocess.run(["node", "-e", HARNESS], input=json.dumps(options),
+ text=True, capture_output=True, check=True, timeout=5)
+ return json.loads(result.stdout)
+
+ def assert_no_delete(self, result):
+ self.assertFalse(any(call.get("method") == "DELETE" for call in result["calls"]))
+ self.assertFalse(any(message["kind"] == "info" for message in result["messages"]))
+
+ def test_shipped_help_matches_source(self):
+ self.assertEqual(GUIDE.read_bytes(), SHIPPED_GUIDE.read_bytes())
+
+ def test_cancel_is_read_only_and_hides_finding_decisions(self):
+ result = self.run_snippet()
+ self.assert_no_delete(result)
+ self.assertEqual(len(result["calls"]), 1)
+ self.assertEqual([row["id"] for row in result["tables"][0]], ["rule-one", "rule-two"])
+
+ def test_unknown_blank_and_finding_ids_cannot_delete(self):
+ for rule_id in ["missing", " ", "finding_one"]:
+ with self.subTest(rule_id=rule_id):
+ self.assert_no_delete(self.run_snippet(id=rule_id))
+
+ def test_duplicate_id_cannot_delete(self):
+ self.assert_no_delete(self.run_snippet(id="rule-one", responses=[
+ {"status": 200, "body": [RULE, RULE]}]))
+
+ def test_confirmation_cancel_is_read_only(self):
+ result = self.run_snippet(id="rule-one", confirm=False)
+ self.assert_no_delete(result)
+ self.assertIn("Test VM", result["confirmations"][0])
+ self.assertIn("backup", result["confirmations"][0])
+ self.assertIn("Test reason", result["confirmations"][0])
+
+ def test_missing_csrf_and_changed_organisation_stop(self):
+ for options in [{"cookie": "pulse_org_id=org-a"}, {"changedOrg": True}]:
+ with self.subTest(options=options):
+ self.assert_no_delete(self.run_snippet(id="rule-one", **options))
+
+ def test_failed_or_malformed_list_cannot_delete(self):
+ for response in [{"status": 401}, {"status": 403}, {"status": 200, "body": {}},
+ {"networkFailure": True}]:
+ with self.subTest(response=response):
+ self.assert_no_delete(self.run_snippet(id="rule-one", responses=[response]))
+
+ def test_delete_failure_or_lost_response_is_not_retried_or_reported_as_success(self):
+ for response in [{"status": 403}, {"status": 404}, {"status": 503},
+ {"networkFailure": True}]:
+ with self.subTest(response=response):
+ result = self.run_snippet(id="rule-one", responses=[
+ {"status": 200, "body": [RULE, OTHER_RULE]}, response])
+ self.assertEqual(len(result["calls"]), 2)
+ self.assertFalse(any(m["kind"] == "info" for m in result["messages"]))
+
+ def test_readback_must_succeed_and_show_selected_rule_absent(self):
+ for response in [{"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 403},
+ {"networkFailure": True}]:
+ with self.subTest(response=response):
+ result = self.run_snippet(id="rule-one", responses=[
+ {"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 200}, response])
+ self.assertEqual(len(result["calls"]), 3)
+ self.assertFalse(any(m["kind"] == "info" for m in result["messages"]))
+
+ def test_only_selected_rule_is_deleted_using_bound_browser_context(self):
+ rule = {**RULE, "id": "rule /?other"}
+ result = self.run_snippet(id=rule["id"], responses=[
+ {"status": 200, "body": [rule, OTHER_RULE, DISMISSAL]}, {"status": 200},
+ {"status": 200, "body": [OTHER_RULE, DISMISSAL]}])
+ self.assertEqual(len(result["calls"]), 3)
+ delete = result["calls"][1]
+ self.assertEqual(delete["path"], "/api/ai/patrol/suppressions/rule%20%2F%3Fother")
+ self.assertEqual(delete["headers"]["X-CSRF-Token"], "synthetic-csrf")
+ for call in result["calls"]:
+ self.assertEqual(call["credentials"], "same-origin")
+ self.assertEqual(call["redirect"], "error")
+ self.assertEqual(call["headers"]["X-Pulse-Org-ID"], "org-a")
+ self.assertNotIn("X-API-Token", call["headers"])
+ self.assertEqual(result["messages"][0]["kind"], "info")
+ self.assertNotIn("synthetic-csrf", json.dumps(result["tables"]))
+ self.assertNotIn("synthetic-csrf", json.dumps(result["messages"]))
+
+ def test_wildcard_scope_is_explicit_in_list_and_confirmation(self):
+ rule = {**RULE, "resource_id": "", "resource_name": "", "category": ""}
+ result = self.run_snippet(id=rule["id"], confirm=False,
+ responses=[{"status": 200, "body": [rule]}])
+ self.assert_no_delete(result)
+ self.assertEqual(result["tables"][0][0]["resource"], "Any resource")
+ self.assertEqual(result["tables"][0][0]["category"], "Any category")
+ self.assertIn("Any resource", result["confirmations"][0])
+ self.assertIn("Any category", result["confirmations"][0])
+
+
+if __name__ == "__main__":
+ unittest.main()