From 820d8c27efbf0aef515d2cddfcf07f2e4c33db8d Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Thu, 22 Oct 2026 18:01:43 -0400 Subject: [PATCH 1/2] feat(console): simplify policy denials and add batch rule review --- .../src/console/agents/sandbox-policy.mts | 1039 ++++++++++++++--- apps/controller/src/console/console.css | 518 +++++++- docs/flows/agent-sandbox-policy.md | 26 +- docs/guides/topics/agent-sandbox-policy.md | 56 +- docs/reference/sandbox-policies.md | 16 +- scripts/ci/test-suites/checks-browser-2.json | 4 + .../pages-agent-detail.stories.ts | 2 + scripts/console-storybook/policy-workflow.md | 77 +- scripts/console-storybook/public/fixtures.mjs | 50 +- .../console-storybook/public/scenarios.mjs | 13 +- .../console-agent-draft-guards.test.mjs | 55 +- .../console-agent-save-refusals.test.ts | 9 +- tests/browser/console-policy-denials.test.ts | 189 +++ 13 files changed, 1743 insertions(+), 311 deletions(-) create mode 100644 tests/browser/console-policy-denials.test.ts diff --git a/apps/controller/src/console/agents/sandbox-policy.mts b/apps/controller/src/console/agents/sandbox-policy.mts index 7c595c7868..019bbc6cca 100644 --- a/apps/controller/src/console/agents/sandbox-policy.mts +++ b/apps/controller/src/console/agents/sandbox-policy.mts @@ -2,6 +2,7 @@ import type { Agent, AgentRuntimeLogPageWire, DriverConfigurationDescriptor, + JSONSchema, RuntimeLogRecord, SandboxNetworkGrant, SandboxPolicyRecommendations, @@ -14,7 +15,7 @@ import { configurationErrors, configurationAction, } from "../configuration-fields.mjs"; -import { button, element } from "../dom.mjs"; +import { button, dismissOnBackdrop, element } from "../dom.mjs"; import type { NamespacePageContext } from "../namespaces.mjs"; import { askForGrant, @@ -112,60 +113,147 @@ function readableRules(value: unknown): value is SandboxNetworkGrant[] { ); } -function decisionRow(record: LogLine, review?: (grant: SandboxNetworkGrant) => void) { - const decision = record.decision; - const fields = record.fields ?? {}; - const binary = decision?.binary ?? fields.binary; - const host = decision?.host ?? fields.dst_host; - const port = decision?.port ?? fields.dst_port; - const reason = decision?.reason ?? fields.reason; - const executable = typeof binary === "string" ? binary.split("/").at(-1) : undefined; - const row = element( - "article", - { className: "sandbox-decision" }, - element( - "h3", - {}, - `Denied · ${visibleText(executable ?? "Unknown executable")} → ${visibleText(host ?? "Unknown destination")}${port ? `:${visibleText(port)}` : ""}`, - ), - decision?.enforcement === "blocked" - ? element("span", { className: "status-badge status-failed" }, "Blocked") - : null, - reason - ? element("p", {}, visibleText(reason)) - : element("p", { className: "muted" }, "Reason not reported"), +function denialGrant(record: LogLine): SandboxNetworkGrant | undefined { + const binary = record.decision?.binary ?? record.fields?.binary; + const host = record.decision?.host ?? record.fields?.dst_host; + const port = Number(record.decision?.port ?? record.fields?.dst_port); + if ( + typeof binary !== "string" || + !binary.startsWith("/") || + typeof host !== "string" || + !host || + !Number.isSafeInteger(port) || + port < 1 || + port > 65535 + ) { + return undefined; + } + return { id: `network-${crypto.randomUUID()}`, binary, host, ports: [{ from: port, to: port }] }; +} + +function grantKey(grant: SandboxNetworkGrant) { + return JSON.stringify([ + grant.binary, + grant.host, + [...grant.ports].sort((a, b) => a.from - b.from || a.to - b.to), + ]); +} + +function grantCovered(grant: SandboxNetworkGrant, existing: readonly SandboxNetworkGrant[]) { + return existing.some( + (rule) => + rule.binary === grant.binary && + rule.host === grant.host && + grant.ports.every((port) => + rule.ports.some((range) => range.from <= port.from && range.to >= port.to), + ), + ); +} + +function pagination(total: number, page: number, size: number, change: (page: number) => void) { + const pages = Math.max(1, Math.ceil(total / size)); + return element( + "nav", + { className: "sandbox-pagination", "aria-label": "Pages" }, element( - "p", + "span", { className: "muted" }, - `Rule: ${visibleText(decision?.rule ?? fields.rule_name ?? "unknown")} · Engine: ${visibleText(decision?.engine ?? fields.rule_type ?? "unknown")} · Policy generation: ${visibleText(decision?.policyGeneration ?? fields.policy_generation ?? "unknown")}`, + total ? `${(page - 1) * size + 1}–${Math.min(page * size, total)} of ${total}` : "0 results", ), element( - "details", - {}, - element("summary", {}, "Decision details and sanitized log"), - recordRow(record), + "div", + { className: "sandbox-page-actions" }, + button("Previous", () => change(page - 1), { size: "sm", disabled: page <= 1 }), + element("span", {}, `Page ${page} of ${pages}`), + button("Next", () => change(page + 1), { size: "sm", disabled: page >= pages }), ), ); - if ( - review && - typeof binary === "string" && - binary.startsWith("/") && - typeof host === "string" && - host && - Number(port) > 0 - ) { - row.append( - button("Review policy addition", () => - review({ - id: `network-${Date.now().toString(36)}`, - binary, - host, - ports: [{ from: Number(port), to: Number(port) }], - }), +} + +function ruleList(rules: readonly unknown[], actions?: (index: number) => Node) { + const root = element("div", { className: "sandbox-rule-collection" }); + let page = 1; + const search = element("input", { + type: "search", + "aria-label": "Search network rules", + placeholder: "Search executable or destination", + }); + const rows = element("div", { className: "sandbox-rule-table" }); + const pager = element("div"); + function render() { + const query = search.value.trim().toLowerCase(); + const filtered = rules + .map((value, index) => { + const fields = responseFields(value); + const rule = { + id: typeof fields.id === "string" ? fields.id : "", + binary: typeof fields.binary === "string" ? fields.binary : "Executable not set", + host: typeof fields.host === "string" ? fields.host : "Destination not set", + ports: Array.isArray(fields.ports) + ? fields.ports.filter( + (port): port is { from: number; to: number } => + typeof port === "object" && + port !== null && + Number.isSafeInteger(port.from) && + Number.isSafeInteger(port.to), + ) + : [], + }; + return { rule, index }; + }) + .filter(({ rule }) => grantText(rule).toLowerCase().includes(query)); + page = Math.max(1, Math.min(page, Math.ceil(filtered.length / 5))); + rows.replaceChildren( + element( + "div", + { className: "sandbox-rule-row sandbox-table-heading", "aria-hidden": "true" }, + element("span", {}, "Executable"), + element("span", {}, "Destination"), + element("span"), ), + ...filtered + .slice((page - 1) * 5, page * 5) + .map(({ rule, index }) => + element( + "div", + { className: "sandbox-rule-row" }, + element("span", { className: "resource-id" }, visibleText(rule.binary)), + element( + "span", + { className: "resource-id" }, + visibleText(rule.host) + + ":" + + rule.ports + .map(({ from, to }) => (from === to ? String(from) : `${from}–${to}`)) + .join(", "), + ), + actions?.(index), + ), + ), + ...(filtered.length + ? [] + : [ + element( + "p", + { className: "muted" }, + query ? "No network rules match this search." : "No explicit network rules yet.", + ), + ]), + ); + pager.replaceChildren( + pagination(filtered.length, page, 5, (next) => { + page = next; + render(); + }), ); } - return row; + search.addEventListener("input", () => { + page = 1; + render(); + }); + root.append(search, rows, pager); + render(); + return root; } /** Logs, recommendations, and policy editing keep independent permissions and availability. */ @@ -195,15 +283,129 @@ export function renderAgentSandboxPolicy( ? configurationAction(descriptor, "read").href : `${path}/sandbox-policy/networking`; const section = element("section", { className: "sandbox-policy" }); - const policyPanel = element("section", { className: "agent-card" }); + const policyCard = element("section", { className: "agent-card sandbox-network-card" }); + const policySummary = element("div"); + const policyPanel = element("section", { className: "sandbox-policy-editor" }); + const dialogTitle = element("h2", { id: "sandbox-policy-dialog-title" }, "Manage network rules"); + const dialog = element("dialog", { + className: "dialog dialog--wide dialog--framed sandbox-policy-dialog", + "aria-labelledby": dialogTitle.id, + }); + const dialogBody = element("div", { className: "dialog-body" }, policyPanel); + let dialogInvoker: HTMLElement | undefined; + const closeDialog = () => { + if (!mutation) { + dialog.close(); + } + }; + const dialogActions = element("div", { className: "dialog-actions" }); + dialog.append( + element( + "div", + { className: "dialog-header" }, + dialogTitle, + button("×", closeDialog, { tone: "ghost", "aria-label": "Close network rules" }), + ), + dialogBody, + dialogActions, + ); + dismissOnBackdrop(dialog); + dialog.addEventListener("cancel", (event) => { + if (mutation) { + event.preventDefault(); + } + }); + dialog.addEventListener("close", () => { + if (current()) { + renderPolicy(); + (dialogInvoker?.isConnected ? dialogInvoker : manageButton).focus(); + } + }); + let editorIndex: number | undefined; + let editingRules = false; + let reviewSummary = ""; + const manageButton = button("Manage rules", () => openPolicyDialog()); + function openPolicyDialog(index?: number) { + dialogInvoker = + document.activeElement instanceof HTMLElement ? document.activeElement : undefined; + editorIndex = index; + editingRules = index !== undefined; + renderEditor(); + if (!dialog.open) { + dialog.showModal(); + } + } const suggestions = element("section", { className: "agent-card" }); const logStatus = element("p", { role: "status" }, "Loading policy denials…"); + const logWindowStatus = element("p", { className: "hint" }); + const logWarnings = element("p", { className: "notice", role: "status", hidden: true }); + let withheldRecords = 0; + let truncatedWindow = false; const logRows = element("div", { className: "sandbox-decisions" }); - const count = element( - "p", - { className: "sandbox-denial-count" }, - "Denials in the loaded window: —", + const logNotices = element("div", { className: "sandbox-log-notices" }); + const logPager = element("div"); + const selection = element("div", { className: "sandbox-selection" }); + const selected = new Set(); + const expanded = new Set(); + let visibleDenials: LogLine[] = []; + let displayedRecords: RuntimeLogRecord[] = []; + let logPage = 1; + let logPageSize = 5; + const selectionCount = element("span", { role: "status" }); + const selectPage = element("input", { type: "checkbox", "aria-label": "Select page" }); + const selectPageText = element("span", {}, "Select page"); + const allowSelected = button( + "Allow selected", + () => { + const grants = [...selected].map(denialGrant).filter((grant) => grant !== undefined); + stageGrants(grants, true); + }, + { tone: "primary", size: "sm" }, + ); + const selectionActions = element( + "div", + { className: "sandbox-selection-actions", hidden: true }, + selectionCount, + button( + "Clear", + () => { + selected.clear(); + renderLogs(); + }, + { tone: "link", size: "sm" }, + ), + allowSelected, + ); + selectPage.addEventListener("change", () => { + for (const record of visibleDenials) { + if (denialGrant(record)) { + if (selectPage.checked) { + selected.add(record); + } else { + selected.delete(record); + } + } + } + renderLogs(); + }); + selection.append(element("label", {}, selectPage, selectPageText), selectionActions); + const newDenials = button( + "Show newest", + () => { + displayedRecords = [...records]; + for (const record of selected) { + if (!displayedRecords.includes(record)) { + selected.delete(record); + } + } + logPage = 1; + filter.value = ""; + renderLogs(); + updateNewDenials(); + }, + { className: "sandbox-new-denials", hidden: true }, ); + const count = element("p", { className: "sandbox-denial-count" }, "— in loaded window"); const follow = element("input", { type: "checkbox", "aria-label": "Follow policy denials" }); const logActions = element( "div", @@ -215,7 +417,7 @@ export function renderAgentSandboxPolicy( const suggestionStatus = element("p", { role: "status" }, "Loading policy suggestions…"); const filter = element("input", { type: "search", - placeholder: "Filter loaded denials", + placeholder: "Search executable, destination, or reason", "aria-label": "Filter loaded denials", }); const filterField = element( @@ -224,6 +426,12 @@ export function renderAgentSandboxPolicy( "Filter the loaded window", filter, ); + const logToolbar = element( + "div", + { className: "sandbox-denials-toolbar" }, + filterField, + logActions, + ); let view: SandboxPolicyViewWire | undefined; let validationDetails: Readonly> = []; let draftGeneration = 0; @@ -236,6 +444,7 @@ export function renderAgentSandboxPolicy( let mutation = false; let unknownOutcome = false; let editDenied = false; + let policyReadUnavailable = false; // The schema editor can produce an incomplete or invalid draft. The policy owner validates it. let additions: unknown = []; let recommendations: SandboxPolicyRecommendations["recommendations"] = []; @@ -261,10 +470,21 @@ export function renderAgentSandboxPolicy( clearTimeout(suggestionTimer); records = []; logsLoaded = false; + logStatus.hidden = false; logStatus.textContent = NO_SANDBOX_DENIALS; logRows.replaceChildren(); // The empty rows block keeps its margins and a grid gap: hide it too (finding 2522). - for (const node of [count, logActions, filterField, logRows]) { + for (const node of [ + count, + logToolbar, + logRows, + logPager, + logNotices, + logWarnings, + newDenials, + selection, + windowDetails, + ]) { node.hidden = true; } } @@ -304,6 +524,7 @@ export function renderAgentSandboxPolicy( view.capability && view.policy.application?.state !== "pending" && !editDenied && + !policyReadUnavailable && !unknownOutcome; const canEdit = () => !mutation && hasEditAuthority(); const editable = () => canEdit() && !protectedExecution(); @@ -317,16 +538,136 @@ export function renderAgentSandboxPolicy( agent.activeRevisionId === revisionId && agent.desiredRuntimeState === "running"; + function denied(record: RuntimeLogRecord): record is LogLine { + return ( + record.type === "line" && + (record.decision?.decision === "denied" || record.fields?.action === "DENIED") + ); + } + + function updateNewDenials() { + const pending = records.filter((record) => !displayedRecords.includes(record)); + newDenials.hidden = pending.length === 0; + const denials = pending.filter(denied).length; + newDenials.textContent = denials + ? `${denials} new denials · Show newest` + : "New log notices · Show newest"; + } + + function renderSelection() { + const selectable = visibleDenials.filter((record) => denialGrant(record)); + const checked = selectable.filter((record) => selected.has(record)).length; + selectPage.checked = selectable.length > 0 && checked === selectable.length; + selectPage.indeterminate = checked > 0 && checked < selectable.length; + selectPage.disabled = !editable() || selectable.length === 0; + selectPageText.textContent = `Select page (${selectable.length})`; + selectionActions.hidden = selected.size === 0; + const elsewhere = [...selected].filter((record) => !visibleDenials.includes(record)).length; + selectionCount.textContent = `${selected.size} selected${elsewhere ? ` · ${elsewhere} outside this page` : ""}`; + allowSelected.textContent = `Allow selected (${selected.size})`; + allowSelected.disabled = !editable(); + } + + function decisionRow(record: LogLine) { + const grant = denialGrant(record); + const decision = record.decision; + const fields = record.fields ?? {}; + const binary = decision?.binary ?? fields.binary; + const host = decision?.host ?? fields.dst_host; + const port = decision?.port ?? fields.dst_port; + const reason = decision?.reason ?? fields.reason; + const checkbox = element("input", { + type: "checkbox", + checked: selected.has(record), + disabled: !editable() || !grant, + "aria-label": `Select denial ${visibleText(record.time ?? "time unknown")} ${visibleText(binary ?? "unknown executable")} to ${visibleText(host ?? "unknown destination")}${port ? `:${visibleText(port)}` : ""}`, + }); + const row = element("article", { className: "sandbox-decision" }); + row.classList.toggle("is-selected", selected.has(record)); + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + selected.add(record); + } else { + selected.delete(record); + } + row.classList.toggle("is-selected", checkbox.checked); + renderSelection(); + }); + const details = element( + "section", + { className: "sandbox-decision-details", hidden: !expanded.has(record) }, + element( + "p", + { className: "muted" }, + `Executable path: ${visibleText(binary ?? "Unknown executable")} · Enforcement: ${visibleText(decision?.enforcement ?? "unknown")} · Rule: ${visibleText(decision?.rule ?? fields.rule_name ?? "unknown")} · Engine: ${visibleText(decision?.engine ?? fields.rule_type ?? "unknown")} · Policy generation: ${visibleText(decision?.policyGeneration ?? fields.policy_generation ?? "unknown")}`, + ), + element("details", {}, element("summary", {}, "Sanitized Sandbox log"), recordRow(record)), + ); + const toggle = button( + "›", + () => { + details.hidden = !details.hidden; + toggle.setAttribute("aria-expanded", String(!details.hidden)); + if (details.hidden) { + expanded.delete(record); + } else { + expanded.add(record); + } + }, + { + className: "sandbox-decision-toggle", + "aria-label": "Show denial details", + "aria-expanded": String(expanded.has(record)), + }, + ); + const time = + record.time && !Number.isNaN(Date.parse(record.time)) + ? new Date(record.time).toLocaleTimeString() + : "Unknown"; + row.append( + element( + "div", + { className: "sandbox-denial-row" }, + element("label", { className: "sandbox-denial-select" }, checkbox), + toggle, + element( + "span", + { className: "sandbox-denial-time", title: record.time ?? "Time not reported" }, + time, + ), + element( + "span", + { + className: "resource-id sandbox-denial-binary", + title: visibleText(binary ?? "Unknown executable"), + }, + visibleText(typeof binary === "string" ? binary.split("/").at(-1) : "Unknown executable"), + ), + element( + "span", + { className: "resource-id sandbox-denial-destination" }, + `${visibleText(host ?? "Unknown destination")}${port ? `:${visibleText(port)}` : ""}`, + ), + element( + "span", + { className: "sandbox-denial-reason" }, + visibleText(reason ?? "Reason not reported"), + ), + editable() && grant + ? button("Allow this", () => prefill(grant), { tone: "link", size: "sm" }) + : element("span"), + ), + details, + ); + return row; + } + function renderLogs() { if (!logsLoaded) { return; } - const denials = records.filter( - (record): record is LogLine => - record.type === "line" && - (record.decision?.decision === "denied" || record.fields?.action === "DENIED"), - ); - count.textContent = `Denials in the loaded window: ${denials.length}`; + const denials = displayedRecords.filter(denied).reverse(); + count.textContent = `${denials.length} in loaded window`; const query = filter.value.trim().toLowerCase(); const shown = query ? denials.filter((record) => @@ -335,24 +676,16 @@ export function renderAgentSandboxPolicy( .includes(query), ) : denials; - const gaps = records.filter((record) => record.type !== "line"); - const unclassified = records.filter( + logPage = Math.max(1, Math.min(logPage, Math.ceil(shown.length / logPageSize))); + visibleDenials = shown.slice((logPage - 1) * logPageSize, logPage * logPageSize); + const unclassified = displayedRecords.filter( (record) => record.type === "line" && record.decision === undefined && record.fields?.action === undefined, ).length; - logRows.replaceChildren( - ...gaps.map(recordRow), - ...(shown.length - ? shown.map((record) => decisionRow(record, editable() ? prefill : undefined)) - : [ - element( - "p", - {}, - denials.length ? "No denials match this filter." : "No denials in this window.", - ), - ]), + logNotices.replaceChildren( + ...displayedRecords.filter((record) => record.type !== "line").map(recordRow), ...(unclassified ? [ element( @@ -363,6 +696,51 @@ export function renderAgentSandboxPolicy( ] : []), ); + logRows.replaceChildren( + element( + "div", + { className: "sandbox-denial-row sandbox-table-heading", "aria-hidden": "true" }, + element("span"), + element("span"), + element("span", {}, "Time"), + element("span", {}, "Executable"), + element("span", {}, "Destination"), + element("span", {}, "Reason"), + element("span"), + ), + ...visibleDenials.map(decisionRow), + ...(shown.length + ? [] + : [ + element( + "p", + { className: "muted" }, + denials.length ? "No denials match this filter." : "No denials in this window.", + ), + ]), + ); + const size = element( + "select", + { "aria-label": "Rows per page" }, + ...[5, 20, 50].map((value) => + element("option", { value, selected: value === logPageSize }, String(value)), + ), + ); + size.addEventListener("change", () => { + logPageSize = Number(size.value); + logPage = 1; + renderLogs(); + }); + const pager = pagination(shown.length, logPage, logPageSize, (page) => { + logPage = page; + renderLogs(); + }); + pager + .querySelector("span") + ?.append(document.createTextNode(query ? " matching denials" : " loaded denials")); + pager.insertBefore(element("label", {}, "Rows per page", size), pager.lastChild); + logPager.replaceChildren(pager); + renderSelection(); } async function readLogs(restart = false) { @@ -381,16 +759,15 @@ export function renderAgentSandboxPolicy( clearTimeout(logTimer); reading = true; if (restart) { - logsLoaded = false; - cursor = null; - records = []; + // Keep the inspected window until the fresh read succeeds. + logStatus.hidden = false; logStatus.textContent = "Loading policy denials…"; } try { const query = new URLSearchParams({ source: "sandbox", tailLines: "1000", - ...(cursor ? { cursor } : {}), + ...(!restart && cursor ? { cursor } : {}), }); const page = (await context.request( `${path}/deployments/${encodeURIComponent(revisionId)}/runtime/logs?${query}`, @@ -398,11 +775,31 @@ export function renderAgentSandboxPolicy( if (!current()) { return; } + const firstRead = !logsLoaded; logsLoaded = true; - records = [...records, ...page.records].slice(-1000); + records = [...(restart ? [] : records), ...page.records].slice(-1000); + if (restart || firstRead) { + displayedRecords = [...records]; + selected.clear(); + expanded.clear(); + logPage = 1; + } cursor = page.cursor; - logStatus.textContent = `Loaded ${records.filter(({ type }) => type === "line").length} records. One action may produce several denial records. Log retention and gaps limit this window.`; - renderLogs(); + withheldRecords = (restart ? 0 : withheldRecords) + page.withheld; + truncatedWindow = (restart ? false : truncatedWindow) || page.truncated; + logWarnings.hidden = withheldRecords === 0 && !truncatedWindow; + logWarnings.textContent = [ + withheldRecords ? `${withheldRecords} records withheld since the last refresh.` : "", + truncatedWindow ? "Some log output was truncated. This window is incomplete." : "", + ] + .filter(Boolean) + .join(" "); + logStatus.hidden = true; + logWindowStatus.textContent = `Loaded ${records.filter(({ type }) => type === "line").length} records. One action may produce several denial records. Log retention and gaps limit this window.${page.withheld ? ` ${page.withheld} records withheld in the latest read.` : ""}${page.truncated ? " The latest read was truncated." : ""}`; + if (restart || firstRead) { + renderLogs(); + } + updateNewDenials(); } catch (error) { if (!current()) { return; @@ -414,6 +811,7 @@ export function renderAgentSandboxPolicy( return; } follow.checked = false; + logStatus.hidden = false; logStatus.textContent = errorText(error, "reading this Agent’s Sandbox logs", LOG_GRANT); if (records.length === 0) { count.textContent = "Denials in the loaded window: unavailable"; @@ -435,46 +833,103 @@ export function renderAgentSandboxPolicy( return JSON.stringify(additions) !== JSON.stringify(view?.policy.additions); } - function prefill(grant: SandboxNetworkGrant) { - if (!editable() || !Array.isArray(additions)) { + function stageGrants(grants: SandboxNetworkGrant[], batch: boolean) { + if (!editable()) { return; } - additions.push(structuredClone(grant)); + if (!readableRules(additions)) { + openPolicyDialog(); + feedback.textContent = + "Finish or remove the incomplete draft rules, then allow these denials."; + feedback.className = "error"; + feedback.setAttribute("role", "alert"); + feedback.focus({ preventScroll: true }); + return; + } + const distinct = [...new Map(grants.map((grant) => [grantKey(grant), grant])).values()]; + const existing = additions; + const fresh = distinct.filter((grant) => !grantCovered(grant, existing)); + reviewSummary = `${grants.length} selected denial${grants.length === 1 ? "" : "s"} · ${distinct.length} distinct request${distinct.length === 1 ? "" : "s"} · ${fresh.length} new rule${fresh.length === 1 ? "" : "s"}`; + const index = additions.length; + additions.push(...structuredClone(fresh)); draftGeneration += 1; validationDetails = []; - renderPolicy(); + openPolicyDialog(!batch && fresh.length ? index : undefined); feedback.className = ""; feedback.setAttribute("role", "status"); - feedback.textContent = "Rule added to your proposal. Review and save to persist changes."; - policyPanel - .querySelector(`[aria-label="Executable path ${additions.length}"]`) - ?.focus(); + feedback.textContent = fresh.length + ? "Rules added to your proposal. Review and save to persist changes." + : "These requests are already covered by saved or proposed rules. Check the policy status before retrying."; + if (batch && fresh.length) { + policyPanel.querySelector("form")?.requestSubmit(); + } + } + + function prefill(grant: SandboxNetworkGrant) { + stageGrants([grant], false); } function renderSuggestions() { - suggestions.replaceChildren( - element("h2", {}, "Policy suggestions"), - suggestionStatus, + suggestions.hidden = recommendations.length === 0 && suggestionStatus.dataset.loaded === "true"; + suggestions.className = "sandbox-suggestions"; + suggestions.replaceChildren(); + if (recommendations.length === 1 && editable()) { + const entry = recommendations[0]!; + suggestions.append( + element( + "div", + {}, + element("strong", {}, "1 suggested rule"), + element("p", { className: "resource-id" }, grantText(entry.grant)), + ), + button("Review suggestion", () => prefill(entry.grant), { size: "sm" }), + ); + } else { + suggestions.append( + element( + "h3", + {}, + recommendations.length + ? `${recommendations.length} suggested rules` + : "Policy suggestions", + ), + suggestionStatus, + ...recommendations.map((entry) => + element( + "div", + { className: "sandbox-suggestion-row" }, + element("p", { className: "resource-id" }, grantText(entry.grant)), + editable() + ? button("Review suggestion", () => prefill(entry.grant), { size: "sm" }) + : null, + ), + ), + ); + } + const details = element( + "details", + { className: "sandbox-suggestion-details" }, + element("summary", {}, "About suggestions"), element( "p", - { className: "muted" }, + { className: "hint" }, "Provider suggestions are untrusted candidate grants. Observation counts are aggregates, separate from denial records.", ), ...recommendations.map((entry) => element( - "article", - { className: "sandbox-decision" }, - element("h3", {}, grantText(entry.grant)), - element("p", {}, `${entry.status} · Observations: ${entry.observations ?? "unknown"}`), - editable() ? button("Review suggestion", () => prefill(entry.grant)) : null, + "p", + { className: "hint" }, + `${entry.status} · Provider observations: ${entry.observations ?? "unknown"}`, ), ), + suggestionStatus.isConnected ? null : suggestionStatus, // A refusal gets the same answer again, and a version without a Sandbox has none to read, // nor does the draft (finding 2509). ...(suggestionsDenied || noSandboxLog || revisionId === "draft" ? [] : [button("Refresh suggestions", () => void loadSuggestions())]), ); + suggestions.append(details); } async function loadSuggestions() { @@ -521,6 +976,7 @@ export function renderAgentSandboxPolicy( return; } recommendations = result.recommendations; + suggestionStatus.dataset.loaded = "true"; suggestionsDenied = false; suggestionStatus.textContent = `Suggestions loaded: ${recommendations.length}${result.providerVersion ? ` · Provider draft version ${result.providerVersion}` : ""}${result.truncated ? " · Some suggestions were omitted or exceed the read limit." : ""}`; } catch (error) { @@ -528,6 +984,7 @@ export function renderAgentSandboxPolicy( return; } recommendations = []; + suggestionStatus.dataset.loaded = "false"; suggestionsDenied = requestFailure(error).status === 403; suggestionStatus.textContent = errorText( error, @@ -599,16 +1056,10 @@ export function renderAgentSandboxPolicy( } } - function renderPolicy() { - if (policyReadDenied) { - policyPanel.replaceChildren( - element("h2", {}, "Agent network policy"), - element("p", { className: "muted" }, POLICY_READ_DENIED), - ); - return; - } + function renderEditor() { + dialogTitle.textContent = editingRules ? "Edit network rule" : "Manage network rules"; + dialogActions.replaceChildren(button("Done", closeDialog, { disabled: mutation })); policyPanel.replaceChildren( - element("h2", {}, "Agent network policy"), feedback, button("Refresh policy", () => void loadPolicy(), { disabled: mutation }), ); @@ -617,6 +1068,11 @@ export function renderAgentSandboxPolicy( return; } const { configuration, editingEnabled, policy } = view; + const settings = element( + "details", + { className: "sandbox-policy-settings" }, + element("summary", {}, "Policy settings and Installation limits"), + ); if (unknownOutcome) { policyPanel.append( element( @@ -628,7 +1084,7 @@ export function renderAgentSandboxPolicy( return; } const { application } = policy; - policyPanel.append( + settings.append( element( "p", {}, @@ -643,24 +1099,6 @@ export function renderAgentSandboxPolicy( ? "Defaults remain read-only. Saved network rules can only be removed while protected execution applies. Disabling editing preserves saved rules." : "Defaults remain read-only. Only the additions below are editable. Disabling editing preserves approved grants.", ), - ...(protectedExecution() - ? [ - element( - "p", - { role: "status", className: "sandbox-policy-protected" }, - "Protected execution uses Credential Proxy routes. You can remove saved network rules, but cannot add new ones. Saving does not change the running revision.", - ), - ...(policy.additions.length > 0 && !editingEnabled - ? [ - element( - "p", - { className: "hint" }, - "Saved network rules block protected deploys. Enable policy editing to remove them.", - ), - ] - : []), - ] - : []), element( "details", {}, @@ -713,7 +1151,7 @@ export function renderAgentSandboxPolicy( ); if (policy.applied) { const applied = policy.applied; - policyPanel.append( + settings.append( element( "p", {}, @@ -722,7 +1160,7 @@ export function renderAgentSandboxPolicy( ); } if (application) { - policyPanel.append( + settings.append( element( "p", { role: "status" }, @@ -730,9 +1168,9 @@ export function renderAgentSandboxPolicy( ), ); if (application.state === "failed") { - // The code stays on the status line, so the reader can match it to the audit log. + // Keep the reason code beside its explanation for audit correlation. const reason = application.reason; - policyPanel.append( + settings.append( element( "p", { className: "notice" }, @@ -743,7 +1181,7 @@ export function renderAgentSandboxPolicy( ); } } else { - policyPanel.append( + settings.append( element( "p", { className: "muted" }, @@ -773,13 +1211,28 @@ export function renderAgentSandboxPolicy( return; } form.noValidate = true; + const itemSchema = responseFields( + responseFields(responseFields(configuration.configurationSchema).properties).additions, + ).items; + const single = + editingRules && + editorIndex !== undefined && + Array.isArray(additions) && + typeof itemSchema === "object" && + itemSchema !== null && + !Array.isArray(itemSchema); + const index = editorIndex ?? 0; form.append( draftStatus, configurationFields( - configuration.configurationSchema, - { additions }, + single ? (itemSchema as JSONSchema) : configuration.configurationSchema, + single && Array.isArray(additions) ? additions[index] : { additions }, (candidate) => { - additions = responseFields(candidate).additions; + if (single && Array.isArray(additions)) { + additions[index] = candidate; + } else { + additions = responseFields(candidate).additions; + } validationDetails = []; for (const error of form.querySelectorAll(".error")) { error.remove(); @@ -788,8 +1241,14 @@ export function renderAgentSandboxPolicy( }, { disabled: !editable() || pending, - details: validationDetails, + details: single + ? validationDetails.map((detail) => ({ + ...detail, + path: detail.path.replace(new RegExp(`^/additions/${index}(?=/|$)`), ""), + })) + : validationDetails, onEdit: () => { + reviewSummary = ""; draftGeneration += 1; feedback.className = ""; feedback.setAttribute("role", "status"); @@ -799,7 +1258,13 @@ export function renderAgentSandboxPolicy( ), ...(validationDetails.length ? [configurationErrors(validationDetails)] : []), ); - form.append(reviewButton); + form.append( + reviewButton, + button("Back to rules", () => { + editingRules = false; + renderEditor(); + }), + ); updateDraftStatus(); form.addEventListener("submit", async (event) => { event.preventDefault(); @@ -939,49 +1404,131 @@ export function renderAgentSandboxPolicy( }, ); } + const changed = reviewedRules.filter( + (rule) => !policy.additions.some((saved) => sameValue(saved, rule)), + ); + const removed = policy.additions.filter( + (saved) => !reviewedRules.some((rule) => rule.id === saved.id), + ); + const unchanged = reviewedRules.length - changed.length; const review = element( "section", - { className: "notice sandbox-policy-review" }, - element("h3", {}, "Review policy changes"), - element("h4", {}, `Currently saved rules · v${expectedVersion}`), - ...(policy.additions.length - ? policy.additions.map((grant) => element("p", {}, grantText(grant))) - : [element("p", {}, "No Agent-specific rules saved.")]), - element("h4", {}, "Proposed rules"), - ...(reviewedRules.length - ? reviewedRules.map((grant) => element("p", {}, grantText(grant))) - : [element("p", {}, "Remove all Agent-specific rules.")]), + { className: "sandbox-policy-review" }, + reviewSummary ? element("p", { className: "hint" }, reviewSummary) : null, + ...(changed.length + ? [element("h3", {}, `Rules to add or update · ${changed.length}`), ruleList(changed)] + : []), + ...(removed.length + ? [element("h3", {}, `Rules to remove · ${removed.length}`), ruleList(removed)] + : []), + element("p", { className: "hint" }, `${unchanged} existing rules unchanged.`), element( - "div", - { className: "sandbox-policy-save-option" }, - button("Save and apply to current revision", () => save(true), { - className: "primary", - disabled: !canApply, - }), - element( - "p", - { className: "hint" }, - canApply - ? "Save these rules, then request live application to this running revision. Wait for provider confirmation before retrying the tool." - : "Live application requires the current running revision and a supporting Sandbox Driver.", - ), + "details", + {}, + element("summary", {}, `Previously saved rules · v${expectedVersion}`), + policy.additions.length + ? ruleList(policy.additions) + : element("p", {}, "No Agent-specific rules saved."), ), element( - "div", - { className: "sandbox-policy-save-option" }, - button("Save", () => save(false)), - element( - "p", - { className: "hint" }, - "Save these rules for later application or deployment. The current revision’s network access stays unchanged.", - ), + "p", + { className: "hint" }, + canApply + ? "Save and apply requests live application to this running revision. Wait for provider confirmation before retrying. Save for later keeps the current revision’s network access unchanged." + : "Save for later keeps the current revision’s network access unchanged. Live application requires the current running revision and a supporting Sandbox Driver.", ), + ); + dialogActions.replaceChildren( button("Back to editing", renderPolicy), + button("Save for later", () => save(false)), + button("Save and apply to current revision", () => save(true), { + tone: "primary", + disabled: !canApply, + }), ); + dialogTitle.textContent = "Review policy changes"; + rulesOverview.hidden = true; + settings.hidden = true; form.replaceWith(review); applyButton.hidden = true; }); - form.prepend( + const rulesOverview = element( + "section", + { className: "sandbox-rules-overview" }, + element( + "div", + { className: "sandbox-policy-card-head" }, + element( + "h3", + {}, + `Agent-specific rules · ${Array.isArray(additions) ? additions.length : "invalid draft"}`, + ), + button( + "Add rule", + () => { + if (!editable() || !Array.isArray(additions)) { + return; + } + additions.push({ + id: `network-${crypto.randomUUID()}`, + binary: "", + host: "", + ports: [], + }); + draftGeneration += 1; + editorIndex = additions.length - 1; + editingRules = true; + reviewSummary = ""; + renderEditor(); + }, + { tone: "primary", size: "sm", disabled: !editable() }, + ), + ), + element( + "p", + { className: "hint" }, + "Read-only Installation defaults also apply. Draft changes require review and an explicit save.", + ), + Array.isArray(additions) + ? ruleList(additions, (index) => { + const menu = element("details", { className: "sandbox-rule-actions" }); + menu.append( + element("summary", { "aria-label": `Actions for network rule ${index + 1}` }, "⋯"), + element( + "div", + {}, + button( + "Edit rule", + () => { + editorIndex = index; + editingRules = true; + reviewSummary = ""; + renderEditor(); + }, + { disabled: !editable() }, + ), + button( + "Remove rule", + () => { + if (!editable() || !Array.isArray(additions)) { + return; + } + additions.splice(index, 1); + reviewSummary = ""; + draftGeneration += 1; + renderEditor(); + }, + { disabled: !editable() }, + ), + ), + ); + return menu; + }) + : element( + "p", + { role: "alert" }, + "The draft contains incomplete rules. Edit the configuration to correct them.", + ), ...(protectedExecution() && policy.additions.length > 0 && editingEnabled ? [ button( @@ -1004,10 +1551,38 @@ export function renderAgentSandboxPolicy( ), ] : []), + editingRules ? null : draftStatus, + ...(validationDetails.length ? [configurationErrors(validationDetails)] : []), + button("Review changes", () => form.requestSubmit(), { + tone: "primary", + disabled: !editable() || !hasProposedChanges(), + }), ); + rulesOverview.hidden = editingRules; + form.hidden = !editingRules; policyPanel.append( + ...(protectedExecution() + ? [ + element( + "p", + { role: "status", className: "sandbox-policy-protected" }, + "Protected execution uses Credential Proxy routes. You can remove saved network rules, but cannot add new ones. Saving does not change the running revision.", + ), + ...(policy.additions.length > 0 && !editingEnabled + ? [ + element( + "p", + { className: "hint" }, + "Saved network rules block protected deploys. Enable policy editing to remove them.", + ), + ] + : []), + ] + : []), + rulesOverview, form, applyButton, + settings, ...(!view.capability?.live ? [ element( @@ -1080,6 +1655,7 @@ export function renderAgentSandboxPolicy( validationDetails = []; unknownOutcome = false; editDenied = false; + policyReadUnavailable = false; renderPolicy(); renderSuggestions(); renderLogs(); @@ -1089,7 +1665,13 @@ export function renderAgentSandboxPolicy( return; } readFailed = true; - policyPanel.replaceChildren( + policyReadUnavailable = true; + if (dialog.open) { + renderEditor(); + } + renderLogs(); + renderSuggestions(); + policySummary.replaceChildren( element("h2", {}, "Agent network policy"), element( "p", @@ -1107,7 +1689,88 @@ export function renderAgentSandboxPolicy( } } - filter.addEventListener("input", renderLogs); + function renderPolicy() { + if (policyReadDenied) { + policySummary.replaceChildren( + element("h2", {}, "Network policy"), + element("p", { className: "muted" }, POLICY_READ_DENIED), + ); + return; + } + policySummary.replaceChildren( + element( + "div", + { className: "sandbox-policy-card-head" }, + element("h2", {}, "Network policy"), + manageButton, + ), + ); + if (!view) { + policySummary.append(element("p", { role: "status" }, "Loading network policy…")); + } + if (view) { + const { policy } = view; + const applied = policy.applied?.revisionId === revisionId ? policy.applied : undefined; + function policyStatus() { + if (policy.application?.state === "pending") { + return { label: "Applying…", tone: "info" }; + } + if (policy.application?.state === "failed") { + return { label: "Application unconfirmed", tone: "warning" }; + } + if (applied?.policyVersion === policy.version) { + return { label: "Applied", tone: "success" }; + } + return { + label: applied ? "Changes not applied" : "Application unconfirmed", + tone: "warning", + }; + } + const presentation = policyStatus(); + policySummary + .querySelector("h2") + ?.append( + element( + "span", + { className: "badge", "data-tone": presentation.tone }, + presentation.label, + ), + ); + policySummary.append( + ...(hasProposedChanges() + ? [ + element( + "p", + { className: "hint" }, + "Unsaved rule changes · Open Manage rules to continue your proposal.", + ), + ] + : []), + element( + "p", + { className: "hint" }, + `${policy.additions.length} explicit rules · Saved v${policy.version} · ${applied ? `Last confirmed live v${applied.policyVersion}` : "Live version unconfirmed"}`, + ), + ...(unknownOutcome + ? [ + element( + "p", + { role: "alert" }, + "The last change could not be confirmed. Open Manage rules and refresh policy before changing access.", + ), + ] + : []), + ); + } + if (dialog.open) { + renderEditor(); + } + } + + filter.addEventListener("input", () => { + logPage = 1; + renderLogs(); + }); follow.addEventListener("change", () => { clearTimeout(logTimer); clearTimeout(suggestionTimer); @@ -1116,15 +1779,31 @@ export function renderAgentSandboxPolicy( void loadSuggestions(); } }); + policyCard.append(policySummary, suggestions); + const windowDetails = element( + "details", + { className: "sandbox-window-details" }, + element("summary", {}, "About this window"), + logWindowStatus, + element( + "p", + { className: "hint" }, + "Counts are retained records for this version, not unique requests or lifetime denials. One action can produce multiple records. Following keeps this view in place until you choose Show newest.", + ), + ); section.append( - element("h2", {}, "Policy denials"), - count, - logActions, + policyCard, + element("div", { className: "sandbox-denials-heading" }, element("h2", {}, "Denials"), count), + logToolbar, logStatus, - filterField, + logWarnings, + logNotices, + newDenials, + selection, logRows, - suggestions, - policyPanel, + logPager, + windowDetails, + dialog, ); renderPolicy(); // A caller whose permission answer denies sandbox policy read is not sent the policy read; an @@ -1145,7 +1824,17 @@ export function renderAgentSandboxPolicy( : NO_CURRENT_VERSION_DENIALS; // It reads no Sandbox log, so the window's count, controls and rows could only show nothing, // as on a version without a Sandbox (findings 2509, 2522). - for (const node of [count, logActions, filterField, logRows]) { + for (const node of [ + count, + logToolbar, + logRows, + logPager, + logNotices, + logWarnings, + newDenials, + selection, + windowDetails, + ]) { node.hidden = true; } } else { diff --git a/apps/controller/src/console/console.css b/apps/controller/src/console/console.css index 32135a6c42..bd7e2a9310 100644 --- a/apps/controller/src/console/console.css +++ b/apps/controller/src/console/console.css @@ -2246,6 +2246,8 @@ select:not([multiple])[size="1"] { .form-field select, .form-field textarea, .collection-toolbar > input[type="search"], +.sandbox-policy input[type="search"], +.sandbox-policy select, .repository-discovery input[type="search"], .plugin-control select, .plugin-json textarea, @@ -4611,59 +4613,280 @@ html:has(#create-agent-form) { .sandbox-policy { display: grid; - gap: var(--space-4); + gap: var(--space-3); min-width: 0; } -.sandbox-policy .agent-card { - padding: var(--space-4); +.sandbox-policy [hidden] { + display: none !important; +} +.sandbox-policy-card-head, +.sandbox-denials-heading, +.sandbox-denials-toolbar, +.sandbox-selection, +.sandbox-selection-actions, +.sandbox-page-actions, +.sandbox-pagination { + display: flex; + align-items: center; + gap: var(--space-3); +} +.sandbox-policy-card-head { + justify-content: space-between; +} +.sandbox-policy-card-head h2 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); + font-size: var(--text-xl); + line-height: var(--text-xl--line-height); + margin: 0; +} +.sandbox-network-card { border: 1px solid var(--color-border); + padding: var(--space-5); } -.sandbox-decision { +.sandbox-network-card .hint { + margin-block: var(--space-2) 0; +} +.sandbox-suggestions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: space-between; padding: var(--space-4); + margin-top: var(--space-4); + background: var(--color-info-subtle); border: 1px solid var(--color-border); - border-radius: var(--radius-md); - margin-block: var(--space-2); - overflow-wrap: anywhere; + border-radius: var(--radius-sm); } -.sandbox-decision h3 { - font-size: var(--text-base); - margin: 0 0 var(--space-2); +.sandbox-suggestions h3, +.sandbox-suggestions p { + margin: 0; } -.sandbox-decision details { - margin-block: var(--space-2); +.sandbox-suggestions .resource-id { + display: block; + margin-top: var(--space-1); } -.sandbox-policy-form { - display: grid; - gap: var(--space-3); +.sandbox-suggestion-details { + flex-basis: 100%; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); } -.sandbox-policy-rule-heading { +.sandbox-suggestion-details summary { + color: var(--color-link); + cursor: pointer; +} +.sandbox-suggestion-row { display: flex; align-items: center; + justify-content: space-between; gap: var(--space-3); + width: 100%; +} +.sandbox-denials-heading { + margin-top: var(--space-3); } -.sandbox-policy-rule-heading h3 { +.sandbox-denials-heading h2 { + font-size: var(--text-xl); + line-height: var(--text-xl--line-height); margin: 0; } -.sandbox-policy button.sandbox-policy-icon { - width: 2.25rem; - height: 2.25rem; - padding: 0; +.sandbox-denial-count { + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-2); + background: var(--color-surface-2); + color: var(--color-text-muted); margin: 0; - font-size: var(--text-xl); - line-height: 1; - flex-shrink: 0; } -.sandbox-policy-remove { +.sandbox-denials-toolbar { + justify-content: space-between; + flex-wrap: wrap; +} +.sandbox-denials-toolbar > input { + flex: 1; + max-width: 32rem; + min-width: 0; +} +.sandbox-policy label, +.sandbox-pagination label { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + margin: 0; +} +.sandbox-policy input[type="checkbox"] { + width: var(--space-4); + height: var(--space-4); + margin: 0; + accent-color: var(--color-accent); +} +.sandbox-selection { + justify-content: space-between; + flex-wrap: wrap; + min-height: var(--control-h-sm); +} +.sandbox-selection-actions { + margin-left: auto; + flex-wrap: wrap; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-decisions { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; +} +.sandbox-decisions > p { + padding: var(--space-4); +} +.sandbox-denial-row { + display: grid; + grid-template-columns: + var(--space-5) var(--space-5) 5rem 7.5rem minmax(0, 1.1fr) minmax(0, 1fr) + 5.5rem; + align-items: center; + gap: var(--space-3); + min-height: 3.5rem; + padding: var(--space-3) var(--space-4); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-table-heading { + background: var(--color-surface-2); + color: var(--color-text-muted); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + min-height: var(--control-h); +} +.sandbox-decision { + border-top: 1px solid var(--color-border); + overflow-wrap: anywhere; +} +.sandbox-decision.is-selected > .sandbox-denial-row { + background: var(--color-info-subtle); +} +.sandbox-denial-time { + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; +} +.sandbox-denial-select { + justify-content: center; +} +.sandbox-denial-row > .button--link { + padding-inline: 0; justify-self: end; } -.sandbox-policy-save-option { - margin-block: var(--space-4); +.sandbox-decision-toggle { + border: 0; + background: transparent; + padding: 0; + min-height: var(--control-h-sm); + color: var(--color-text-muted); } -.sandbox-policy-save-option .hint { - margin-block: var(--space-1); +.sandbox-decision-toggle[aria-expanded="true"] { + transform: rotate(90deg); } -.sandbox-policy-form > button { - justify-self: start; +.sandbox-decision-details { + border-top: 1px solid var(--color-border); + padding: var(--space-4); + background: var(--color-surface-2); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-decision-details > p { + margin-top: 0; +} +.sandbox-pagination { + justify-content: space-between; + flex-wrap: wrap; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-pagination > label { + margin-left: auto; +} +.sandbox-pagination select { + width: auto; + min-height: var(--control-h-sm); +} +.sandbox-page-actions { + white-space: nowrap; +} +.sandbox-window-details { + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + color: var(--color-text-muted); +} +.sandbox-window-details summary { + cursor: pointer; +} +.sandbox-log-notices:empty { + display: none; +} +.sandbox-new-denials { + color: var(--color-link); + border-color: var(--color-border); + background: var(--color-info-subtle); +} +.dialog.sandbox-policy-dialog { + width: min(60rem, calc(100% - 2 * var(--space-4))); + height: min(56rem, calc(100dvh - 2 * var(--space-4))); + max-height: calc(100dvh - 2 * var(--space-4)); + overflow: hidden; +} +.dialog.sandbox-policy-dialog[open] { + display: flex; + flex-direction: column; +} +.sandbox-policy-dialog .dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + flex-shrink: 0; +} +.sandbox-policy-dialog .dialog-header h2 { + font-size: var(--text-xl); + line-height: var(--text-xl--line-height); +} +.sandbox-policy-dialog .dialog-header button { + color: var(--color-on-inverse); +} +.sandbox-policy-dialog .dialog-body { + flex: 1; + overflow: auto; + min-height: 0; +} +.sandbox-policy-editor > :first-child:empty { + display: none; +} +.sandbox-policy-settings { + margin-block: var(--space-3); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-policy-settings > summary { + cursor: pointer; + color: var(--color-link); +} +.sandbox-policy-settings pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.sandbox-policy-form { + display: grid; + gap: var(--space-3); } .sandbox-policy-form fieldset { min-width: 0; @@ -4673,30 +4896,235 @@ html:has(#create-agent-form) { border-radius: var(--radius-sm); } .sandbox-policy-form label { - margin: 0; display: grid; gap: var(--space-1); + margin: 0; } .sandbox-policy-form input { width: 100%; min-width: 0; } -.sandbox-policy .form-actions { - display: flex; - flex-wrap: wrap; +.sandbox-rule-collection { + display: grid; gap: var(--space-3); + margin-block: var(--space-4); +} +.sandbox-rule-table { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} +.sandbox-rule-row { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) var(--space-8); + gap: var(--space-4); align-items: center; + padding: var(--space-3) var(--space-4); + min-height: 3.5rem; + overflow-wrap: anywhere; } -.sandbox-policy button { - margin: var(--space-1) var(--space-2) var(--space-1) 0; +.sandbox-rule-row + .sandbox-rule-row { + border-top: 1px solid var(--color-border); } - -.sandbox-decisions { - max-height: 36rem; - overflow: auto; - margin-block: var(--space-3); +.sandbox-rule-row:first-child { + border-radius: var(--radius-md) var(--radius-md) 0 0; } -.sandbox-policy input[type="search"] { - width: 100%; - margin-block: var(--space-1); +.sandbox-rule-actions { + position: relative; +} +.sandbox-rule-actions > summary { + cursor: pointer; + list-style: none; + font-size: var(--text-xl); + line-height: var(--text-xl--line-height); +} +.sandbox-rule-actions > summary::-webkit-details-marker { + display: none; +} +.sandbox-rule-actions > div { + display: grid; + position: absolute; + right: 0; + top: 100%; + min-width: 9rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-1); + background: var(--color-surface); + box-shadow: var(--shadow-1); + z-index: 2; +} +.sandbox-rule-actions > div button { + border: 0; + text-align: left; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-policy-review > details > summary { + cursor: pointer; + color: var(--color-link); +} +.sandbox-policy-dialog .dialog-actions { + flex-shrink: 0; + flex-wrap: wrap; +} +@media (max-width: 1100px) { + .sandbox-denial-row { + grid-template-columns: + var(--space-4) var(--space-4) 4.5rem 6rem minmax(0, 1fr) minmax(0, 1fr) + 5rem; + gap: var(--space-2); + padding-inline: var(--space-3); + } +} +@media (max-width: 760px) { + .sandbox-decisions > .sandbox-table-heading { + display: none; + } + .sandbox-denial-row { + grid-template-columns: var(--space-5) var(--space-5) 6rem minmax(0, 1fr) 5rem; + } + .sandbox-denial-select { + grid-column: 1; + grid-row: 1 / 3; + } + .sandbox-decision-toggle { + grid-column: 2; + grid-row: 1 / 3; + } + .sandbox-denial-binary { + grid-column: 3; + grid-row: 1; + } + .sandbox-denial-time { + grid-column: 3; + grid-row: 2; + } + .sandbox-denial-destination { + grid-column: 4; + grid-row: 1; + } + .sandbox-denial-reason { + grid-column: 4; + grid-row: 2; + } + .sandbox-denial-row > .button--link { + grid-column: 5; + grid-row: 1 / 3; + } +} +@media (max-width: 520px) { + .sandbox-network-card { + padding: var(--space-4); + } + .sandbox-policy-card-head { + align-items: flex-start; + gap: var(--space-2); + } + .sandbox-policy-card-head h2 { + font-size: var(--text-lg); + line-height: var(--text-lg--line-height); + gap: var(--space-2); + } + .sandbox-policy-card-head > button { + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); + padding-inline: var(--space-2); + } + .sandbox-denials-toolbar > input { + flex-basis: 100%; + max-width: none; + } + .sandbox-denials-toolbar > .form-actions { + justify-content: space-between; + width: 100%; + } + .sandbox-selection-actions { + flex-basis: 100%; + justify-content: space-between; + margin: 0; + padding: var(--space-2); + border-radius: var(--radius-sm); + background: var(--color-info-subtle); + gap: var(--space-2); + } + .sandbox-selection-actions button, + .sandbox-selection-actions span { + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + } + .sandbox-denial-row { + grid-template-columns: var(--space-5) minmax(0, 1fr) 5rem; + padding: var(--space-3); + gap: var(--space-2); + } + .sandbox-denial-select { + grid-column: 1; + grid-row: 1; + } + .sandbox-decision-toggle { + grid-column: 1; + grid-row: 2 / 4; + } + .sandbox-denial-binary { + grid-column: 2; + grid-row: 1; + } + .sandbox-denial-destination { + grid-column: 2; + grid-row: 2; + } + .sandbox-denial-reason { + grid-column: 2; + grid-row: 3; + } + .sandbox-denial-time { + grid-column: 3; + grid-row: 1; + text-align: right; + } + .sandbox-denial-row > .button--link { + grid-column: 3; + grid-row: 2 / 4; + } + .sandbox-pagination { + gap: var(--space-2); + } + .sandbox-pagination > span { + flex-basis: 100%; + } + .sandbox-pagination > label { + margin-left: 0; + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + } + .sandbox-page-actions { + gap: var(--space-1); + margin-left: auto; + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + } + .sandbox-page-actions button { + padding-inline: var(--space-2); + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + } + .sandbox-rule-row { + grid-template-columns: minmax(0, 1fr) var(--space-8); + gap: var(--space-1) var(--space-2); + } + .sandbox-rule-row > :first-child { + grid-column: 1; + grid-row: 1; + } + .sandbox-rule-row > :nth-child(2) { + grid-column: 1; + grid-row: 2; + } + .sandbox-rule-actions { + grid-column: 2; + grid-row: 1 / 3; + } + .sandbox-rule-table > .sandbox-table-heading { + display: none; + } } diff --git a/docs/flows/agent-sandbox-policy.md b/docs/flows/agent-sandbox-policy.md index 9d3883764f..4c4352f9a8 100644 --- a/docs/flows/agent-sandbox-policy.md +++ b/docs/flows/agent-sandbox-policy.md @@ -150,9 +150,23 @@ renders schema fields and JSON fallback, with safe field/root diagnostics. String controls retain the complete draft. Length validation stays with the policy owner: JSON Schema counts Unicode code points, while HTML length attributes count UTF-16 code units and can truncate a valid executable path. -`console/agents/sandbox-policy.mts:renderPolicy` keeps proposed rules local until -preview. Review shows the rules the controller validated, and Save sends those -rules with the reviewed policy version. Superseded preview and read responses +`console/agents/sandbox-policy.mts:renderLogs` snapshots the displayed log window, +filters it, and renders five denials per page initially. Selection spans pages; +Select page changes only eligible visible rows. Follow retains up to 1,000 new +log records without replacing inspected rows. Show newest accepts the retained +window and removes selections for records no longer retained. + +`stageGrants` builds exact executable/host/port candidates, deduplicates requests, +and skips requests already covered by saved or proposed grants. It retains the +complete additions array and opens the schema editor or batch preview. Missing +metadata disables prefilling. An incomplete existing draft must be corrected +before staging more grants. `ruleList` searches and pages explicit rules five at +a time; row controls edit or remove local additions. + +`console/agents/sandbox-policy.mts:renderPolicy` places policy status and suggestions +above the log. `renderEditor` keeps proposed rules local until preview. Review shows additions, updates, and removals from the controller-validated rules, +with the previously saved policy available for comparison. Save sends the complete +validated array with the reviewed policy version. Superseded preview and read responses cannot overwrite a newer draft. Policy reads use a separate sequence: an earlier response cannot invalidate a newer request. A pending application re-arms polling after the latest read, including a response discarded after a draft edit. @@ -194,6 +208,10 @@ the policy read, only the network policy card states it; the suggestions card is - [PostgreSQL admission integration](../../tests/integration/postgres-agent-sandbox-policy.test.mjs) exercises HTTP/IAM/storage; the [OpenShell runtime lane](../../tests/integration/sandbox-driver-openshell-k3d-real.test.mjs) owns real enforcement proof. Storybook proves simulated presentation only. +- [Console batch browser tests](../../tests/browser/console-policy-denials.test.ts) + cover selection across pages, stable Follow, deduplication, preservation of saved + rules, and ceiling rejection through real controller routes. They use in-memory + storage and test Drivers with simulated log input, not provider enforcement. ## Related docs @@ -216,6 +234,8 @@ the policy read, only the network policy card states it; the suggestions card is - 2026-10-22 23:45: Preserve CLI output guidance alongside upstream grant admission rules after conflict resolution. (authoring-run/01a10a81-070c-7aa3-97d0-fbce8f285d4d - c574fc743b21599c51d3809365039010a7e077d2) +- 2026-10-22 16:59: Trace the accompanying console changes for paged denials, batch proposals, rule management, and review before saving. (authoring-run/cd2ce6a3-4507-46cc-b936-68c9862c794b - 0c73a86a6cdd92f0129c8e52e0f230517490bd46) + - 2026-10-22 13:40: Run the shared grant rules before a Driver contribution, at every admission (preview, save, apply, deploy) and the worker recheck; empty or reversed ranges never satisfy the ceiling. (bughunt-1022c) - 2026-10-21 04:25: Declare structured policy CLI output before requests. (authoring-run/a66234ce-95fd-4d64-9fe8-3ce3d143e572 - e8185dac805b737363c7178d3068b310a53a7c24) diff --git a/docs/guides/topics/agent-sandbox-policy.md b/docs/guides/topics/agent-sandbox-policy.md index ce232e96c7..f64ee90635 100644 --- a/docs/guides/topics/agent-sandbox-policy.md +++ b/docs/guides/topics/agent-sandbox-policy.md @@ -6,30 +6,38 @@ observed denials. A version that runs without a Sandbox records none, and the ta says so. Policy reads and changes have independent permissions; see the [policy reference](../../reference/sandbox-policies.md). -1. Read **Denials in the loaded window** and any gap notices. Expand a row for - decision metadata and its sanitized log. Missing binary, destination, reason, - or generation information is shown as unknown. -2. If available, inspect **Policy suggestions** separately. Their observation - counts are aggregated provider data. Choose **Review suggestion**, or enter a - grant manually under **Agent network policy**. A denial with an exact path and - destination can also prefill a grant. -3. In the discovered configuration section, use **Add Network rules** and - **Remove Network rules** to edit additions. These changes stay local. Review the executable path, hostname, and complete - port scope against the **Installation delegation ceiling**, then select - **Review changes**. The preview shows field errors without saving or applying. - Review the rules the controller validated. If the Console cannot read them, - your proposal stays editable. **Back to editing** also preserves your proposal. -4. Choose **Save** to persist the policy without changing the current revision, - or **Save and apply to current revision** to save and request live application - to the selected running revision. Wait for `applied` and a provider version - before retrying the original tool action. A successful tool result provides - the final execution evidence. - If the Console cannot confirm which rules were saved, select **Refresh policy** - and inspect the saved version before deciding whether to apply it. -5. To apply a policy saved earlier, select **Apply saved policy to current - revision**. This control is unavailable while your proposal has unsaved edits. - When live application is unsupported, use **Deploy new version** explicitly. - Deployment requires deploy permission and can interrupt the Agent. +1. Check the **Network policy** card above the log. It shows the saved version, + last confirmed live version, and any suggested rules. Suggestions are untrusted + candidates; **Review suggestion** opens a local proposal. +2. Read the denial count and any gap notices. The log starts with five rows per + page. Search the loaded window or use **Next** to inspect more records. Expand + a row for decision metadata and its sanitized log. Missing fields stay unknown. + **Follow** collects updates without moving the rows you are inspecting; select + **Show newest** to display them. +3. Choose **Allow this** for one denial, or select several and choose + **Allow selected**. **Select page** selects eligible rows on the visible page. + Selection carries across pages; the count includes selections outside the + current page. Exact executable, host, and port requests become proposed rules. + Repeated requests and requests covered by saved or proposed rules do not add + duplicate rules. Incomplete metadata cannot prefill a rule. +4. To manage rules manually, choose **Manage rules**. Search the list or page + through five rules at a time. Use **Add rule** or a row's menu to edit or remove + a rule. These changes stay local. Under **Policy settings and Installation + limits**, check the delegation ceiling and read-only defaults. +5. Select **Review changes** after editing a rule. A batch opens this review + directly. Check the rules to add, update, or remove. The preview shows field + errors without saving or applying. **Back to editing** retains the proposal. +6. Choose **Save for later** to persist the policy without changing the current + revision, or **Save and apply to current revision** to save and request live + application. Wait for **Applied** before retrying the original tool action. + A successful tool result provides the final execution evidence. + If the Console cannot confirm which rules were saved, open **Manage rules**, + select **Refresh policy**, and inspect the saved version before applying it. +7. To apply a policy saved earlier, open **Manage rules** and select **Apply saved + policy to current revision**. This control is unavailable while your proposal + has unsaved edits. When live application is unsupported, use **Deploy new + version** explicitly. Deployment requires deploy permission and can interrupt + the Agent. The schema supplies field descriptions and constraints. Shapes without a form use a schema-aware JSON editor. A preview for an older draft or policy version diff --git a/docs/reference/sandbox-policies.md b/docs/reference/sandbox-policies.md index 6b186ab4bd..35cc36cc89 100644 --- a/docs/reference/sandbox-policies.md +++ b/docs/reference/sandbox-policies.md @@ -145,10 +145,22 @@ The console's **Policy denials** tab uses the existing bounded, exact-revision Sandbox log path and its `read_logs`/administration rules. Typed metadata is optional. Recognized OpenShell records are parsed by the Driver; OCC validates, redacts, and applies the normal output and retention limits. Unknown fields stay -unknown. **Denials in the loaded window** counts retained records, not requests -or lifetime denials. Buffer gaps, withheld records, permission denial, and missing +unknown. The denial count describes retained records, not unique requests or +lifetime denials. The console retains up to 1,000 log records and initially shows +five denials per page, newest first. Search filters the loaded window. Follow +collects new records without moving inspected rows; **Show newest** displays them. Buffer gaps, withheld records, permission denial, and missing metadata remain visible. An empty successful read says **No denials in this window.** +The **Network policy** card precedes the denial log and distinguishes saved +policy from confirmed live application. **Allow this** prefills one exact rule; +**Allow selected** stages eligible denials selected across pages. **Select page** +selects only the visible page. Repeated executable/host/port requests and requests +covered by existing rules do not create additional rules. The complete proposed +policy preserves existing rules and passes the normal preview, save, and apply +admission. Selecting denials grants no access until the user reviews and saves. +**Manage rules** searches and pages explicit rules, five at a time, with separate +controls to edit or remove a rule. Installation defaults stay read-only. + Provider suggestions use a separate authorized and audited read. OpenShell's `GetDraftPolicy` returns aggregated chunks, including its lifecycle and observation count. Chunks can be observation-generated or agent-authored; origin is not diff --git a/scripts/ci/test-suites/checks-browser-2.json b/scripts/ci/test-suites/checks-browser-2.json index 676daacfd0..1289733226 100644 --- a/scripts/ci/test-suites/checks-browser-2.json +++ b/scripts/ci/test-suites/checks-browser-2.json @@ -8,6 +8,7 @@ "tests/browser/console-agents.test.ts", "tests/integration/ci-impact.test.mjs", "tests/browser/console-agent-draft-guards.test.mjs", + "tests/browser/console-policy-denials.test.ts", "tests/browser/console-agent-channels.test.mjs", "tests/browser/console-dialogs.test.ts", "tests/browser/console-form-fields.test.ts", @@ -51,6 +52,9 @@ { "path": "tests/browser/console-agent-draft-guards.test.mjs" }, + { + "path": "tests/browser/console-policy-denials.test.ts" + }, { "path": "tests/browser/console-agent-state.test.mjs" }, diff --git a/scripts/console-storybook/pages-agent-detail.stories.ts b/scripts/console-storybook/pages-agent-detail.stories.ts index beabd08a66..5f4765293b 100644 --- a/scripts/console-storybook/pages-agent-detail.stories.ts +++ b/scripts/console-storybook/pages-agent-detail.stories.ts @@ -190,6 +190,8 @@ export const BootstrapContainerStatusAndLogs = story("runtimeBootstrapContainers export const PolicyDenials = story("policyDenials"); export const PolicyUnicodeExecutable = story("policyUnicodeExecutable"); +export const PolicyDenialsMany = story("policyDenialsMany"); +export const PolicyRulesPopulated = story("policyRulesPopulated"); export const PolicyDenialsEmpty = story("policyDenialsEmpty"); export const PolicyDenialsDisabled = story("policyDenialsDisabled"); export const PolicyDenialsUnsupported = story("policyDenialsUnsupported"); diff --git a/scripts/console-storybook/policy-workflow.md b/scripts/console-storybook/policy-workflow.md index 3d628c89d4..ea0b88307e 100644 --- a/scripts/console-storybook/policy-workflow.md +++ b/scripts/console-storybook/policy-workflow.md @@ -1,31 +1,43 @@ # Agent policy walkthrough -Use **Pages/Agent detail → Policy denials and reviewed network additions**. -Filter the loaded window by `example.org`, clear the filter, and expand a denial. -Confirm the expanded sanitized log is labelled `sandbox` and uses activity content. -Review its exact executable and endpoint, or select **Review suggestion**. +Use **Pages/Agent detail → Policy Denials Many** for 200 simulated denials and +**Policy Rules Populated** for ten applied network rules. -1. Under **Proposed network rules**, select **+** to add a rule and **×** to - remove it. Confirm the unsaved message appears and the saved version stays - unchanged. Fill the required fields, then select **Review changes**. -2. Select **Back to editing** and confirm the proposal remains editable. Review - again, then select **Save**. Confirm the saved version increases and the - message says the current revision is unchanged. -3. Edit the saved rule and review again. Select **Save and apply to current - revision**. Wait for pending to become applied with a provider version. - **Apply saved policy to current revision** applies an earlier saved policy; - it is unavailable while the proposal has unsaved changes. -4. In **Policy saved but live application rejected**, use the combined action. - Confirm the alert distinguishes a saved policy from unconfirmed application. -5. In **Policy addition exceeds the delegation ceiling**, review the suggested - `outside.example.org` rule and use the combined action. Confirm the error - receives focus and is visible without scrolling. The attempted rule remains - editable; the saved policy version and rules stay unchanged. -6. In **Policy preview omits the validated rules**, review a suggestion. Confirm - the preview leaves the proposal editable and offers no Save action. -7. In **Policy save returns different rules than reviewed**, use the combined - action. Confirm the Console asks you to refresh, then shows which rules were - saved. Live application requires a separate, explicit action. +1. Confirm the Network policy card and suggestion appear above the denial log. + The initial page shows five of 200 denials, on page one of 40. Search the loaded + window, clear the filter, and expand a denial to inspect its sanitized log. +2. Select the visible page, go to the next page, and select another denial. Check + the total and the count outside this page. Enable **Follow**: the new-denial + button appears while the inspected rows, details, and selection stay in place. +3. Choose **Allow selected**. Check the selected-record, distinct-request, and + new-rule counts. Repeated requests produce one rule. Review the changes and + expand the previously saved rules to check existing access. No save or apply + occurs before an explicit action. +4. Choose **Back to editing**, then **Done**. Open **Manage rules** and confirm + the draft is retained. Search the list, clear the search, and page through five + rules at a time. Use a row menu to edit or remove a rule. **Add rule** opens + the schema editor. **Back to rules** retains an incomplete draft for correction. +5. Review again and choose **Save for later**. The saved version increases; live + access stays unchanged. **Apply saved policy to current revision** requests + application separately and is unavailable while the proposal has unsaved changes. +6. In **Policy Rules Populated**, open **Manage rules** on a fresh preview. Check + ten rules across two pages, search for `allowed-10`, and inspect the row menu. + Repeat at phone width and confirm the modal scrolls with its footer visible. +7. In **Policy Denials**, choose **Allow this** or **Review suggestion**, review + the proposal, and **Save and apply to current revision**. Wait for pending to + become applied. The card distinguishes the saved and last confirmed live versions. +8. In **Policy Denials Apply Rejected**, use the combined action. The alert must + distinguish a saved policy from unconfirmed application. +9. In **Policy Denials Ceiling Denied**, review the `outside.example.org` suggestion. + Confirm the error receives focus inside the modal. The draft remains editable; + the saved policy and live access stay unchanged. +10. In **Policy Denials Protected**, open **Manage rules**. Confirm the protected + execution notice is visible, adding and editing are disabled, and **Remove + saved network rules** opens review. **Save for later** clears the rules; live + application stays disabled. +11. In **Policy Incomplete Preview**, review a suggestion. The unreadable preview + retains the draft and offers no Save action. In **Policy Unconfirmed Save**, + use the combined action and refresh before making another change. In **Unicode executable path remains complete**, copy the full path from the Installation delegation ceiling. Select **Review suggestion**, then paste that @@ -38,8 +50,13 @@ This scenario simulates the API; it does not verify an executable or policy application. Compare empty, disabled, unsupported, gap, missing-metadata, permission-denied, -loading, unavailable-credential, and unconfirmed-application stories. Confirm -manual editing remains available without logs or suggestions. The unsupported -story shows one sentence and no editor. A permission refusal offers no retry. These previews -prove simulated presentation only; use the OpenShell real-runtime lane for -persistence and enforcement proof. +loading, unavailable-credential, and unconfirmed-application stories. Manual +editing remains available without logs or suggestions. The unsupported story +shows one sentence and no editor. A permission refusal offers no retry. Inspect +screenshots and record the final interactions outside the checkout. + +These fixtures prove simulated UI behavior. Browser tests in +`tests/browser/console-policy-denials.test.ts` exercise real controller admission, +save, and application requests with in-memory storage and test Drivers; only their +log input is simulated. Use the OpenShell real-runtime lane for provider and +runtime enforcement proof. diff --git a/scripts/console-storybook/public/fixtures.mjs b/scripts/console-storybook/public/fixtures.mjs index 0e068a2c05..98f8a3a5e5 100644 --- a/scripts/console-storybook/public/fixtures.mjs +++ b/scripts/console-storybook/public/fixtures.mjs @@ -742,8 +742,24 @@ export function installFixture(scenario, evidence) { namespaceId, agentId: agent.id, version: 1, - additions: scenario.policyState === "protected" ? [policyGrant] : [], + additions: + scenario.policyState === "protected" + ? [policyGrant] + : Array.from({ length: scenario.policyRuleCount ?? 0 }, (_, index) => ({ + ...structuredClone(policyGrant), + id: `explicit-${index + 1}`, + host: `allowed-${index + 1}.example.org`, + })), }; + if (scenario.policyRuleCount) { + sandboxPolicy.applied = { + revisionId: candidateRevisionId, + policyVersion: 1, + providerVersion: "4", + appliedAt: createdAt, + }; + } + let policyNewRead = false; if (scenario.policyState === "failed" || scenario.policyState === "failedCeiling") { sandboxPolicy.application = { id: "00000000-0000-4000-8000-000000000009", @@ -1726,6 +1742,22 @@ export function installFixture(scenario, evidence) { reason: "Destination is not in the network policy", }, }; + if (url.searchParams.has("cursor") && scenario.policyNewDenials && !policyNewRead) { + policyNewRead = true; + return response({ + revisionId: candidateRevisionId, + source: "sandbox", + stream, + observedAt: createdAt, + records: Array.from({ length: scenario.policyNewDenials }, (_, index) => ({ + ...log, + time: new Date(Date.parse(createdAt) + (index + 1) * 1000).toISOString(), + })), + cursor: "simulated-policy-new-cursor", + withheld: 0, + truncated: false, + }); + } return response({ revisionId: candidateRevisionId, source: "sandbox", @@ -1746,7 +1778,21 @@ export function installFixture(scenario, evidence) { }, ] : []), - log, + ...Array.from({ length: scenario.policyDenialCount ?? 1 }, (_, index) => ({ + ...log, + time: new Date( + Date.parse(createdAt) - + ((scenario.policyDenialCount ?? 1) - index - 1) * 17000, + ).toISOString(), + ...(scenario.policyDenialCount + ? { + decision: { + ...log.decision, + host: `service-${Math.floor(index / 2) + 1}.example.org`, + }, + } + : {}), + })), ], cursor: "simulated-policy-cursor", withheld: 0, diff --git a/scripts/console-storybook/public/scenarios.mjs b/scripts/console-storybook/public/scenarios.mjs index 7fb30e1312..c7ce344259 100644 --- a/scripts/console-storybook/public/scenarios.mjs +++ b/scripts/console-storybook/public/scenarios.mjs @@ -8599,6 +8599,8 @@ export const scenarios = { for (const [id, state, name] of [ ["policyDenials", "ready", "Policy denials and reviewed network additions"], ["policyUnicodeExecutable", "unicode", "Unicode executable path remains complete"], + ["policyDenialsMany", "many", "Two hundred denials with batch approval"], + ["policyRulesPopulated", "populated", "Manage ten applied network rules"], [ "policyConfigurationAlternate", "alternate", @@ -8657,6 +8659,9 @@ for (const [id, state, name] of [ deployed: true, candidateDeploymentStatus: "succeeded", policyState: state, + ...(["many", "populated"].includes(state) + ? { policyDenialCount: 200, policyRuleCount: 10, policyNewDenials: 12 } + : {}), ...(state === "discoveryLoading" ? { rules: [{ suffix: "/driver-configurations", hold: true }] } : {}), @@ -8668,8 +8673,8 @@ for (const [id, state, name] of [ "Observed denial records, aggregated provider suggestions, and governed policy additions remain distinct.", steps: [ "Expand a denial to inspect the sanitized log. Review its exact executable and endpoint before saving an addition.", - "Use the schema field Add and Remove controls to edit proposed rules. Confirm these local edits do not save. Review changes and return with Back to editing.", - "Compare Save (current revision unchanged) with Save and apply to current revision (pending, then confirmed with a provider version). Apply saved policy to current revision is unavailable while a proposal has unsaved edits.", + "Select denial rows across pages, then Allow selected. Confirm the validated proposal retains existing rules and combines repeated executable/host/port requests. Back to editing preserves the draft.", + "Open Manage rules. Search the five-row pages, use a row menu to edit or remove a rule, or Add rule. Compare Save for later with Save and apply to current revision. Apply saved policy is unavailable while a proposal has unsaved edits.", "Disable policy editing and confirm approved grants remain visible. Check the empty, denied, unsupported, missing metadata, and unconfirmed stories.", ], gap: "Simulated UI proof only. These fixtures do not prove authorization, persistence, enforcement, or provider policy activation.", @@ -8713,8 +8718,8 @@ for (const [id, state, name] of [ ...(state === "protected" ? { steps: [ - "The tab states that protected execution applies: the Credential Proxy routes alone govern this Agent's egress, so network policy additions are refused. The proposal editor and Apply are unavailable.", - "A rule saved before protected execution applied blocks every protected deploy. Choose Remove saved network rules and confirm the saved policy is now empty.", + "Open Manage rules. The protected execution notice is visible, adding and editing are disabled, and live application is unavailable.", + "A rule saved before protected execution applied blocks every protected deploy. Choose Remove saved network rules, review the removal, then Save for later and confirm the saved policy is empty.", ], } : {}), diff --git a/tests/browser/console-agent-draft-guards.test.mjs b/tests/browser/console-agent-draft-guards.test.mjs index 0fa790b5da..98b5e65cf1 100644 --- a/tests/browser/console-agent-draft-guards.test.mjs +++ b/tests/browser/console-agent-draft-guards.test.mjs @@ -74,13 +74,16 @@ test("Agent policy ceiling rejection is announced in view and preserves unsaved await loginThroughForm(page, fixture, url); await page.waitForURL(url.href); const requests = apiRequests(page, fixture.origin); - await page.getByRole("button", { name: "Add Network rules", exact: true }).click(); - await page.getByRole("button", { name: "Remove Network rules 1", exact: true }).click(); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByRole("button", { name: "Add rule", exact: true }).click(); + await page.getByRole("button", { name: "Back to rules", exact: true }).click(); + await page.getByLabel("Actions for network rule 1", { exact: true }).click(); + await page.getByRole("button", { name: "Remove rule", exact: true }).click(); assert.equal( await page.getByRole("textbox", { name: "Executable path", exact: true }).count(), 0, ); - await page.getByRole("button", { name: "Add Network rules", exact: true }).click(); + await page.getByRole("button", { name: "Add rule", exact: true }).click(); await page .getByRole("textbox", { name: "Executable path", exact: true }) .fill("/usr/bin/python3.11"); @@ -414,7 +417,8 @@ test("Agent policy proposals save separately or save then apply the newly saved const { page } = await newPage(t, fixture); const requests = apiRequests(page, fixture.origin); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); - await page.getByRole("button", { name: "Add Network rules", exact: true }).click(); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByRole("button", { name: "Add rule", exact: true }).click(); await page .getByRole("textbox", { name: "Executable path", exact: true }) .fill(delegatedPolicyGrant.binary); @@ -446,7 +450,7 @@ test("Agent policy proposals save separately or save then apply the newly saved preview.release(); await previewResponse; await page.getByText("Draft changed. Review again before saving.", { exact: true }).waitFor(); - assert.equal(await page.getByRole("button", { name: "Save", exact: true }).count(), 0); + assert.equal(await page.getByRole("button", { name: "Save for later", exact: true }).count(), 0); assert.equal( await page.getByRole("textbox", { name: "Rule ID", exact: true }).inputValue(), "newer-draft", @@ -460,7 +464,7 @@ test("Agent policy proposals save separately or save then apply the newly saved delegatedPolicyGrant.host, ); await page.getByRole("button", { name: "Review changes", exact: true }).click(); - await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("button", { name: "Save for later", exact: true }).click(); await page.getByText(/Policy v2 saved\. The current revision is unchanged\./).waitFor(); const saved = (await fixture.request("GET", base)).data.policy; assert.equal(saved.version, 2); @@ -621,6 +625,7 @@ test("Agent policy under protected execution offers only removal of saved networ const readResponse = await read; t.diagnostic(`Policy GET status: ${readResponse.status()}`); assert.equal(readResponse.status(), 200); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); await page .getByText( "Protected execution uses Credential Proxy routes. You can remove saved network rules, but cannot add new ones. Saving does not change the running revision.", @@ -628,16 +633,13 @@ test("Agent policy under protected execution offers only removal of saved networ ) .waitFor(); // Neither adding a rule nor widening a saved rule is available under protected execution. - for (const [role, name] of [ - ["button", "Add Network rules"], - ["button", "Add Port ranges"], - ["textbox", "Rule ID"], - ["textbox", "Executable path"], - ["textbox", "Destination hostname"], - ["spinbutton", "Start port"], - ["spinbutton", "End port"], - ]) { - assert.equal(await page.getByRole(role, { name, exact: true }).isDisabled(), true, name); + assert.equal( + await page.getByRole("button", { name: "Add rule", exact: true }).isDisabled(), + true, + ); + await page.getByLabel("Actions for network rule 1", { exact: true }).click(); + for (const name of ["Edit rule", "Remove rule"]) { + assert.equal(await page.getByRole("button", { name, exact: true }).isDisabled(), true, name); } const preview = page.waitForResponse( (response) => @@ -652,7 +654,7 @@ test("Agent policy under protected execution offers only removal of saved networ data: { additions: [] }, }); await page.getByRole("heading", { name: "Review policy changes", exact: true }).waitFor(); - await page.getByText("Remove all Agent-specific rules.", { exact: true }).waitFor(); + await page.getByRole("heading", { name: "Rules to remove · 1", exact: true }).waitFor(); assert.deepEqual( pathRequests(requests, "POST", `${base}/validate`).map(({ body }) => body), [{ expectedVersion: 2, additions: [] }], @@ -672,7 +674,7 @@ test("Agent policy under protected execution offers only removal of saved networ const removal = page.waitForResponse( (response) => response.request().method() === "PUT" && response.url().endsWith(base), ); - await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("button", { name: "Save for later", exact: true }).click(); const removalResponse = await removal; t.diagnostic(`Policy removal PUT status: ${removalResponse.status()}`); assert.equal(removalResponse.status(), 200); @@ -693,7 +695,7 @@ test("Agent policy under protected execution offers only removal of saved networ 0, ); assert.equal( - await page.getByRole("button", { name: "Add Network rules", exact: true }).isDisabled(), + await page.getByRole("button", { name: "Add rule", exact: true }).isDisabled(), true, ); }); @@ -704,7 +706,8 @@ test("Agent policy combined save distinguishes ceiling rejection from a saved po const { page } = await newPage(t, fixture); const requests = apiRequests(page, fixture.origin); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); - await page.getByRole("button", { name: "Add Network rules", exact: true }).click(); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByRole("button", { name: "Add rule", exact: true }).click(); await page .getByRole("textbox", { name: "Executable path", exact: true }) .fill(delegatedPolicyGrant.binary); @@ -755,7 +758,8 @@ test("Agent policy conflict reload retains the draft and revalidates the new ver const { fixture, namespace, agent, base } = await createPolicyConsole(t, [delegatedPolicyGrant]); const { page } = await newPage(t, fixture); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, "draft", "policy")); - await page.getByRole("button", { name: "Add Network rules", exact: true }).click(); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByRole("button", { name: "Add rule", exact: true }).click(); await page.getByRole("textbox", { name: "Rule ID", exact: true }).fill(delegatedPolicyGrant.id); await page .getByRole("textbox", { name: "Executable path", exact: true }) @@ -767,13 +771,13 @@ test("Agent policy conflict reload retains the draft and revalidates the new ver await page.getByRole("spinbutton", { name: "Start port", exact: true }).fill("443"); await page.getByRole("spinbutton", { name: "End port", exact: true }).fill("443"); await page.getByRole("button", { name: "Review changes", exact: true }).click(); - await page.getByRole("button", { name: "Save", exact: true }).waitFor(); + await page.getByRole("button", { name: "Save for later", exact: true }).waitFor(); // A separate supported API save advances the version after this browser's successful preview. assert.equal( (await fixture.request("PUT", base, { body: { expectedVersion: 1, additions: [] } })).status, 200, ); - await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("button", { name: "Save for later", exact: true }).click(); await page.getByRole("alert").filter({ hasText: "The policy changed" }).waitFor(); await page.getByRole("button", { name: "Refresh policy", exact: true }).click(); await page.getByText(/Policy v2 reloaded. Your draft is retained/).waitFor(); @@ -782,7 +786,7 @@ test("Agent policy conflict reload retains the draft and revalidates the new ver delegatedPolicyGrant.id, ); await page.getByRole("button", { name: "Review changes", exact: true }).click(); - await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("button", { name: "Save for later", exact: true }).click(); await page.getByText(/Policy v3 saved/).waitFor(); assert.deepEqual((await fixture.request("GET", base)).data.policy.additions, [ delegatedPolicyGrant, @@ -795,6 +799,7 @@ test("overlapping policy refreshes retain the newest read and continue pending p const { page } = await newPage(t, fixture); const requests = apiRequests(page, fixture.origin); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); await page.getByText("Matches saved policy v1.", { exact: false }).waitFor(); const url = `${fixture.origin}${base}`; const first = await holdRequests(t, page, url, { once: true, method: "GET", afterServer: true }); @@ -821,6 +826,8 @@ test("overlapping policy refreshes retain the newest read and continue pending p second.release(); await secondResponse; await page.getByText("Matches saved policy v2.", { exact: false }).waitFor(); + await page.getByLabel("Actions for network rule 1", { exact: true }).click(); + await page.getByRole("button", { name: "Edit rule", exact: true }).click(); assert.equal( await page.getByRole("textbox", { name: "Rule ID", exact: true }).inputValue(), delegatedPolicyGrant.id, diff --git a/tests/browser/console-agent-save-refusals.test.ts b/tests/browser/console-agent-save-refusals.test.ts index e415e8c409..9bca53839f 100644 --- a/tests/browser/console-agent-save-refusals.test.ts +++ b/tests/browser/console-agent-save-refusals.test.ts @@ -915,7 +915,9 @@ for (const reply of ["an incomplete preview", "a save with different rules"] as const preview = reply === "an incomplete preview"; const url = detailUrl(fixture, namespace.id, agent.id, revision.id, "policy"); await login(page, fixture, url.href); - await page.getByRole("button", { name: "Remove Network rules 1", exact: true }).click(); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByLabel("Actions for network rule 1", { exact: true }).click(); + await page.getByRole("button", { name: "Remove rule", exact: true }).click(); const refresh = page.getByRole("button", { name: "Refresh policy", exact: true }); // The operator removes the grant. Let the controller validate or commit that removal, then @@ -942,7 +944,10 @@ for (const reply of ["an incomplete preview", "a save with different rules"] as .getByRole("alert") .filter({ hasText: "could not read the validated rules" }) .waitFor(); - assert.equal(await page.getByRole("button", { name: "Save", exact: true }).count(), 0); + assert.equal( + await page.getByRole("button", { name: "Save for later", exact: true }).count(), + 0, + ); assert.equal(pathRequests(requests, "PUT", base).length, 0); assert.equal(await review.isEnabled(), true); } else { diff --git a/tests/browser/console-policy-denials.test.ts b/tests/browser/console-policy-denials.test.ts new file mode 100644 index 0000000000..6b66e1595e --- /dev/null +++ b/tests/browser/console-policy-denials.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Page } from "playwright"; +import type { RuntimeLogRecord, SandboxNetworkGrant } from "../../packages/contracts/src/index.ts"; +import { createPolicyConsole } from "./console-sandbox-policy-fixture.ts"; +import { + apiRequests, + detailUrl, + login, + newPage, + pathRequests, +} from "./console-agents-browser-helpers.mjs"; + +const grants: SandboxNetworkGrant[] = ["one", "two", "three"].map((name) => ({ + id: `python-${name}`, + binary: "/usr/bin/python3.11", + host: `${name}.example.org`, + ports: [{ from: 443, to: 443 }], +})); + +function denial(grant: SandboxNetworkGrant, index: number): RuntimeLogRecord { + return { + type: "line", + time: new Date(Date.UTC(2026, 9, 22, 10, 0, index)).toISOString(), + stream: { source: "sandbox", sandbox: "browser-policy" }, + contentClass: "activity", + kind: "sandbox", + level: "warn", + message: "Network request denied", + decision: { + decision: "denied", + enforcement: "blocked", + binary: grant.binary, + host: grant.host, + port: 443, + reason: "Destination not allowed", + }, + }; +} + +async function logWindow( + page: Page, + origin: string, + agentPath: string, + revisionId: string, + records: RuntimeLogRecord[], +) { + let followed = false; + // Only the observed log input is simulated. Admission, saves, IAM checks, and application + // requests below pass through the real controller with its in-memory storage and test Drivers. + await page.route( + `${origin}${agentPath}/deployments/${revisionId}/runtime/logs?*`, + async (route) => { + const next = new URL(route.request().url()).searchParams.has("cursor"); + const updates = + next && !followed + ? Array.from({ length: 12 }, (_, index) => denial(grants[1]!, 201 + index)) + : []; + if (next) { + followed = true; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + json: { + data: { + revisionId, + source: "sandbox", + stream: { source: "sandbox", sandbox: "browser-policy" }, + records: next ? updates : records, + cursor: "browser-policy-window", + observedAt: new Date().toISOString(), + withheld: 0, + truncated: false, + }, + }, + }); + }, + ); +} + +test("denial selection spans pages, keeps the inspected window, and saves a deduplicated batch before application", async (t) => { + const { fixture, namespace, agent, base } = await createPolicyConsole(t, grants); + // An existing admitted rule must survive a batch: the API saves a complete desired policy. + assert.equal( + (await fixture.request("PUT", base, { body: { expectedVersion: 1, additions: [grants[0]] } })) + .status, + 200, + ); + const { revision } = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + const { page } = await newPage(t, fixture); + const records = Array.from({ length: 200 }, (_, index) => + denial(grants[Math.floor(index / 2) % grants.length]!, index), + ); + const agentPath = `/namespaces/${namespace.id}/agents/${agent.id}`; + await logWindow(page, fixture.origin, agentPath, revision.id, records); + const requests = apiRequests(page, fixture.origin); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); + const tab = page.locator(".sandbox-policy"); + await tab.getByText("200 in loaded window", { exact: true }).waitFor(); + assert.equal(await tab.locator(".sandbox-decision").count(), 5); + await tab.getByLabel("Select page", { exact: true }).check(); + await tab.getByRole("button", { name: "Allow selected (5)", exact: true }).waitFor(); + await tab.getByRole("button", { name: "Next", exact: true }).click(); + await tab.locator(".sandbox-denial-select input").first().check(); + await tab.getByRole("button", { name: "Allow selected (6)", exact: true }).waitFor(); + assert.match(await tab.locator(".sandbox-selection-actions").innerText(), /5 outside this page/); + + // Polling must not replace rows, close their details, or change the explicit selection. + await tab.getByRole("button", { name: "Show denial details", exact: true }).first().click(); + const inspected = await tab.locator(".sandbox-decisions").innerText(); + await tab.getByRole("checkbox", { name: "Follow policy denials", exact: true }).check(); + await tab.getByRole("button", { name: "12 new denials · Show newest", exact: true }).waitFor(); + assert.equal(await tab.locator(".sandbox-decisions").innerText(), inspected); + assert.equal(await tab.locator(".sandbox-decision-details:visible").count(), 1); + await tab.getByRole("checkbox", { name: "Follow policy denials", exact: true }).uncheck(); + + await tab.getByRole("button", { name: "Allow selected (6)", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Review policy changes", exact: true }); + await dialog.waitFor(); + assert.equal(pathRequests(requests, "PUT", base).length, 0, "review cannot save or apply"); + const validations = pathRequests(requests, "POST", `${base}/validate`); + assert.equal(validations.length, 1); + const validationBody = validations[0]!.body; + assert.ok(validationBody && typeof validationBody === "object" && "additions" in validationBody); + const proposed = validationBody.additions; + assert.ok(Array.isArray(proposed)); + assert.equal( + proposed.length, + 3, + "repeated denials and the saved grant must not create duplicate rules", + ); + assert.deepEqual(proposed[0], grants[0]); + await dialog + .getByRole("button", { name: "Save and apply to current revision", exact: true }) + .click(); + await page + .locator(".sandbox-policy-dialog") + .getByText(/Application to the current revision is pending/) + .waitFor(); + const saved = (await fixture.request("GET", base)).data.policy; + assert.equal(saved.version, 3); + assert.equal(saved.additions.length, 3); + assert.deepEqual(saved.additions[0], grants[0]); + assert.equal(saved.application.state, "pending"); + assert.equal(saved.application.policyVersion, 3); + assert.deepEqual( + pathRequests(requests, "POST", `${base}/apply`).map(({ body }: { body: unknown }) => body), + [{ expectedVersion: 3, revisionId: revision.id }], + ); +}); + +test("a policy batch outside the Installation ceiling retains its draft and does not save or apply", async (t) => { + const { fixture, namespace, agent, base } = await createPolicyConsole(t); + const { revision } = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + const { page } = await newPage(t, fixture, { + context: { viewport: { width: 390, height: 844 } }, + }); + await logWindow( + page, + fixture.origin, + `/namespaces/${namespace.id}/agents/${agent.id}`, + revision.id, + [denial(grants[1]!, 1), denial(grants[1]!, 2)], + ); + const requests = apiRequests(page, fixture.origin); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); + await page.getByText("2 in loaded window", { exact: true }).waitFor(); + await page.getByLabel("Select page", { exact: true }).check(); + await page.getByRole("button", { name: "Allow selected (2)", exact: true }).click(); + // The real validate route rejects an undelegated addition. Its error stays visible inside + // the modal and the single deduplicated proposal remains available for correction. + const alert = page + .getByRole("alert") + .filter({ hasText: "Your proposal has not been saved or applied" }); + await alert.waitFor(); + assert.equal(await alert.evaluate((node) => node === document.activeElement), true); + assert.equal(pathRequests(requests, "PUT", base).length, 0); + assert.equal(pathRequests(requests, "POST", `${base}/apply`).length, 0); + const saved = (await fixture.request("GET", base)).data.policy; + assert.equal(saved.version, 1); + assert.deepEqual(saved.additions, []); + await page.getByLabel("Actions for network rule 1", { exact: true }).click(); + await page.getByRole("button", { name: "Edit rule", exact: true }).click(); + assert.equal( + await page.getByRole("textbox", { name: "Destination hostname", exact: true }).inputValue(), + grants[1]!.host, + ); +}); From 50427a697a77056efde6f89dbcaef01a90c0878c Mon Sep 17 00:00:00 2001 From: Derek Carr Date: Mon, 26 Oct 2026 12:43:36 -0400 Subject: [PATCH 2/2] feat(console): refine policy editing controls and network limits --- .../src/console/agents/sandbox-policy.mts | 789 ++++++++++++++---- apps/controller/src/console/console.css | 350 +++++++- docs/flows/agent-sandbox-policy.md | 31 +- docs/guides/topics/agent-sandbox-policy.md | 33 +- docs/reference/sandbox-policies.md | 12 + .../pages-agent-detail.stories.ts | 8 + scripts/console-storybook/policy-workflow.md | 46 +- scripts/console-storybook/public/fixtures.mjs | 41 +- .../console-storybook/public/scenarios.mjs | 59 +- .../console-agent-draft-guards.test.mjs | 90 +- tests/browser/console-policy-denials.test.ts | 174 +++- 11 files changed, 1360 insertions(+), 273 deletions(-) diff --git a/apps/controller/src/console/agents/sandbox-policy.mts b/apps/controller/src/console/agents/sandbox-policy.mts index 019bbc6cca..96343e9b14 100644 --- a/apps/controller/src/console/agents/sandbox-policy.mts +++ b/apps/controller/src/console/agents/sandbox-policy.mts @@ -324,6 +324,17 @@ export function renderAgentSandboxPolicy( let editorIndex: number | undefined; let editingRules = false; let reviewSummary = ""; + let stagedReview: + | { + selected: number; + fresh: number; + repeated: number; + covered: number; + requests: { grant: SandboxNetworkGrant; count: number; covered: boolean }[]; + } + | undefined; + let outcomeRequested = false; + let failedApplicationInspected = false; const manageButton = button("Manage rules", () => openPolicyDialog()); function openPolicyDialog(index?: number) { dialogInvoker = @@ -335,6 +346,87 @@ export function renderAgentSandboxPolicy( dialog.showModal(); } } + const limitsTitle = element("h2", { id: "sandbox-network-limits-title" }, "Network limits"); + const limitsBody = element("div", { className: "dialog-body sandbox-network-limits-body" }); + const limitsDialog = element("dialog", { + className: "dialog dialog--framed sandbox-network-limits-dialog", + "aria-labelledby": limitsTitle.id, + }); + let limitsInvoker: Element | null; + limitsDialog.append( + element( + "div", + { className: "dialog-header" }, + limitsTitle, + button("×", () => limitsDialog.close(), { + tone: "ghost", + "aria-label": "Close network limits", + }), + ), + limitsBody, + element( + "div", + { className: "dialog-actions" }, + button("Done", () => limitsDialog.close()), + ), + ); + dismissOnBackdrop(limitsDialog); + limitsDialog.addEventListener("close", () => { + if (current()) { + const target = limitsInvoker?.isConnected + ? limitsInvoker + : policySummary.querySelector(".sandbox-network-limits-link"); + if (target instanceof HTMLElement) { + target.focus(); + } + } + }); + function openNetworkLimits() { + if (!view) { + return; + } + limitsInvoker = document.activeElement; + limitsBody.replaceChildren( + element( + "p", + {}, + "The Installation ceiling defines the maximum additional network access you can request for this Agent. Ceiling entries do not grant access by themselves.", + ), + element( + "div", + { className: "sandbox-limits-heading" }, + element("h3", {}, "Installation ceiling"), + element("span", { className: "badge" }, "Read-only"), + ), + element( + "p", + { className: "hint" }, + "These limits are set by Installation configuration. Turning policy editing on or off does not change them.", + ), + ...(view.ceiling.length + ? [ + element( + "p", + { className: "hint" }, + `${view.ceiling.length} configured ${view.ceiling.length === 1 ? "entry" : "entries"} · Executable, destination and permitted ports`, + ), + ruleList(view.ceiling), + ] + : [ + element( + "p", + { className: "notice" }, + "No additional network access is delegated. Agent-specific network rules cannot be added. Installation defaults still apply.", + ), + ]), + element( + "p", + { className: "hint" }, + "Policy changes must also meet Agent permissions and execution restrictions.", + ), + ); + limitsDialog.showModal(); + } const suggestions = element("section", { className: "agent-card" }); const logStatus = element("p", { role: "status" }, "Loading policy denials…"); const logWindowStatus = element("p", { className: "hint" }); @@ -344,7 +436,7 @@ export function renderAgentSandboxPolicy( const logRows = element("div", { className: "sandbox-decisions" }); const logNotices = element("div", { className: "sandbox-log-notices" }); const logPager = element("div"); - const selection = element("div", { className: "sandbox-selection" }); + const selection = element("div", { className: "sandbox-selection", hidden: true }); const selected = new Set(); const expanded = new Set(); let visibleDenials: LogLine[] = []; @@ -561,6 +653,8 @@ export function renderAgentSandboxPolicy( selectPage.indeterminate = checked > 0 && checked < selectable.length; selectPage.disabled = !editable() || selectable.length === 0; selectPageText.textContent = `Select page (${selectable.length})`; + selection.hidden = !editable(); + section.classList.toggle("sandbox-readonly", !editable()); selectionActions.hidden = selected.size === 0; const elsewhere = [...selected].filter((record) => !visibleDenials.includes(record)).length; selectionCount.textContent = `${selected.size} selected${elsewhere ? ` · ${elsewhere} outside this page` : ""}`; @@ -850,6 +944,19 @@ export function renderAgentSandboxPolicy( const existing = additions; const fresh = distinct.filter((grant) => !grantCovered(grant, existing)); reviewSummary = `${grants.length} selected denial${grants.length === 1 ? "" : "s"} · ${distinct.length} distinct request${distinct.length === 1 ? "" : "s"} · ${fresh.length} new rule${fresh.length === 1 ? "" : "s"}`; + stagedReview = batch + ? { + selected: grants.length, + fresh: fresh.length, + repeated: grants.length - distinct.length, + covered: distinct.length - fresh.length, + requests: distinct.map((grant) => ({ + grant, + count: grants.filter((item) => grantKey(item) === grantKey(grant)).length, + covered: grantCovered(grant, existing), + })), + } + : undefined; const index = additions.length; additions.push(...structuredClone(fresh)); draftGeneration += 1; @@ -865,43 +972,174 @@ export function renderAgentSandboxPolicy( } } + function selectionReviewSummary() { + if (stagedReview) { + return element( + "section", + { className: "sandbox-bulk-summary" }, + element( + "h3", + {}, + `${stagedReview.selected} ${stagedReview.selected === 1 ? "denial" : "denials"} selected → ${stagedReview.fresh} new ${stagedReview.fresh === 1 ? "rule" : "rules"}`, + ), + element( + "p", + {}, + `${stagedReview.repeated} repeated ${stagedReview.repeated === 1 ? "denial" : "denials"} grouped · ${stagedReview.covered} distinct ${stagedReview.covered === 1 ? "request" : "requests"} already covered by saved or proposed rules.`, + ), + element( + "p", + { className: "hint" }, + "Review the executable, destination and ports below. Access changes only after saving and confirmed application, or deployment.", + ), + element( + "details", + { className: "sandbox-selection-breakdown" }, + element("summary", {}, "How the selection becomes rules"), + ...stagedReview.requests.map((item) => + element( + "p", + { className: "resource-id" }, + `${visibleText(item.grant.binary)} → ${item.grant.host}:${item.grant.ports.map((port) => (port.from === port.to ? port.from : `${port.from}–${port.to}`)).join(", ")} · ${item.count} ${item.count === 1 ? "denial" : "denials"} → ${item.covered ? "already covered" : "1 new rule"}`, + ), + ), + ), + ); + } + return reviewSummary ? element("p", { className: "hint" }, reviewSummary) : null; + } + function prefill(grant: SandboxNetworkGrant) { stageGrants([grant], false); } + let suggestionsExpanded = false; + let suggestionsShowAll = false; function renderSuggestions() { suggestions.hidden = recommendations.length === 0 && suggestionStatus.dataset.loaded === "true"; suggestions.className = "sandbox-suggestions"; suggestions.replaceChildren(); - if (recommendations.length === 1 && editable()) { - const entry = recommendations[0]!; + const canReview = editable(); + const showCandidates = canReview || suggestionsExpanded; + let suggestionHelp = "Inspect potential fixes. Changes require additional policy permissions."; + if (!view?.editingEnabled) { + suggestionHelp = "Inspect potential fixes. Policy editing must be on to make changes."; + } else if (view.policy.application?.state === "pending" || mutation) { + suggestionHelp = "Inspect potential fixes while the current update completes."; + } else if (protectedExecution()) { + suggestionHelp = + "Inspect observed requests. Protected execution restricts network additions."; + } else if (canReview) { + suggestionHelp = "Review observed requests before adding network access."; + } + const summary = element( + "div", + { className: "sandbox-suggestions-copy" }, + element( + "h3", + {}, + recommendations.length + ? `${recommendations.length} suggested ${recommendations.length === 1 ? "rule" : "rules"}` + : "Policy suggestions", + ), + element("p", { className: "hint" }, suggestionHelp), + ); + suggestions.append( + element( + "div", + { className: "sandbox-suggestions-head" }, + summary, + !canReview && recommendations.length + ? button( + suggestionsExpanded ? "Hide suggestions" : "View suggestions", + () => { + suggestionsExpanded = !suggestionsExpanded; + renderSuggestions(); + suggestions + .querySelector('[data-suggestions-toggle="visibility"]') + ?.focus(); + }, + { + size: "sm", + "aria-expanded": String(suggestionsExpanded), + "data-suggestions-toggle": "visibility", + }, + ) + : null, + ), + ); + if (suggestionStatus.dataset.loaded !== "true") { + suggestions.append(suggestionStatus); + } + if (showCandidates) { + for (const entry of suggestionsShowAll ? recommendations : recommendations.slice(0, 3)) { + const grant = entry.grant; + const ports = grant.ports + .map(({ from, to }) => (from === to ? from : `${from}–${to}`)) + .join(", "); + const path = element( + "details", + { className: "sandbox-executable-path" }, + element("summary", {}, "Full executable path"), + element("p", { className: "resource-id" }, visibleText(grant.binary)), + ); + suggestions.append( + element( + "div", + { className: "sandbox-suggestion-row" }, + element( + "div", + { className: "sandbox-suggestion-target" }, + element( + "div", + { className: "sandbox-suggestion-label" }, + element( + "p", + { className: "resource-id" }, + `${visibleText(grant.binary.split("/").at(-1))} → ${visibleText(grant.host)}:${ports}`, + ), + element( + "span", + { className: "sandbox-observations" }, + Number.isInteger(entry.observations) + ? `Observed ${entry.observations} ${entry.observations === 1 ? "time" : "times"}` + : "Observation count unavailable", + ), + ), + path, + ), + canReview ? button("Review suggestion", () => prefill(grant), { size: "sm" }) : null, + ), + ); + } + } + if (showCandidates && recommendations.length > 3) { suggestions.append( element( "div", - {}, - element("strong", {}, "1 suggested rule"), - element("p", { className: "resource-id" }, grantText(entry.grant)), - ), - button("Review suggestion", () => prefill(entry.grant), { size: "sm" }), - ); - } else { - suggestions.append( - element( - "h3", - {}, - recommendations.length - ? `${recommendations.length} suggested rules` - : "Policy suggestions", - ), - suggestionStatus, - ...recommendations.map((entry) => + { className: "sandbox-suggestions-footer" }, element( - "div", - { className: "sandbox-suggestion-row" }, - element("p", { className: "resource-id" }, grantText(entry.grant)), - editable() - ? button("Review suggestion", () => prefill(entry.grant), { size: "sm" }) - : null, + "span", + { className: "hint" }, + `Showing ${suggestionsShowAll ? recommendations.length : 3} of ${recommendations.length}`, + ), + button( + suggestionsShowAll + ? "Show fewer suggestions" + : `Show all ${recommendations.length} suggestions`, + () => { + suggestionsShowAll = !suggestionsShowAll; + renderSuggestions(); + suggestions + .querySelector('[data-suggestions-toggle="all"]') + ?.focus(); + }, + { + tone: "link", + size: "sm", + "aria-expanded": String(suggestionsShowAll), + "data-suggestions-toggle": "all", + }, ), ), ); @@ -929,7 +1167,9 @@ export function renderAgentSandboxPolicy( ? [] : [button("Refresh suggestions", () => void loadSuggestions())]), ); - suggestions.append(details); + if (canReview || suggestionsExpanded || !recommendations.length) { + suggestions.append(details); + } } async function loadSuggestions() { @@ -1057,22 +1297,20 @@ export function renderAgentSandboxPolicy( } function renderEditor() { - dialogTitle.textContent = editingRules ? "Edit network rule" : "Manage network rules"; + dialogTitle.textContent = view?.editingEnabled ? "Manage network rules" : "View network rules"; + if (editingRules) { + dialogTitle.textContent = "Edit network rule"; + } dialogActions.replaceChildren(button("Done", closeDialog, { disabled: mutation })); policyPanel.replaceChildren( feedback, - button("Refresh policy", () => void loadPolicy(), { disabled: mutation }), + button("Refresh policy", () => void loadPolicy(true), { disabled: mutation }), ); if (!view) { policyPanel.append(element("p", { role: "status" }, "Loading Agent policy…")); return; } const { configuration, editingEnabled, policy } = view; - const settings = element( - "details", - { className: "sandbox-policy-settings" }, - element("summary", {}, "Policy settings and Installation limits"), - ); if (unknownOutcome) { policyPanel.append( element( @@ -1084,128 +1322,83 @@ export function renderAgentSandboxPolicy( return; } const { application } = policy; - settings.append( - element( - "p", - {}, - view.capability - ? `Policy v${policy.version} · Editing ${editingEnabled ? "enabled" : "disabled"}` - : "This Sandbox Driver does not support per-Agent network policy.", - ), - element( - "p", - { className: "muted" }, - protectedExecution() - ? "Defaults remain read-only. Saved network rules can only be removed while protected execution applies. Disabling editing preserves saved rules." - : "Defaults remain read-only. Only the additions below are editable. Disabling editing preserves approved grants.", - ), - element( - "details", - {}, - element("summary", {}, "Read-only Installation defaults"), - element( - "p", - { className: "muted" }, - view.defaultsRevisionId - ? `Admitted for running version ${view.defaultsRevisionId}. Generated transport access is managed separately.` - : protectedExecution() - ? "Defaults from the selected Sandbox Driver. Protected versions use Credential Proxy routes instead." - : "Defaults from the selected Sandbox Driver. Generated transport access is managed separately.", - ), - view.defaults === null - ? element("p", {}, "No editable network-policy capability is available.") - : element("pre", {}, visibleText(JSON.stringify(view.defaults, null, 2))), - ), - element( - "details", - {}, - element("summary", {}, "Installation delegation ceiling"), - ...(view.ceiling.length - ? view.ceiling.map((grant) => element("p", {}, grantText(grant))) - : [element("p", {}, "No additions are delegated.")]), - ), - button( - editingEnabled ? "Disable policy editing" : "Enable policy editing", - () => - void mutate( - async () => { - await context.request(`${policyPath}/editing`, { - method: "PATCH", - body: { enabled: !editingEnabled }, - }); - await loadPolicy(); - }, - false, - (error) => - requestFailure(error).status === 403 - ? POLICY_EDITING_SETTING_REFUSED - : errorText(error, "changing the editing setting", POLICY_CHANGE_GRANT), - ), - { disabled: mutation }, - ), - element( - "p", - { className: "hint" }, - "Changing the editing setting requires Installation and Agent administration. Editing additions requires separate Agent policy and SandboxPolicy permissions.", - ), - ); - if (policy.applied) { - const applied = policy.applied; - settings.append( - element( - "p", - {}, - `Last confirmed live policy: v${applied.policyVersion} · Provider version ${applied.providerVersion} · Revision ${applied.revisionId}`, - ), - ); - } - if (application) { - settings.append( - element( - "p", - { role: "status" }, - `Application: ${application.state}${application.providerVersion ? ` · Provider version ${application.providerVersion}` : ""}${application.reason ? ` · ${application.reason}` : ""} · Revision ${application.revisionId}`, - ), - ); - if (application.state === "failed") { - // Keep the reason code beside its explanation for audit correlation. - const reason = application.reason; - settings.append( - element( - "p", - { className: "notice" }, - reason && Object.hasOwn(APPLICATION_FAILURES, reason) - ? APPLICATION_FAILURES[reason] - : APPLICATION_UNCONFIRMED, - ), - ); - } - } else { - settings.append( - element( - "p", - { className: "muted" }, - "Saved policy changes take effect on explicit deployment or confirmed live application.", - ), - ); - } const pending = application?.state === "pending"; const canApply = canApplySavedPolicy(); const form = element("form", { className: "sandbox-policy-form" }); - const draftStatus = element("p", { role: "status", className: "hint" }); - const reviewButton = element("button", { type: "submit" }, "Review changes"); - const applyButton = button("Apply saved policy to current revision", () => { - if (policyPanel.contains(applyButton)) { - void mutate(applySavedPolicy); - } + form.id = "sandbox-network-policy-form"; + const reviewButton = element( + "button", + { + type: "submit", + form: form.id, + className: "button button--primary", + }, + "Review changes", + ); + const footerStatus = element("span", { + className: "sandbox-dialog-change-status", + role: "status", }); function updateDraftStatus() { const dirty = hasProposedChanges(); - draftStatus.textContent = dirty - ? "Unsaved changes. Adding or removing a proposed rule does not save or apply it." - : `Matches saved policy v${policy.version}. Editing rules only changes your proposal.`; - reviewButton.disabled = !editable() || pending || !dirty; - applyButton.disabled = !editable() || !canApply || pending || dirty; + let changes = 1; + if (Array.isArray(additions)) { + const addedOrChanged = additions.filter( + (rule) => !policy.additions.some((saved) => sameValue(saved, rule)), + ).length; + const proposed = additions; + const removed = policy.additions.filter( + (saved) => !proposed.some((rule) => rule?.id === saved.id), + ).length; + changes = Math.max(1, addedOrChanged + removed); + } + const unapplied = + policy.applied?.revisionId !== revisionId || + policy.applied?.policyVersion !== policy.version; + const failed = application?.state === "failed"; + footerStatus.textContent = "No unsaved changes"; + if (dirty) { + footerStatus.textContent = `${changes} rule ${changes === 1 ? "change" : "changes"} to review`; + } else if (pending) { + footerStatus.textContent = "Applying…"; + } else if (failed) { + footerStatus.textContent = "Application unconfirmed"; + } else if (unapplied) { + footerStatus.textContent = "Saved rules awaiting application"; + } + const canReview = + canEdit() && + (!protectedExecution() || (Array.isArray(additions) && additions.length === 0)); + reviewButton.disabled = mutation || !canReview || pending; + dialogActions.replaceChildren( + footerStatus, + button("Done", closeDialog, { disabled: mutation }), + ); + if (dirty && editingEnabled && canReview) { + dialogActions.append(reviewButton); + } else if ( + !dirty && + (unapplied || failed) && + !pending && + (!failed || failedApplicationInspected) && + canApply + ) { + dialogActions.append( + button( + failed ? "Retry application" : "Apply saved rules", + () => void mutate(applySavedPolicy), + { + tone: "primary", + disabled: mutation, + }, + ), + ); + } + const addRule = rulesOverview.querySelector(".sandbox-add-rule"); + addRule?.classList.toggle( + "button--primary", + !dirty && !(unapplied && !pending && (!failed || failedApplicationInspected) && canApply), + ); } if (!configuration) { return; @@ -1223,7 +1416,6 @@ export function renderAgentSandboxPolicy( !Array.isArray(itemSchema); const index = editorIndex ?? 0; form.append( - draftStatus, configurationFields( single ? (itemSchema as JSONSchema) : configuration.configurationSchema, single && Array.isArray(additions) ? additions[index] : { additions }, @@ -1249,23 +1441,22 @@ export function renderAgentSandboxPolicy( : validationDetails, onEdit: () => { reviewSummary = ""; + stagedReview = undefined; draftGeneration += 1; feedback.className = ""; feedback.setAttribute("role", "status"); - feedback.textContent = "Draft changed. Review again before saving."; + feedback.textContent = ""; }, }, ), ...(validationDetails.length ? [configurationErrors(validationDetails)] : []), ); form.append( - reviewButton, button("Back to rules", () => { editingRules = false; renderEditor(); }), ); - updateDraftStatus(); form.addEventListener("submit", async (event) => { event.preventDefault(); if ( @@ -1384,6 +1575,11 @@ export function renderAgentSandboxPolicy( view.policy = saved; additions = structuredClone(saved.additions); savedVersion = saved.version; + outcomeRequested = true; + selected.clear(); + stagedReview = undefined; + editingRules = false; + dialog.close(); if (apply) { await applySavedPolicy(); } else { @@ -1414,7 +1610,7 @@ export function renderAgentSandboxPolicy( const review = element( "section", { className: "sandbox-policy-review" }, - reviewSummary ? element("p", { className: "hint" }, reviewSummary) : null, + selectionReviewSummary(), ...(changed.length ? [element("h3", {}, `Rules to add or update · ${changed.length}`), ruleList(changed)] : []), @@ -1448,9 +1644,7 @@ export function renderAgentSandboxPolicy( ); dialogTitle.textContent = "Review policy changes"; rulesOverview.hidden = true; - settings.hidden = true; form.replaceWith(review); - applyButton.hidden = true; }); const rulesOverview = element( "section", @@ -1479,9 +1673,10 @@ export function renderAgentSandboxPolicy( editorIndex = additions.length - 1; editingRules = true; reviewSummary = ""; + stagedReview = undefined; renderEditor(); }, - { tone: "primary", size: "sm", disabled: !editable() }, + { tone: "primary", size: "sm", className: "sandbox-add-rule", disabled: !editable() }, ), ), element( @@ -1503,6 +1698,7 @@ export function renderAgentSandboxPolicy( editorIndex = index; editingRules = true; reviewSummary = ""; + stagedReview = undefined; renderEditor(); }, { disabled: !editable() }, @@ -1515,6 +1711,7 @@ export function renderAgentSandboxPolicy( } additions.splice(index, 1); reviewSummary = ""; + stagedReview = undefined; draftGeneration += 1; renderEditor(); }, @@ -1551,12 +1748,7 @@ export function renderAgentSandboxPolicy( ), ] : []), - editingRules ? null : draftStatus, ...(validationDetails.length ? [configurationErrors(validationDetails)] : []), - button("Review changes", () => form.requestSubmit(), { - tone: "primary", - disabled: !editable() || !hasProposedChanges(), - }), ); rulesOverview.hidden = editingRules; form.hidden = !editingRules; @@ -1581,8 +1773,6 @@ export function renderAgentSandboxPolicy( : []), rulesOverview, form, - applyButton, - settings, ...(!view.capability?.live ? [ element( @@ -1593,9 +1783,28 @@ export function renderAgentSandboxPolicy( ] : []), ); + if (!editable() && !editingRules) { + for (const action of rulesOverview.querySelectorAll("button")) { + if (["Add rule", "Review changes"].includes(action.textContent)) { + action.hidden = true; + } + } + for (const menu of rulesOverview.querySelectorAll(".sandbox-rule-actions")) { + menu.hidden = true; + } + const hint = rulesOverview.querySelector(".hint"); + if (hint) { + hint.textContent = + "Existing rules still apply. Changes require enabled editing and policy permissions."; + } + } + + updateDraftStatus(); } async function applySavedPolicy() { + outcomeRequested = true; + failedApplicationInspected = false; if (!view?.configuration || !canApplySavedPolicy() || hasProposedChanges()) { return; } @@ -1620,7 +1829,7 @@ export function renderAgentSandboxPolicy( }, delay); } - async function loadPolicy() { + async function loadPolicy(inspectFailure = false) { clearTimeout(policyTimer); const sequence = ++policyReadSequence; const generation = draftGeneration; @@ -1633,6 +1842,7 @@ export function renderAgentSandboxPolicy( } const wasPending = view?.policy.application?.state === "pending"; view = loaded; + failedApplicationInspected = inspectFailure && loaded.policy.application?.state === "failed"; if (view.capability === null) { // The draft tab reads no logs or suggestions, so the unsupported state is shown from here too. void policySupported(); @@ -1689,6 +1899,53 @@ export function renderAgentSandboxPolicy( } } + function policyEditingExplanation() { + if (!view?.editingEnabled) { + return [ + element( + "p", + { className: "hint sandbox-editing-explanation" }, + "Editing is off. Existing rules still apply. Turn on Policy editing above to review and allow requests. Changing this setting requires Installation and Agent administration.", + ), + ]; + } + if ( + !editable() && + !mutation && + !unknownOutcome && + view.policy.application?.state !== "pending" + ) { + return [ + element( + "p", + { className: "hint" }, + "Policy changes require additional permission or may be restricted for this Agent.", + ), + ]; + } + return []; + } + + async function setPolicyEditing(enabled: boolean) { + await mutate( + async () => { + await context.request(`${policyPath}/editing`, { + method: "PATCH", + body: { enabled: !enabled }, + }); + await loadPolicy(); + }, + false, + (error) => + requestFailure(error).status === 403 + ? POLICY_EDITING_SETTING_REFUSED + : errorText(error, "changing the editing setting", POLICY_CHANGE_GRANT), + ); + if (current() && feedback.getAttribute("role") !== "alert") { + policySummary.querySelector(".sandbox-editing-switch, button")?.focus(); + } + } + function renderPolicy() { if (policyReadDenied) { policySummary.replaceChildren( @@ -1697,12 +1954,38 @@ export function renderAgentSandboxPolicy( ); return; } + // Keep the failed read and its recovery action visible after a mutation finishes. + if (policyReadUnavailable) { + return; + } + manageButton.disabled = mutation || !view; + manageButton.textContent = view?.editingEnabled ? "Manage rules" : "View rules"; + const controls = element("div", { className: "sandbox-policy-controls" }); + if (view) { + const enabled = view.editingEnabled; + const editing = button(enabled ? "On" : "Off", () => void setPolicyEditing(enabled), { + className: "sandbox-editing-switch", + role: "switch", + "aria-label": "Policy editing", + "aria-checked": String(enabled), + disabled: mutation || unknownOutcome, + }); + controls.append( + element( + "div", + { className: "sandbox-editing-control" }, + element("span", {}, "Policy editing"), + editing, + ), + ); + } + controls.append(manageButton); policySummary.replaceChildren( element( "div", - { className: "sandbox-policy-card-head" }, + { className: "sandbox-policy-card-head sandbox-policy-header" }, element("h2", {}, "Network policy"), - manageButton, + controls, ), ); if (!view) { @@ -1722,20 +2005,11 @@ export function renderAgentSandboxPolicy( return { label: "Applied", tone: "success" }; } return { - label: applied ? "Changes not applied" : "Application unconfirmed", + label: applied || policy.version > 1 ? "Saved for later" : "Application unconfirmed", tone: "warning", }; } const presentation = policyStatus(); - policySummary - .querySelector("h2") - ?.append( - element( - "span", - { className: "badge", "data-tone": presentation.tone }, - presentation.label, - ), - ); policySummary.append( ...(hasProposedChanges() ? [ @@ -1747,10 +2021,65 @@ export function renderAgentSandboxPolicy( ] : []), element( - "p", - { className: "hint" }, - `${policy.additions.length} explicit rules · Saved v${policy.version} · ${applied ? `Last confirmed live v${applied.policyVersion}` : "Live version unconfirmed"}`, + "div", + { className: "sandbox-policy-meta" }, + element( + "div", + { className: "sandbox-policy-rule-status" }, + element( + "strong", + {}, + `${policy.additions.length} explicit ${policy.additions.length === 1 ? "rule" : "rules"}`, + ), + element("span", { "aria-hidden": "true" }, "·"), + element( + "span", + { className: "badge", "data-tone": presentation.tone, role: "status" }, + presentation.label, + ), + ), + button("View network limits", openNetworkLimits, { + tone: "link", + size: "sm", + className: "sandbox-network-limits-link", + "aria-haspopup": "dialog", + }), + element( + "details", + { className: "sandbox-policy-versions" }, + element("summary", {}, "Policy details"), + element( + "details", + {}, + element("summary", {}, "Read-only Installation defaults"), + element( + "p", + { className: "hint" }, + view.defaultsRevisionId + ? `Admitted for running revision ${view.defaultsRevisionId}. Generated transport access is managed separately.` + : "Defaults from the selected Sandbox Driver. Generated transport access is managed separately.", + ), + element("pre", {}, visibleText(JSON.stringify(view.defaults, null, 2))), + ), + policy.application + ? element( + "p", + { className: "hint" }, + `Application: ${policy.application.state} · Revision ${policy.application.revisionId}${policy.application.reason ? ` · ${policy.application.reason}` : ""}`, + ) + : null, + + element("p", { className: "hint" }, `Saved version: v${policy.version}`), + element( + "p", + { className: "hint" }, + applied + ? `Last confirmed live version: v${applied.policyVersion}` + : "Live version unconfirmed", + ), + ), ), + ...policyEditingExplanation(), ...(unknownOutcome ? [ element( @@ -1762,8 +2091,105 @@ export function renderAgentSandboxPolicy( : []), ); } + const currentPolicy = view?.policy; + const application = currentPolicy?.application; + const confirmed = + currentPolicy?.applied?.revisionId === revisionId && + currentPolicy.applied.policyVersion === currentPolicy.version; + let outcome; + if (unknownOutcome) { + outcome = { + tone: "warning", + title: "Change outcome unknown", + text: "Refresh status to check the saved policy before making another change.", + }; + } else if (application?.state === "failed") { + outcome = { + tone: "warning", + title: "Application unconfirmed", + text: + application.reason && Object.hasOwn(APPLICATION_FAILURES, application.reason) + ? APPLICATION_FAILURES[application.reason] + : APPLICATION_UNCONFIRMED, + }; + } else if (application?.state === "pending") { + outcome = { + tone: "info", + title: "Applying…", + text: "Your rules are saved. Waiting for confirmation from the running Agent’s Sandbox.", + }; + } else if ( + currentPolicy && + !confirmed && + (currentPolicy.applied?.revisionId === revisionId || + currentPolicy.version > 1 || + outcomeRequested) + ) { + outcome = { + tone: "info", + title: "Saved for later", + text: + currentPolicy.applied?.revisionId === revisionId + ? "Your rules are saved. The running Agent still uses its previously applied rules." + : "Your rules are saved. Apply them explicitly or deploy a new version when ready.", + }; + } else if (confirmed && (outcomeRequested || application?.state === "applied")) { + outcome = { + tone: "success", + title: "Applied to the running Agent", + text: "The Sandbox confirmed these rules are active. Earlier denials remain in the historical log.", + }; + } + if (outcome) { + const actions = element("div", { className: "sandbox-outcome-actions" }); + if (outcome.title === "Saved for later" && !hasProposedChanges() && canApplySavedPolicy()) { + actions.append( + button("Apply saved rules", () => void mutate(applySavedPolicy), { + size: "sm", + disabled: mutation, + }), + ); + } + if ( + outcome.title === "Applying…" || + outcome.title === "Application unconfirmed" || + outcome.title === "Change outcome unknown" + ) { + actions.append( + button("Refresh status", () => void loadPolicy(true), { size: "sm", disabled: mutation }), + ); + } + if ( + application?.state === "failed" && + failedApplicationInspected && + !hasProposedChanges() && + canApplySavedPolicy() + ) { + actions.append( + button("Retry application", () => void mutate(applySavedPolicy), { + size: "sm", + disabled: mutation, + }), + ); + } + policySummary.append( + element( + "section", + { + className: "sandbox-application-feedback", + "data-tone": outcome.tone, + role: outcome.tone === "warning" ? "alert" : "status", + }, + element("h3", {}, outcome.title), + element("p", {}, outcome.text), + actions, + ), + ); + } if (dialog.open) { renderEditor(); + } else if (feedback.textContent) { + policySummary.append(feedback); } } @@ -1804,6 +2230,7 @@ export function renderAgentSandboxPolicy( logPager, windowDetails, dialog, + limitsDialog, ); renderPolicy(); // A caller whose permission answer denies sandbox policy read is not sent the policy read; an diff --git a/apps/controller/src/console/console.css b/apps/controller/src/console/console.css index bd7e2a9310..275e65a561 100644 --- a/apps/controller/src/console/console.css +++ b/apps/controller/src/console/console.css @@ -4704,10 +4704,12 @@ html:has(#create-agent-form) { } .sandbox-denials-toolbar { justify-content: space-between; + align-items: flex-end; flex-wrap: wrap; } -.sandbox-denials-toolbar > input { - flex: 1; +.sandbox-denials-toolbar > .form-field { + display: grid; + flex: 1 1 20rem; max-width: 32rem; min-width: 0; } @@ -4871,19 +4873,6 @@ html:has(#create-agent-form) { .sandbox-policy-editor > :first-child:empty { display: none; } -.sandbox-policy-settings { - margin-block: var(--space-3); - font-size: var(--text-sm); - line-height: var(--text-sm--line-height); -} -.sandbox-policy-settings > summary { - cursor: pointer; - color: var(--color-link); -} -.sandbox-policy-settings pre { - white-space: pre-wrap; - overflow-wrap: anywhere; -} .sandbox-policy-form { display: grid; gap: var(--space-3); @@ -5030,7 +5019,7 @@ html:has(#create-agent-form) { line-height: var(--text-sm--line-height); padding-inline: var(--space-2); } - .sandbox-denials-toolbar > input { + .sandbox-denials-toolbar > .form-field { flex-basis: 100%; max-width: none; } @@ -5128,3 +5117,332 @@ html:has(#create-agent-form) { display: none; } } + +/* Policy denial controls and application feedback. */ + +.sandbox-policy-header { + flex-wrap: wrap; + gap: var(--space-4); +} +.sandbox-policy-controls { + display: flex; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} +.sandbox-editing-control { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + white-space: nowrap; +} +.sandbox-editing-switch { + display: flex; + align-items: center; + position: relative; + gap: var(--space-2); + border: 0; + background: transparent; + padding-inline: 0; + box-shadow: none; + min-width: 4.5rem; +} +.sandbox-editing-switch::before { + content: ""; + display: inline-block; + width: 2.25rem; + height: 1.25rem; + border-radius: var(--radius-pill); + background: var(--color-text-muted); + box-shadow: inset 0 0 0 1px var(--color-border); +} +.sandbox-editing-switch::after { + content: ""; + position: absolute; + width: 0.875rem; + height: 0.875rem; + border-radius: var(--radius-pill); + margin-left: calc(var(--space-1) * 0.75); + background: var(--color-surface); + pointer-events: none; +} +.sandbox-editing-switch[aria-checked="true"]::before { + background: var(--color-success); +} +.sandbox-editing-switch[aria-checked="true"]::after { + margin-left: calc(var(--space-4) + var(--space-1) * 0.75); +} +.sandbox-editing-explanation { + color: var(--color-text-muted); +} +.sandbox-suggestions { + display: block; + padding: var(--space-4); +} +.sandbox-suggestions-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} +.sandbox-suggestions-copy h3 { + font-size: var(--text-md); + line-height: var(--text-md--line-height); +} +.sandbox-suggestions-copy .hint { + margin-top: var(--space-1); +} +.sandbox-suggestion-row { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); +} +.sandbox-suggestion-target { + min-width: 0; + overflow-wrap: anywhere; +} +.sandbox-suggestion-label { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} +.sandbox-suggestion-label .resource-id { + margin-top: 0; +} +.sandbox-observations { + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + padding: var(--space-1) var(--space-2); + white-space: nowrap; +} +.sandbox-policy-meta { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: var(--space-3) var(--space-4); + margin-top: var(--space-3); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-policy-rule-status { + display: flex; + align-items: center; + gap: var(--space-2); +} +.sandbox-policy-versions { + color: var(--color-text-muted); +} +.sandbox-policy-versions summary { + cursor: pointer; + color: var(--color-link); + padding-block: var(--space-1); +} +.sandbox-policy-versions[open] { + flex-basis: 100%; +} +.sandbox-executable-path { + font-size: var(--text-xs); + line-height: var(--text-xs--line-height); + color: var(--color-text-muted); + margin-top: var(--space-1); +} +.sandbox-executable-path summary { + cursor: pointer; +} +.sandbox-suggestion-details { + margin-top: var(--space-3); +} +.sandbox-readonly .sandbox-denial-select { + display: none; +} +.sandbox-readonly .sandbox-denial-row { + grid-template-columns: var(--space-5) 5rem 7.5rem minmax(0, 1.1fr) minmax(0, 1fr); +} +.sandbox-readonly .sandbox-decisions > .sandbox-table-heading > :first-child, +.sandbox-readonly .sandbox-decisions > .sandbox-table-heading > :last-child { + display: none; +} +.sandbox-readonly .sandbox-denial-row > :last-child:not(.sandbox-denial-reason) { + display: none; +} +@media (max-width: 760px) { + .sandbox-policy-header { + align-items: flex-start; + } + .sandbox-policy-controls { + gap: var(--space-3); + } + .sandbox-readonly .sandbox-denial-row { + grid-template-columns: var(--space-5) 6rem minmax(0, 1fr); + } + .sandbox-readonly .sandbox-decision-toggle { + grid-column: 1; + } + .sandbox-readonly .sandbox-denial-binary, + .sandbox-readonly .sandbox-denial-time { + grid-column: 2; + } + .sandbox-readonly .sandbox-denial-destination, + .sandbox-readonly .sandbox-denial-reason { + grid-column: 3; + } +} +@media (max-width: 520px) { + .sandbox-policy-controls { + width: 100%; + justify-content: space-between; + } + .sandbox-policy-controls > button { + font-size: var(--text-sm); + padding-inline: var(--space-3); + } + .sandbox-suggestions-head { + flex-wrap: wrap; + gap: var(--space-3); + } + .sandbox-suggestion-row { + align-items: flex-start; + flex-wrap: wrap; + } + .sandbox-readonly .sandbox-denial-row { + grid-template-columns: var(--space-5) minmax(0, 1fr) 5rem; + } + .sandbox-readonly .sandbox-denial-time { + grid-column: 3; + } + .sandbox-readonly .sandbox-denial-destination, + .sandbox-readonly .sandbox-denial-reason { + grid-column: 2 / -1; + } +} + +.sandbox-suggestions-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + margin-top: var(--space-3); +} +.sandbox-bulk-summary { + background: var(--color-info-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); +} +.sandbox-bulk-summary h3 { + margin: 0; + font-size: var(--text-lg); + line-height: var(--text-lg--line-height); +} +.sandbox-bulk-summary p { + margin-bottom: 0; +} +.sandbox-selection-breakdown { + margin-top: var(--space-3); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-selection-breakdown summary { + cursor: pointer; + color: var(--color-link); +} +.sandbox-selection-breakdown p { + overflow-wrap: anywhere; +} +.sandbox-application-feedback { + margin-top: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-info); + border-radius: var(--radius-sm); + background: var(--color-info-subtle); +} +.sandbox-application-feedback[data-tone="success"] { + border-left-color: var(--color-success); + background: var(--color-success-subtle); +} +.sandbox-application-feedback[data-tone="warning"] { + border-left-color: var(--color-warning); + background: var(--color-warning-subtle); +} +.sandbox-application-feedback h3 { + margin: 0; + font-size: var(--text-md); + line-height: var(--text-md--line-height); +} +.sandbox-application-feedback p { + margin-block: var(--space-2) 0; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-outcome-actions:empty { + display: none; +} +.sandbox-outcome-actions { + margin-top: var(--space-3); +} + +.sandbox-network-limits-link { + order: 2; + padding: var(--space-1) 0; + min-height: auto; + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-policy-versions { + order: 1; +} +.sandbox-network-limits-body > p:first-child { + margin-top: 0; +} +.sandbox-network-limits-dialog .dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} +.sandbox-network-limits-dialog .dialog-header h2 { + font-size: var(--text-xl); + line-height: var(--text-xl--line-height); +} +.sandbox-network-limits-dialog .dialog-header button { + color: var(--color-on-inverse); +} +.sandbox-limits-heading { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-5); +} +.sandbox-limits-heading h3 { + margin: 0; + font-size: var(--text-lg); + line-height: var(--text-lg--line-height); +} +.sandbox-network-limits-body .sandbox-rule-collection { + margin-top: var(--space-4); +} +.sandbox-network-limits-body .sandbox-rule-collection .sandbox-rule-row { + overflow-wrap: anywhere; +} + +.sandbox-dialog-change-status { + margin-right: auto; + color: var(--color-text-muted); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.sandbox-policy-dialog > .dialog-actions { + align-items: center; +} +@media (max-width: 520px) { + .sandbox-dialog-change-status { + flex-basis: 100%; + } +} diff --git a/docs/flows/agent-sandbox-policy.md b/docs/flows/agent-sandbox-policy.md index 4c4352f9a8..cbc9daed4b 100644 --- a/docs/flows/agent-sandbox-policy.md +++ b/docs/flows/agent-sandbox-policy.md @@ -1,7 +1,7 @@ --- created: 2026-10-15 -updated: 2026-10-25 -last_updated_session: authoring-run/b7994562-b460-4667-9017-4c62c3f56eb0 +updated: "2026-10-26" +last_updated_session: "authoring-run/0f3c367f-75d3-40f0-bcfa-3b649b78721f" --- # Agent sandbox policy flow @@ -164,7 +164,17 @@ before staging more grants. `ruleList` searches and pages explicit rules five at a time; row controls edit or remove local additions. `console/agents/sandbox-policy.mts:renderPolicy` places policy status and suggestions -above the log. `renderEditor` keeps proposed rules local until preview. Review shows additions, updates, and removals from the controller-validated rules, +above the log. It shows three suggestions initially and expands the remaining +candidates on request, independently of mutation authority. `openNetworkLimits` +uses the policy view's ceiling in a separate read-only dialog. Defaults and +version details stay in the main-page disclosure. The Network policy card also enables or disables Agent policy editing +through the separately authorized editing operation. Mutation feedback appears in +the card while the dialog is closed; a failed policy read retains its error and +read recovery action. `renderEditor` manages rules without an editing toggle and +keeps proposed rules local until preview. Its footer compares the draft with saved +additions, including removals and restored values, to offer review, application, +or Done. Saving a confirmed response closes the dialog and clears the completed +selection. Review shows additions, updates, and removals from the controller-validated rules, with the previously saved policy available for comparison. Save sends the complete validated array with the reviewed policy version. Superseded preview and read responses cannot overwrite a newer draft. Policy reads use a separate sequence: an earlier response @@ -178,7 +188,10 @@ unconfirmed save, only an explicit refresh started after the outcome can reopen Save alone leaves running enforcement unchanged. Applying a previously saved policy is disabled while local edits are unsaved. If application fails after saving, the alert identifies the saved version rather than implying -that the entire operation was rejected. +that the entire operation was rejected. `loadPolicy(true)` records explicit +inspection of a failed application. Refresh issues only the policy GET; a separate +Retry application action invokes the existing apply descriptor after inspection. +The action remains subject to current permissions, lifecycle, and draft guards. `console/agents/sandbox-policy.mts:mutate` displays rejected saves as focused alerts and scrolls the reason into view after rendering. Ceiling rejection @@ -211,7 +224,9 @@ the policy read, only the network policy card states it; the suggestions card is - [Console batch browser tests](../../tests/browser/console-policy-denials.test.ts) cover selection across pages, stable Follow, deduplication, preservation of saved rules, and ceiling rejection through real controller routes. They use in-memory - storage and test Drivers with simulated log input, not provider enforcement. + storage and test Drivers with simulated log input. A simulated failed-application + view covers explicit inspection and retry against the real API refusal; neither + case proves provider enforcement. ## Related docs @@ -224,6 +239,8 @@ the policy read, only the network policy card states it; the suggestions card is ## Changelog +- 2026-10-26 10:49: Combine the accompanying policy UX controls with upstream permission hints, unavailable Sandbox log handling, and application failure explanations. (authoring-run/0f3c367f-75d3-40f0-bcfa-3b649b78721f - e89a4e37a0b3dff75739cf6988028f81cd96f5ac) + - 2026-10-25: Hide the empty denial rows and state a refused policy read once. (finding 2522) - 2026-10-25: Hide the denial window's controls on the draft. (finding 2509) - 2026-10-25: Skip the policy and suggestion reads that the permission answer refuses. (finding 2454) @@ -234,6 +251,10 @@ the policy read, only the network policy card states it; the suggestions card is - 2026-10-22 23:45: Preserve CLI output guidance alongside upstream grant admission rules after conflict resolution. (authoring-run/01a10a81-070c-7aa3-97d0-fbce8f285d4d - c574fc743b21599c51d3809365039010a7e077d2) +- 2026-10-23 16:20: Trace the accompanying compact policy controls, suggestion disclosure, independent ceiling dialog, contextual footer, and explicit retry after status inspection. (authoring-run/2d64e0f2-543e-4fee-bc9d-0ed8f2d48f5b - a8575405cf9fbd1779b2daa1fab96a88d584cc7c) + +- 2026-10-23 14:55: Trace the accompanying move of the policy-editing control to the page, visible mutation feedback, and read recovery. (authoring-run/71e6c3c8-7b57-42ff-bc6c-37f2506f2da5 - a8575405cf9fbd1779b2daa1fab96a88d584cc7c) + - 2026-10-22 16:59: Trace the accompanying console changes for paged denials, batch proposals, rule management, and review before saving. (authoring-run/cd2ce6a3-4507-46cc-b936-68c9862c794b - 0c73a86a6cdd92f0129c8e52e0f230517490bd46) - 2026-10-22 13:40: Run the shared grant rules before a Driver contribution, at every admission (preview, save, apply, deploy) and the worker recheck; empty or reversed ranges never satisfy the ceiling. (bughunt-1022c) diff --git a/docs/guides/topics/agent-sandbox-policy.md b/docs/guides/topics/agent-sandbox-policy.md index f64ee90635..91b6170cc6 100644 --- a/docs/guides/topics/agent-sandbox-policy.md +++ b/docs/guides/topics/agent-sandbox-policy.md @@ -6,9 +6,12 @@ observed denials. A version that runs without a Sandbox records none, and the ta says so. Policy reads and changes have independent permissions; see the [policy reference](../../reference/sandbox-policies.md). -1. Check the **Network policy** card above the log. It shows the saved version, - last confirmed live version, and any suggested rules. Suggestions are untrusted - candidates; **Review suggestion** opens a local proposal. +1. Check the **Network policy** card above the log. **Policy details** shows saved + and confirmed live versions and read-only Installation defaults. **View network + limits** opens the Installation ceiling, which grants no access by itself. + Suggestions are untrusted candidates. The card shows three at a time; **Show all** + expands the list. Expand **Full executable path** before reviewing a suggestion. + With editing off, **View suggestions** still lets you inspect candidates. 2. Read the denial count and any gap notices. The log starts with five rows per page. Search the loaded window or use **Next** to inspect more records. Expand a row for decision metadata and its sanitized log. Missing fields stay unknown. @@ -20,10 +23,13 @@ says so. Policy reads and changes have independent permissions; see the current page. Exact executable, host, and port requests become proposed rules. Repeated requests and requests covered by saved or proposed rules do not add duplicate rules. Incomplete metadata cannot prefill a rule. -4. To manage rules manually, choose **Manage rules**. Search the list or page - through five rules at a time. Use **Add rule** or a row's menu to edit or remove - a rule. These changes stay local. Under **Policy settings and Installation - limits**, check the delegation ceiling and read-only defaults. +4. Administrators can use the **Policy editing** switch beside **Manage rules**. + Its **On** or **Off** label states the current setting. Disabling editing preserves + saved rules. **View rules** remains available with editing off. To change rules, + enable editing and choose **Manage rules**. Search or page through five rules at + a time. Use **Add rule** or a row's menu to edit or remove a rule. These changes + stay local. The dialog footer offers **Review changes** when the draft changes; + **Done** closes without saving and retains the proposal on this page. 5. Select **Review changes** after editing a rule. A batch opens this review directly. Check the rules to add, update, or remove. The preview shows field errors without saving or applying. **Back to editing** retains the proposal. @@ -33,11 +39,14 @@ says so. Policy reads and changes have independent permissions; see the A successful tool result provides the final execution evidence. If the Console cannot confirm which rules were saved, open **Manage rules**, select **Refresh policy**, and inspect the saved version before applying it. -7. To apply a policy saved earlier, open **Manage rules** and select **Apply saved - policy to current revision**. This control is unavailable while your proposal - has unsaved edits. When live application is unsupported, use **Deploy new - version** explicitly. Deployment requires deploy permission and can interrupt - the Agent. +7. To apply a policy saved earlier, choose **Apply saved rules** on the card or + in the **Manage rules** footer. This action is unavailable while the proposal + has unsaved edits. **Applying…** waits for provider confirmation. If application + is unconfirmed, use **Refresh status** to read the outcome without submitting + another request. If the refreshed application still failed, **Retry application** + offers a separate explicit request to authorized users. When live application + is unsupported, use **Deploy new version** explicitly. Deployment requires deploy + permission and can interrupt the Agent. The schema supplies field descriptions and constraints. Shapes without a form use a schema-aware JSON editor. A preview for an older draft or policy version diff --git a/docs/reference/sandbox-policies.md b/docs/reference/sandbox-policies.md index 35cc36cc89..68d0f20761 100644 --- a/docs/reference/sandbox-policies.md +++ b/docs/reference/sandbox-policies.md @@ -160,6 +160,18 @@ policy preserves existing rules and passes the normal preview, save, and apply admission. Selecting denials grants no access until the user reviews and saves. **Manage rules** searches and pages explicit rules, five at a time, with separate controls to edit or remove a rule. Installation defaults stay read-only. +The **Policy editing** switch sits beside **Manage rules**. With editing off, +**View rules**, suggestions, and **View network limits** remain available for +inspection; mutation controls are hidden. The ceiling has a separate read-only +dialog. **Policy details** on the page shows versions and Installation defaults. +Suggestions initially show three entries, with an explicit action to show the rest. +Their executable basenames have expandable full paths. Bulk review shows selected +record and new-rule counts, repeated requests, and already-covered requests. +The rule dialog footer offers review for unsaved changes, application for saved +changes, and **Done** when no action remains. Save feedback distinguishes saved, +pending, confirmed, and unconfirmed outcomes. **Refresh status** only reads; +**Retry application** requires an explicit refresh of the failed application and +rechecks application authority. Neither action starts a deployment. Provider suggestions use a separate authorized and audited read. OpenShell's `GetDraftPolicy` returns aggregated chunks, including its lifecycle and observation diff --git a/scripts/console-storybook/pages-agent-detail.stories.ts b/scripts/console-storybook/pages-agent-detail.stories.ts index 5f4765293b..6f1029e43f 100644 --- a/scripts/console-storybook/pages-agent-detail.stories.ts +++ b/scripts/console-storybook/pages-agent-detail.stories.ts @@ -221,3 +221,11 @@ export const ConfigurationRefreshRace = story("policyConfigurationRefresh"); export const ConfigurationJsonFallback = story("policyConfigurationJson"); export const ConfigurationOwnerDenied = story("policyConfigurationHidden"); + +export const PolicySuggestionsExpanded = story("policySuggestionsExpanded"); +export const PolicySavedForLater = story("policySavedForLater"); +export const PolicyApplying = story("policyApplying"); +export const PolicyApplied = story("policyApplied"); +export const PolicyRetryApplication = story("policyRetryApplication"); +export const PolicyEditingAdminDenied = story("policyEditingAdminDenied"); +export const PolicyNoNetworkLimits = story("policyNoNetworkLimits"); diff --git a/scripts/console-storybook/policy-workflow.md b/scripts/console-storybook/policy-workflow.md index ea0b88307e..da51c3ef33 100644 --- a/scripts/console-storybook/policy-workflow.md +++ b/scripts/console-storybook/policy-workflow.md @@ -18,10 +18,14 @@ Use **Pages/Agent detail → Policy Denials Many** for 200 simulated denials and rules at a time. Use a row menu to edit or remove a rule. **Add rule** opens the schema editor. **Back to rules** retains an incomplete draft for correction. 5. Review again and choose **Save for later**. The saved version increases; live - access stays unchanged. **Apply saved policy to current revision** requests + access stays unchanged. **Apply saved rules** requests application separately and is unavailable while the proposal has unsaved changes. 6. In **Policy Rules Populated**, open **Manage rules** on a fresh preview. Check ten rules across two pages, search for `allowed-10`, and inspect the row menu. + Confirm the dialog has no editing toggle. Close it and turn **Policy editing** + off in the Network policy card. Open **View rules**: all ten rules remain and + **Add rule** is unavailable. Close the dialog, turn **Policy editing** on, + and confirm **Add rule** becomes available again. Repeat at phone width and confirm the modal scrolls with its footer visible. 7. In **Policy Denials**, choose **Allow this** or **Review suggestion**, review the proposal, and **Save and apply to current revision**. Wait for pending to @@ -32,7 +36,7 @@ Use **Pages/Agent detail → Policy Denials Many** for 200 simulated denials and Confirm the error receives focus inside the modal. The draft remains editable; the saved policy and live access stay unchanged. 10. In **Policy Denials Protected**, open **Manage rules**. Confirm the protected - execution notice is visible, adding and editing are disabled, and **Remove + execution notice is visible, adding and editing controls are hidden, and **Remove saved network rules** opens review. **Save for later** clears the rules; live application stays disabled. 11. In **Policy Incomplete Preview**, review a suggestion. The unreadable preview @@ -40,23 +44,47 @@ Use **Pages/Agent detail → Policy Denials Many** for 200 simulated denials and use the combined action and refresh before making another change. In **Unicode executable path remains complete**, copy the full path from the -Installation delegation ceiling. Select **Review suggestion**, then paste that -path into **Executable path**. Confirm it keeps the final `/tool`. The path has +**View network limits** on the page, then close the limits dialog. Select +**Review suggestion**, then paste that path into **Executable path**. Confirm it keeps the final `/tool`. The path has 281 Unicode code points, 551 UTF-16 code units, and 1091 UTF-8 bytes: six directory components of 45 Deseret letters (`U+10400`) each, followed by `/tool`. Select -**Review changes**, then **Save**. Confirm the complete path appears in review +**Review changes**, then **Save for later**. Confirm the complete path appears in review and saved policy v2, and the current revision stays unchanged. Reset to repeat. This scenario simulates the API; it does not verify an executable or policy application. Compare empty, disabled, unsupported, gap, missing-metadata, permission-denied, -loading, unavailable-credential, and unconfirmed-application stories. Manual +loading, unavailable-credential, and unconfirmed-application stories. In **Policy +Denials Disabled**, enable editing from the page's **Network policy** card before +opening **Manage rules**. The control shows the current editing status. Manual editing remains available without logs or suggestions. The unsupported story -shows one sentence and no editor. A permission refusal offers no retry. Inspect +shows one sentence and no editor. In **Policy Denials Edit Denied**, try turning **Policy editing** off and confirm the refusal is visible and focused on the page. +Refused policy reads and suggestions offer no retry. Inspect screenshots and record the final interactions outside the checkout. These fixtures prove simulated UI behavior. Browser tests in `tests/browser/console-policy-denials.test.ts` exercise real controller admission, -save, and application requests with in-memory storage and test Drivers; only their -log input is simulated. Use the OpenShell real-runtime lane for provider and +save, and application requests with in-memory storage and test Drivers. Logs and +the failed-application presentation are simulated; the retry reaches real admission +and checks its refusal for the fixture’s pending application. Use the OpenShell real-runtime lane for provider and runtime enforcement proof. + +Use **Policy Suggestions Expanded** to inspect three of twelve candidates, show +all, show fewer, and expand full paths. **Policy Denials Disabled** keeps +suggestions inspectable through **View suggestions**. Open **View network limits** +on the main page with editing on and off; search and page the read-only ceiling. +**Policy No Network Limits** explains that no additional access is delegated. +The rule dialog contains neither limits nor policy details. + +In **Policy Rules Populated**, the clean footer shows **No unsaved changes** and +**Done**. Edit and restore a hostname: the clean state returns. Remove a rule: +**Review changes** becomes the primary footer action and **Add rule** is secondary. +Review and save for later, reopen the dialog, and choose **Apply saved rules**. +The footer stays visible on desktop, tablet, and phone. + +Compare **Policy Saved For Later**, **Policy Applying**, **Policy Applied**, and +**Policy Retry Application**. In the last story, **Refresh status** must issue +only a GET, keep the unconfirmed warning, and expose **Retry application**. +Only that separate action requests application. **Policy Editing Admin Denied** +shows the real control with a simulated administrative refusal, without claiming +that the API provides an administrative permission hint. diff --git a/scripts/console-storybook/public/fixtures.mjs b/scripts/console-storybook/public/fixtures.mjs index 98f8a3a5e5..9bebe2c853 100644 --- a/scripts/console-storybook/public/fixtures.mjs +++ b/scripts/console-storybook/public/fixtures.mjs @@ -465,6 +465,7 @@ export function installFixture(scenario, evidence) { : {}), harness: { id: harnessId, version: "demo", mode: owner.executionMode }, compute: { id: "kubernetes-demo", implementation: "kubernetes" }, + ...(scenario.policyState ? { sandboxDriverId: "sandbox-story" } : {}), servicePrincipalId: owner.servicePrincipalId, ...(owner.repositoryBindings?.length ? { @@ -759,6 +760,30 @@ export function installFixture(scenario, evidence) { appliedAt: createdAt, }; } + const policyCeiling = scenario.policyCeiling ?? [ + policyGrant, + ...(scenario.policyDenialCount + ? [{ ...policyGrant, id: "observed-services-ceiling", host: "*.example.org" }] + : []), + ...sandboxPolicy.additions, + ...(scenario.policySuggestions ?? []).map((entry) => entry.grant), + ]; + if (scenario.policyInitialOutcome) { + sandboxPolicy.version = 2; + if (scenario.policyInitialOutcome !== "saved") { + sandboxPolicy.application = { + id: "00000000-0000-4000-8000-000000000009", + revisionId: candidateRevisionId, + policyVersion: 2, + state: scenario.policyInitialOutcome, + requestedBy: "identity_demo", + requestedAt: createdAt, + }; + } + if (scenario.policyInitialOutcome === "applied") { + sandboxPolicy.applied.policyVersion = 2; + } + } let policyNewRead = false; if (scenario.policyState === "failed" || scenario.policyState === "failedCeiling") { sandboxPolicy.application = { @@ -1587,7 +1612,11 @@ export function installFixture(scenario, evidence) { // Simulate another operator saving between reads; HTTP workflow coverage uses real saves. sandboxPolicy.version = read; } - if (sandboxPolicy.application?.state === "pending" && ++policyPolls > 1) { + if ( + sandboxPolicy.application?.state === "pending" && + !scenario.policyKeepPending && + ++policyPolls > 1 + ) { sandboxPolicy.application.state = "applied"; sandboxPolicy.application.providerVersion = "5"; sandboxPolicy.applied = { @@ -1602,7 +1631,7 @@ export function installFixture(scenario, evidence) { configuration: policyDescriptor, editingEnabled: policyEditing, protectedExecution: scenario.policyState === "protected", - ceiling: [policyGrant], + ceiling: policyCeiling, defaults: scenario.policyState === "unsupported" ? null @@ -1633,6 +1662,9 @@ export function installFixture(scenario, evidence) { suffix === "/sandbox-policy/networking/editing" && method === "PATCH" ) { + if (scenario.policyAdminDenied) { + return error(403, "AUTHORIZATION_DENIED"); + } policyEditing = body.enabled; return response({ ...agent, sandboxPolicyEditing: policyEditing }); } @@ -1701,7 +1733,8 @@ export function installFixture(scenario, evidence) { } return response({ recommendations: - scenario.policyState === "empty" + scenario.policySuggestions ?? + (scenario.policyState === "empty" ? [] : [ { @@ -1713,7 +1746,7 @@ export function installFixture(scenario, evidence) { : policyGrant, observations: 4, }, - ], + ]), truncated: false, }); } diff --git a/scripts/console-storybook/public/scenarios.mjs b/scripts/console-storybook/public/scenarios.mjs index c7ce344259..ca0d5ad591 100644 --- a/scripts/console-storybook/public/scenarios.mjs +++ b/scripts/console-storybook/public/scenarios.mjs @@ -8668,6 +8668,9 @@ for (const [id, state, name] of [ ...(state === "discoveryDenied" ? { rules: [{ suffix: "/driver-configurations", status: 403 }] } : {}), + ...(state === "editDenied" + ? { rules: [{ method: "PATCH", suffix: "/sandbox-policy/networking/editing", status: 403 }] } + : {}), ...(state === "stalePreview" ? { rules: [{ suffix: "/validate", delayMs: 3000 }] } : {}), description: "Observed denial records, aggregated provider suggestions, and governed policy additions remain distinct.", @@ -8675,7 +8678,7 @@ for (const [id, state, name] of [ "Expand a denial to inspect the sanitized log. Review its exact executable and endpoint before saving an addition.", "Select denial rows across pages, then Allow selected. Confirm the validated proposal retains existing rules and combines repeated executable/host/port requests. Back to editing preserves the draft.", "Open Manage rules. Search the five-row pages, use a row menu to edit or remove a rule, or Add rule. Compare Save for later with Save and apply to current revision. Apply saved policy is unavailable while a proposal has unsaved edits.", - "Disable policy editing and confirm approved grants remain visible. Check the empty, denied, unsupported, missing metadata, and unconfirmed stories.", + "Use the Policy editing switch in the Network policy card on the page. Open Manage rules and confirm the editing toggle is absent, saved rules remain, and Add rule follows the editing setting. Close the dialog to re-enable editing. Check the empty, denied, unsupported, missing metadata, and unconfirmed stories.", ], gap: "Simulated UI proof only. These fixtures do not prove authorization, persistence, enforcement, or provider policy activation.", ...(state === "unicode" @@ -8683,8 +8686,8 @@ for (const [id, state, name] of [ description: "A normalized executable path has 281 Unicode code points and 551 UTF-16 code units, within the API schema's 512-code-point limit.", steps: [ - "Copy the full executable path from Installation delegation ceiling. Choose Review suggestion, then paste the copied path into Executable path. Confirm the value still ends with /tool.", - "Choose Review changes. Confirm the complete executable path, including /tool, appears in Proposed rules. Choose Save and confirm policy v2 keeps that same path while the current revision stays unchanged.", + "Open View network limits and copy the full executable path from the Installation ceiling. Close the dialog, choose Review suggestion, then paste the copied path into Executable path. Confirm the value still ends with /tool.", + "Choose Review changes. Confirm the complete executable path, including /tool, appears in Proposed rules. Choose Save for later and confirm policy v2 keeps that same path while the current revision stays unchanged.", "Reset the story to repeat. The six directory components each contain 45 Deseret letters (U+10400); the path is 1091 UTF-8 bytes.", ], gap: "Simulated UI proof only. This story does not validate a real executable, save to OCC, apply OpenShell policy or start a workload.", @@ -8785,3 +8788,53 @@ for (const [id, source, name, theme] of [ "Deployment branding from the production theme loader. Resource, session, and runtime states are simulated.", }; } + +const policySuggestions = Array.from({ length: 12 }, (_, index) => ({ + id: `suggested-${index}`, + status: "pending", + observations: 12 - index, + grant: { + id: `suggested-rule-${index}`, + binary: "/usr/bin/python3.11", + host: `service-${index + 1}.example.org`, + ports: [{ from: 443, to: 443 }], + }, +})); +for (const [id, outcome, name] of [ + ["policySuggestionsExpanded", undefined, "Twelve suggestions with compact review"], + ["policySavedForLater", "saved", "Saved rules awaiting application"], + ["policyApplying", "pending", "Waiting for Sandbox confirmation"], + ["policyApplied", "applied", "Confirmed running policy"], + ["policyRetryApplication", "failed", "Inspect then retry unconfirmed application"], +]) { + scenarios[id] = { + ...scenarios.policyRulesPopulated, + name, + policySuggestions, + policyInitialOutcome: outcome, + policyKeepPending: outcome === "pending", + policyNewDenials: 0, + steps: [ + "Inspect three suggestions, show all twelve, then show fewer. Expand a full executable path.", + "Open View network limits directly from the page. The ceiling is read-only and grants no access.", + "Manage rules: inspect five at a time. Remove a rule and review through the fixed footer. Done preserves the local proposal without saving.", + "For failed application, Refresh status reads without applying. Retry application is a separate explicit action after inspection.", + ], + }; +} +scenarios.policyDenialsDisabled = { + ...scenarios.policyDenialsDisabled, + policyRuleCount: 10, + policySuggestions, +}; +scenarios.policyEditingAdminDenied = { + ...scenarios.policyDenialsDisabled, + name: "Editing administration refused", + policyAdminDenied: true, +}; +scenarios.policyNoNetworkLimits = { + ...scenarios.policyDenialsDisabled, + name: "No additional network access delegated", + policyRuleCount: 0, + policyCeiling: [], +}; diff --git a/tests/browser/console-agent-draft-guards.test.mjs b/tests/browser/console-agent-draft-guards.test.mjs index 98b5e65cf1..de981c57b3 100644 --- a/tests/browser/console-agent-draft-guards.test.mjs +++ b/tests/browser/console-agent-draft-guards.test.mjs @@ -123,6 +123,7 @@ test("Agent policy ceiling rejection is announced in view and preserves unsaved // The list names the setting as a dotted path, as the API's refusals do, never a JSON pointer // (finding 2538). await page + .locator(".sandbox-policy-form") .getByText(`Configuration setting additions[0]: ${GRANT_EXCEEDS_CEILING}`, { exact: true }) .waitFor(); assert.equal(await page.getByText(/^\/additions/).count(), 0); @@ -239,7 +240,7 @@ test("A refused policy editing change says who can change it", async (t) => { response.url().endsWith(`/agents/${agent.id}/sandbox-policy/networking/editing`) && response.request().method() === "PATCH", ); - await tab.getByRole("button", { name: "Disable policy editing", exact: true }).click(); + await tab.getByRole("switch", { name: "Policy editing", checked: true }).click(); assert.equal((await refused).status(), 403); await tab.getByText(POLICY_EDITING_SETTING_REFUSED, { exact: true }).waitFor(); }); @@ -334,7 +335,7 @@ test("A policy read grant after the page loaded shows the policy on the first vi role.permissions.push({ action: "read", resourceKind: "sandbox_policy" }); await page.getByRole("tab", { name: "Policy denials", exact: true }).click(); const tab = page.locator(".sandbox-policy"); - await tab.getByText(/^Policy v\d+ · Editing/).waitFor(); + await tab.getByText("0 explicit rules", { exact: true }).waitFor(); await settlePageRequests(page); assert.equal(await tab.getByText(/Your access does not include reading/).count(), 0); assert.equal( @@ -363,24 +364,17 @@ test("Policy denials on the draft says where denials and suggestions come from", } assert.equal(await tab.getByRole("checkbox", { name: "Follow policy denials" }).count(), 0); assert.equal(await tab.getByRole("searchbox", { name: "Filter loaded denials" }).count(), 0); - // No empty band where the rows would be: only the grid gap and the two elements' own margins - // separate the reason from the suggestions card (finding 2522). - assert.equal(await tab.locator(".sandbox-decisions").isVisible(), false); - const band = await tab.evaluate((section) => { - const style = (node) => globalThis.getComputedStyle(node); - const reason = [...section.children].find((node) => - node.textContent?.startsWith("Policy denials come from deployed versions."), - ); - const card = section.querySelector(":scope > .agent-card"); - return { - expected: - Number.parseFloat(style(section).rowGap) + - Number.parseFloat(style(reason).marginBottom) + - Number.parseFloat(style(card).marginTop), - space: card.getBoundingClientRect().top - reason.getBoundingClientRect().bottom, - }; - }); - assert.ok(Math.abs(band.space - band.expected) < 1, JSON.stringify(band)); + // Suggestions now precede the log. With no deployed source, the log retains only its + // heading and explanation; hidden window controls must not reserve space below it. + for (const selector of [ + ".sandbox-decisions", + ".sandbox-denials-toolbar", + ".sandbox-log-pager", + ".sandbox-window-details", + ]) { + const node = tab.locator(selector); + assert.equal(await node.isVisible(), false, selector); + } }; await tab .getByText("Policy denials come from deployed versions. This Agent has no current version.") @@ -431,9 +425,10 @@ test("Agent policy proposals save separately or save then apply the newly saved await page.getByRole("spinbutton", { name: "End port", exact: true }).fill("443"); assert.equal( await page - .getByRole("button", { name: "Apply saved policy to current revision", exact: true }) - .isDisabled(), - true, + .getByRole("dialog") + .getByRole("button", { name: "Apply saved rules", exact: true }) + .count(), + 0, ); // Delay a real successful preview while the user edits; the old response must not replace the draft. const preview = await holdRequests(t, page, `${fixture.origin}${base}/validate`, { @@ -449,7 +444,7 @@ test("Agent policy proposals save separately or save then apply the newly saved ); preview.release(); await previewResponse; - await page.getByText("Draft changed. Review again before saving.", { exact: true }).waitFor(); + await page.getByText(/rule change to review/).waitFor(); assert.equal(await page.getByRole("button", { name: "Save for later", exact: true }).count(), 0); assert.equal( await page.getByRole("textbox", { name: "Rule ID", exact: true }).inputValue(), @@ -478,6 +473,9 @@ test("Agent policy proposals save separately or save then apply the newly saved // The combined action must submit the accepted version, rather than applying the // old saved version while silently leaving a local proposal behind. + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByLabel("Actions for network rule 1", { exact: true }).click(); + await page.getByRole("button", { name: "Edit rule", exact: true }).click(); await page.getByRole("textbox", { name: "Rule ID", exact: true }).fill("python-reviewed"); await page.getByRole("button", { name: "Review changes", exact: true }).click(); await page @@ -518,7 +516,8 @@ test("Agent policy proposals preserve valid Unicode executable paths through rev const { page } = await newPage(t, fixture); const requests = apiRequests(page, fixture.origin); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, "draft", "policy")); - await page.getByRole("button", { name: "Add Network rules", exact: true }).click(); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByRole("button", { name: "Add rule", exact: true }).click(); await page.getByRole("textbox", { name: "Rule ID", exact: true }).fill(grant.id); await page.getByRole("textbox", { name: "Destination hostname", exact: true }).fill(grant.host); await page.getByRole("button", { name: "Add Port ranges", exact: true }).click(); @@ -548,7 +547,7 @@ test("Agent policy proposals preserve valid Unicode executable paths through rev const saved = page.waitForResponse( (response) => response.request().method() === "PUT" && response.url().endsWith(base), ); - await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("button", { name: "Save for later", exact: true }).click(); assert.equal((await saved).status(), 200); await page.getByText(/Policy v2 saved\. The current revision is unchanged\./).waitFor(); const persisted = (await fixture.request("GET", base)).data.policy; @@ -568,6 +567,9 @@ test("Agent policy proposals preserve valid Unicode executable paths through rev // Keep the schema owner's limit: exceeding 512 code points is an explicit // validation refusal, not a truncated input or a second saved policy. const tooLong = grant.binary + "a".repeat(232); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); + await page.getByLabel("Actions for network rule 1", { exact: true }).click(); + await page.getByRole("button", { name: "Edit rule", exact: true }).click(); await executable.click(); await executable.press("ControlOrMeta+A"); await page.keyboard.insertText(tooLong); @@ -634,13 +636,13 @@ test("Agent policy under protected execution offers only removal of saved networ .waitFor(); // Neither adding a rule nor widening a saved rule is available under protected execution. assert.equal( - await page.getByRole("button", { name: "Add rule", exact: true }).isDisabled(), - true, + await page.getByRole("button", { name: "Add rule", exact: true }).isVisible(), + false, + ); + assert.equal( + await page.getByLabel("Actions for network rule 1", { exact: true }).isVisible(), + false, ); - await page.getByLabel("Actions for network rule 1", { exact: true }).click(); - for (const name of ["Edit rule", "Remove rule"]) { - assert.equal(await page.getByRole("button", { name, exact: true }).isDisabled(), true, name); - } const preview = page.waitForResponse( (response) => response.request().method() === "POST" && response.url().endsWith(`${base}/validate`), @@ -695,8 +697,8 @@ test("Agent policy under protected execution offers only removal of saved networ 0, ); assert.equal( - await page.getByRole("button", { name: "Add rule", exact: true }).isDisabled(), - true, + await page.getByRole("button", { name: "Add rule", exact: true }).isVisible(), + false, ); }); @@ -800,7 +802,7 @@ test("overlapping policy refreshes retain the newest read and continue pending p const requests = apiRequests(page, fixture.origin); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); await page.getByRole("button", { name: "Manage rules", exact: true }).click(); - await page.getByText("Matches saved policy v1.", { exact: false }).waitFor(); + await page.getByText("Saved rules awaiting application", { exact: false }).waitFor(); const url = `${fixture.origin}${base}`; const first = await holdRequests(t, page, url, { once: true, method: "GET", afterServer: true }); await page.getByRole("button", { name: "Refresh policy", exact: true }).click(); @@ -825,7 +827,7 @@ test("overlapping policy refreshes retain the newest read and continue pending p const secondResponse = page.waitForResponse((response) => response.url() === url); second.release(); await secondResponse; - await page.getByText("Matches saved policy v2.", { exact: false }).waitFor(); + await page.getByText("Saved rules awaiting application", { exact: false }).waitFor(); await page.getByLabel("Actions for network rule 1", { exact: true }).click(); await page.getByRole("button", { name: "Edit rule", exact: true }).click(); assert.equal( @@ -834,7 +836,8 @@ test("overlapping policy refreshes retain the newest read and continue pending p ); await page - .getByRole("button", { name: "Apply saved policy to current revision", exact: true }) + .getByRole("dialog") + .getByRole("button", { name: "Apply saved rules", exact: true }) .click(); await page.getByText(/Application to the current revision is pending/).waitFor(); const reads = pathRequests(requests, "GET", base).length; @@ -879,10 +882,12 @@ for (const c of [ ); const { page } = await newPage(t, fixture); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); - await page.getByText("Matches saved policy v2.", { exact: false }).waitFor(); + await page.getByRole("button", { name: "Manage rules", exact: true }).click(); await page - .getByRole("button", { name: "Apply saved policy to current revision", exact: true }) + .getByRole("dialog", { name: "Manage network rules", exact: true }) + .getByRole("button", { name: "Apply saved rules", exact: true }) .click(); + await page.getByRole("button", { name: "Done", exact: true }).click(); await page.getByText(/Application to the current revision is pending/).waitFor(); await page.route(`${fixture.origin}${base}`, async (route) => { if (route.request().method() !== "GET") { @@ -897,11 +902,12 @@ for (const c of [ await route.fulfill({ response, json: body }); }); // The pending application's poll reads the failure. - const notice = page.locator(".notice", { hasText: c.text }); + const notice = page.locator(".sandbox-application-feedback").getByText(c.text, { exact: true }); await notice.waitFor(); assert.equal(await notice.textContent(), c.text); - // The code stays on the status line beside the failed state. - await page.getByText(`Application: failed · ${c.reason} ·`, { exact: false }).waitFor(); + // The explanation stays prominent; the full reason code is available in Policy details. + await page.getByText("Policy details", { exact: true }).click(); + await page.getByText(new RegExp(`Application: failed .*${c.reason}`)).waitFor(); }); } diff --git a/tests/browser/console-policy-denials.test.ts b/tests/browser/console-policy-denials.test.ts index 6b66e1595e..ae4494c69f 100644 --- a/tests/browser/console-policy-denials.test.ts +++ b/tests/browser/console-policy-denials.test.ts @@ -79,6 +79,114 @@ async function logWindow( ); } +test("the Policy denials page controls policy editing and disabling preserves saved rules", async (t) => { + const { fixture, namespace, agent, base } = await createPolicyConsole(t, grants); + assert.equal( + (await fixture.request("PUT", base, { body: { expectedVersion: 1, additions: [grants[0]] } })) + .status, + 200, + ); + assert.equal( + (await fixture.request("PATCH", `${base}/editing`, { body: { enabled: false } })).status, + 200, + ); + const { page } = await newPage(t, fixture); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, undefined, "policy")); + const card = page.locator(".sandbox-network-card"); + // Editing can be enabled from the page before opening the rule-management dialog. + const enable = card.getByRole("switch", { name: "Policy editing", checked: false }); + await enable.waitFor({ state: "visible", timeout: 3000 }); + await card.getByRole("button", { name: "View network limits", exact: true }).click(); + const limits = page.getByRole("dialog", { name: "Network limits", exact: true }); + await limits.getByText("Installation ceiling", { exact: true }).waitFor(); + await limits.getByText(grants[0]!.binary, { exact: true }).first().waitFor(); + assert.equal(await limits.getByRole("button", { name: "Add rule", exact: true }).count(), 0); + await limits.getByRole("button", { name: "Done", exact: true }).click(); + assert.equal( + await card + .getByRole("button", { name: "View network limits", exact: true }) + .evaluate((node) => node === document.activeElement), + true, + ); + assert.equal(await page.getByLabel("Select page", { exact: true }).isVisible(), false); + await card.getByRole("button", { name: /^(Manage|View) rules$/ }).click(); + const dialog = page.getByRole("dialog", { name: /^(Manage|View) network rules$/ }); + assert.equal(await dialog.getByRole("switch", { name: "Policy editing" }).count(), 0); + assert.equal( + await dialog.getByRole("button", { name: "Add rule", exact: true }).isVisible(), + false, + ); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + await enable.click(); + const disable = card.getByRole("switch", { name: "Policy editing", checked: true }); + await disable.waitFor(); + assert.equal((await fixture.request("GET", base)).data.editingEnabled, true); + await card.getByRole("button", { name: /^(Manage|View) rules$/ }).click(); + assert.equal( + await dialog.getByRole("button", { name: "Add rule", exact: true }).isEnabled(), + true, + ); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + await disable.click(); + await enable.waitFor(); + const disabled = (await fixture.request("GET", base)).data; + assert.equal(disabled.editingEnabled, false); + assert.equal(disabled.policy.version, 2); + assert.deepEqual(disabled.policy.additions, [grants[0]]); + const reloadPolicy = page.waitForResponse( + (response) => response.request().method() === "GET" && response.url() === fixture.origin + base, + ); + await page.reload(); + const reloaded = await reloadPolicy; + assert.equal(reloaded.status(), 200, await reloaded.text()); + await enable.waitFor(); + await card.getByRole("button", { name: /^(Manage|View) rules$/ }).click(); + assert.equal( + await dialog.getByRole("button", { name: "Add rule", exact: true }).isVisible(), + false, + ); + await dialog.getByText("Agent-specific rules · 1", { exact: true }).waitFor(); + await dialog.getByRole("button", { name: "Done", exact: true }).click(); + + // A successful toggle followed by a failed read cannot establish the new editing state. + // Drop the read response after the real controller answers; recover through an explicit read. + await page.route(fixture.origin + base, async (route) => { + const response = await route.fetch(); + assert.equal(response.status(), 200); + await route.fulfill({ + status: 503, + json: { error: { code: "SERVICE_UNAVAILABLE", message: "Policy read interrupted" } }, + }); + }); + await enable.click(); + await card.getByRole("alert").waitFor(); + const retry = card.getByRole("button", { name: "Retry policy read", exact: true }); + await retry.waitFor(); + assert.equal((await fixture.request("GET", base)).data.editingEnabled, true); + assert.equal(await card.getByRole("switch", { name: "Policy editing" }).count(), 0); + await page.unroute(fixture.origin + base); + await retry.click(); + await disable.waitFor(); + await disable.click(); + await enable.waitFor(); + + // Administration can be revoked after the page read. The real IAM refusal must remain + // visible and focused on the page, with the saved editing setting unchanged. + fixture.policy.restrictions.push({ + id: "deny-policy-editing-administration", + namespaceId: namespace.id, + resourceKind: "agent", + resourceId: agent.id, + action: "administer", + effect: "deny", + }); + await enable.click(); + const alert = card.getByRole("alert"); + await alert.waitFor(); + assert.equal(await alert.evaluate((node) => node === document.activeElement), true); + assert.equal((await fixture.request("GET", base)).data.editingEnabled, false); +}); + test("denial selection spans pages, keeps the inspected window, and saves a deduplicated batch before application", async (t) => { const { fixture, namespace, agent, base } = await createPolicyConsole(t, grants); // An existing admitted rule must survive a batch: the API saves a complete desired policy. @@ -118,6 +226,9 @@ test("denial selection spans pages, keeps the inspected window, and saves a dedu await tab.getByRole("button", { name: "Allow selected (6)", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Review policy changes", exact: true }); await dialog.waitFor(); + await dialog.getByText("6 denials selected → 2 new rules", { exact: true }).waitFor(); + await dialog.getByText("How the selection becomes rules", { exact: true }).click(); + assert.match(await dialog.locator(".sandbox-selection-breakdown").innerText(), /already covered/); assert.equal(pathRequests(requests, "PUT", base).length, 0, "review cannot save or apply"); const validations = pathRequests(requests, "POST", `${base}/validate`); assert.equal(validations.length, 1); @@ -135,7 +246,7 @@ test("denial selection spans pages, keeps the inspected window, and saves a dedu .getByRole("button", { name: "Save and apply to current revision", exact: true }) .click(); await page - .locator(".sandbox-policy-dialog") + .locator(".sandbox-network-card") .getByText(/Application to the current revision is pending/) .waitFor(); const saved = (await fixture.request("GET", base)).data.policy; @@ -166,6 +277,13 @@ test("a policy batch outside the Installation ceiling retains its draft and does const requests = apiRequests(page, fixture.origin); await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); await page.getByText("2 in loaded window", { exact: true }).waitFor(); + // A fresh policy has no confirmed provider version and was never saved for later. + await page + .locator(".sandbox-policy-rule-status") + .getByText("Application unconfirmed", { exact: true }) + .waitFor(); + assert.equal(await page.locator(".sandbox-application-feedback").count(), 0); + await page.getByLabel("Select page", { exact: true }).check(); await page.getByRole("button", { name: "Allow selected (2)", exact: true }).click(); // The real validate route rejects an undelegated addition. Its error stays visible inside @@ -187,3 +305,57 @@ test("a policy batch outside the Installation ceiling retains its draft and does grants[1]!.host, ); }); + +test("unconfirmed application requires status inspection before an explicit retry and displays retry refusal", async (t) => { + const { fixture, namespace, agent, base } = await createPolicyConsole(t, grants); + const { revision } = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + assert.equal( + ( + await fixture.request("POST", `${base}/apply`, { + body: { expectedVersion: 1, revisionId: revision.id }, + }) + ).status, + 202, + ); + const { page } = await newPage(t, fixture); + // Simulate only the observed failed status: this fixture has no worker. The retry still + // reaches real HTTP/IAM/admission, which refuses the actually pending application. + await page.route(fixture.origin + base, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const response = await route.fetch(); + const payload = await response.json(); + payload.data.policy.application.state = "failed"; + payload.data.policy.application.reason = "SANDBOX_POLICY_UNCONFIRMED"; + // A failed application re-enables the public action hints; preserve a coherent view. + for (const action of ["validate", "save", "apply"]) { + payload.data.configuration.actions[action].available = true; + } + await route.fulfill({ response, json: payload }); + }); + const requests = apiRequests(page, fixture.origin); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, revision.id, "policy")); + const card = page.locator(".sandbox-network-card"); + const outcome = card.locator(".sandbox-application-feedback"); + await outcome.getByRole("heading", { name: "Application unconfirmed", exact: true }).waitFor(); + assert.equal( + await card.getByRole("button", { name: "Retry application", exact: true }).count(), + 0, + ); + await outcome.getByRole("button", { name: "Refresh status", exact: true }).click(); + await outcome.getByRole("button", { name: "Retry application", exact: true }).waitFor(); + assert.equal( + pathRequests(requests, "POST", `${base}/apply`).length, + 0, + "refresh must never request application", + ); + await outcome.getByRole("button", { name: "Retry application", exact: true }).click(); + await card + .getByRole("alert") + .filter({ hasText: "A policy application is already pending." }) + .waitFor(); + assert.equal(pathRequests(requests, "POST", `${base}/apply`).length, 1); + assert.equal((await fixture.request("GET", base)).data.policy.application.state, "pending"); +});