Canonical guest safety evidence
+Synthetic local responses. No guest check or backup is sent.
+From 310c7e6e5195271efc8200aebb3f32feef687c2c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:14:39 +0100 Subject: [PATCH 1/6] Document safe removal of mistakenly created Patrol rules Explain the missing v6.4.5 undo control and the existing scoped API. Verify exact selection, confirmation, browser session and CSRF, failure handling and readback without copying credentials or deleting unrelated rules. Contract-Neutral: Documentation and regression-proof coverage of existing Patrol suppression APIs only; no runtime, route, authority or subsystem contract changes. Change-source: pulse-maintainer --- docs/AI.md | 85 +++++++++- .../browser-tests/patrol-rule-help.cjs | 160 ++++++++++++++++++ frontend-modern/public/docs/AI.md | 85 +++++++++- .../api/patrol_suppression_management_test.go | 107 ++++++++++++ scripts/tests/test_patrol_rule_help.py | 158 +++++++++++++++++ 5 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 frontend-modern/browser-tests/patrol-rule-help.cjs create mode 100644 internal/api/patrol_suppression_management_test.go create mode 100644 scripts/tests/test_patrol_rule_help.py 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/frontend-modern/browser-tests/patrol-rule-help.cjs b/frontend-modern/browser-tests/patrol-rule-help.cjs new file mode 100644 index 0000000000..6e08008b36 --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-help.cjs @@ -0,0 +1,160 @@ +// 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/browser-tests/artifacts/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(`
Cancel keeps both rules. A confirmed removal targets one exact ID.
+No removal confirmed
+${escape(snippet)}`);
+ return;
+ }
+ assert.ok(request.url.startsWith('/api/ai/patrol/suppressions'));
+ 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 === '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);
+ if (name === 'changed-org') {
+ // Change context at the documented confirmation boundary.
+ const changedSnippet = snippet.replace(
+ "const csrf = cookie('pulse_csrf');",
+ "const csrf = cookie('pulse_csrf');");
+ // The browser test changes the cookie when the prompt opens, before
+ // the exact, unchanged snippet reaches its organisation recheck.
+ page.once('dialog', async () => {
+ await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]);
+ });
+ assert.equal(changedSnippet, snippet);
+ }
+ 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/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/internal/api/patrol_suppression_management_test.go b/internal/api/patrol_suppression_management_test.go
new file mode 100644
index 0000000000..9d2575b31d
--- /dev/null
+++ b/internal/api/patrol_suppression_management_test.go
@@ -0,0 +1,107 @@
+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("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()
From becd63364a1c9f9af50aec78359c30bd39a75e9c Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Mon, 5 Oct 2026 17:15:54 +0100
Subject: [PATCH 2/6] Bind rule-help browser controls before releasing the
prompt
Await the organisation-cookie change before accepting the synthetic selection prompt and keep generated proof artefacts outside source admission.
Contract-Neutral: Proof orchestration only; no runtime or documented rule-removal behaviour changes.
Change-source: pulse-maintainer
---
.../browser-tests/patrol-rule-help.cjs | 17 ++++-------------
1 file changed, 4 insertions(+), 13 deletions(-)
diff --git a/frontend-modern/browser-tests/patrol-rule-help.cjs b/frontend-modern/browser-tests/patrol-rule-help.cjs
index 6e08008b36..3df7bb9bc2 100644
--- a/frontend-modern/browser-tests/patrol-rule-help.cjs
+++ b/frontend-modern/browser-tests/patrol-rule-help.cjs
@@ -11,7 +11,7 @@ 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/browser-tests/artifacts/patrol-rule-help';
+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',
@@ -92,6 +92,9 @@ const server = http.createServer((request, response) => {
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 {
@@ -100,18 +103,6 @@ const server = http.createServer((request, response) => {
}
});
await page.goto(url);
- if (name === 'changed-org') {
- // Change context at the documented confirmation boundary.
- const changedSnippet = snippet.replace(
- "const csrf = cookie('pulse_csrf');",
- "const csrf = cookie('pulse_csrf');");
- // The browser test changes the cookie when the prompt opens, before
- // the exact, unchanged snippet reaches its organisation recheck.
- page.once('dialog', async () => {
- await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]);
- });
- assert.equal(changedSnippet, snippet);
- }
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');
From ee618fb99366bb76c798f8ada028de85079d4dfa Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Mon, 5 Oct 2026 17:16:03 +0100
Subject: [PATCH 3/6] Guard canonical VM Discovery during guest-read deferrals
Use the existing guest safety signals in the canonical resource drawer, including typed and legacy PVE facets. Keep saved Discovery evidence readable and the backup precaution visible without admitting a manual rescan while paused.
Contract-Neutral: performance-and-scalability; the workload helper input is narrowed without changing its decisions, metrics, render path or performance contract.
Change-source: pulse-maintainer
---
.../internal/subsystems/unified-resources.md | 24 ++
.../browser-tests/resource-guest-safety.cjs | 312 ++++++++++++++++++
.../browser-tests/resource-guest-safety.html | 12 +
.../browser-tests/resource-guest-safety.tsx | 64 ++++
.../cd3f858e636899f4.json | 45 +++
.../Infrastructure/ResourceDetailDrawer.tsx | 19 ++
.../ResourceDetailDrawerOverviewTab.tsx | 1 +
.../ResourceDetailDrawer.discovery.test.ts | 75 ++++-
...ResourceDetailDrawer.guest-safety.test.tsx | 135 ++++++++
.../resourceDetailDiscoveryModel.ts | 28 ++
.../useResourceDetailDrawerDerivedState.ts | 7 +-
.../components/Workloads/guestDrawerModel.ts | 24 +-
12 files changed, 740 insertions(+), 6 deletions(-)
create mode 100644 frontend-modern/browser-tests/resource-guest-safety.cjs
create mode 100644 frontend-modern/browser-tests/resource-guest-safety.html
create mode 100644 frontend-modern/browser-tests/resource-guest-safety.tsx
create mode 100644 frontend-modern/browser-verification/cd3f858e636899f4.json
create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 15c3f338e7..94a3586bfc 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/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 @@
+
+
+
+
+
+ Synthetic local responses. No guest check or backup is sent.
+{message()}
+{GUEST_DRAWER_BACKUP_PRECAUTION}
+${escape(snippet)}`);
return;
}
- assert.ok(request.url.startsWith('/api/ai/patrol/suppressions'));
+ 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';
diff --git a/internal/api/patrol_suppression_management_test.go b/internal/api/patrol_suppression_management_test.go
index 9d2575b31d..1add39ed89 100644
--- a/internal/api/patrol_suppression_management_test.go
+++ b/internal/api/patrol_suppression_management_test.go
@@ -31,6 +31,7 @@ func TestPatrolSuppressionManagementBrowserSession(t *testing.T) {
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 {
From ce2111c089b451fb59f410f180b979ba95619b1c Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Mon, 5 Oct 2026 17:41:55 +0100
Subject: [PATCH 5/6] Keep shared guest-read evidence optional across workload
types
Correct the union input identified by source-9p3hq3rc without changing guest-read decisions. Keep required resource identity fields explicit in the new test fixtures.
Contract-Neutral: performance-and-scalability; type-only optional guest-read input correction and typed fixture fields, with unchanged runtime decisions, metrics and performance contract.
Change-source: pulse-maintainer
---
.../browser-tests/resource-guest-safety.tsx | 1 +
...ResourceDetailDrawer.guest-safety.test.tsx | 4 +++-
.../components/Workloads/guestDrawerModel.ts | 19 ++++++++-----------
3 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/frontend-modern/browser-tests/resource-guest-safety.tsx b/frontend-modern/browser-tests/resource-guest-safety.tsx
index dc048daf34..b307a6fd2e 100644
--- a/frontend-modern/browser-tests/resource-guest-safety.tsx
+++ b/frontend-modern/browser-tests/resource-guest-safety.tsx
@@ -24,6 +24,7 @@ const resource = (
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'],
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
index 3f3a1ced6b..6651b5f197 100644
--- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx
+++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx
@@ -42,6 +42,8 @@ const vm = (patch: Partial