From 4d08f08a8dd22f876aecdb2335f8cc1cb78db1f7 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Fri, 6 Nov 2026 10:44:33 -0500 Subject: [PATCH] feat: streamline scoped test permissions and PPE defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46e778f9-d964-46b0-bebe-3b6f9f71eb74 --- dashboard/copilot-session.js | 8 +- dashboard/dev-post.js | 9 ++ dashboard/public/app.js | 118 ++++++++++++++++++- dashboard/public/index.html | 6 +- dashboard/runtime-permission-policy.js | 40 ++++++- dashboard/runtime-requests.js | 33 ++++++ dashboard/server.js | 61 +++++++++- docs/architecture.md | 16 +++ docs/commands.md | 32 ++++- docs/security.md | 10 ++ e2e/dashboard-readiness.spec.js | 111 +++++++++++++++++- mcp/dashboard-client.js | 11 +- test/dashboard-runtime-policy.test.js | 60 ++++++++++ test/dashboard-runtime-requests.test.js | 22 ++++ test/dashboard-server.test.js | 149 ++++++++++++++++++++++++ 15 files changed, 669 insertions(+), 17 deletions(-) diff --git a/dashboard/copilot-session.js b/dashboard/copilot-session.js index 06a93bd..e3bc71f 100644 --- a/dashboard/copilot-session.js +++ b/dashboard/copilot-session.js @@ -65,7 +65,8 @@ export async function runCopilotSession(config, { client, ask, output, onUsage = config.dashboardPort < 1 || config.dashboardPort > 65535)) { throw new Error("Reviewed validation-gate trust requires the owning dashboard's current port."); } - const permissions = createRuntimePermissionPolicy(config.repository, config.protectedPaths, fileAccess, config.dashboardPort); + const permissions = createRuntimePermissionPolicy(config.repository, config.protectedPaths, fileAccess, + config.dashboardPort, config.sessionId); const publication = createPublicationPermissions(config.repository, config.protectedPaths); let publicationGranted = false; let policyReady = false; @@ -162,8 +163,11 @@ export async function runCopilotSession(config, { client, ask, output, onUsage = } }, null, 2); } + const sourceEditReview = policyReady && config.mode === "fix-and-validate" + ? permissions.reviewSourceEdit(request, prompt) : null; const response = await ask({ - ...prompt, ...(grantScope ? { grantScope } : {}), ...(canAllowAll ? { canAllowAll: true } : {}) + ...prompt, ...(grantScope ? { grantScope } : {}), ...(canAllowAll ? { canAllowAll: true } : {}), + ...(sourceEditReview ? { sourceEditReview } : {}) }); if (budgetReached) { return { kind: "reject" }; diff --git a/dashboard/dev-post.js b/dashboard/dev-post.js index f75de0a..b4787cb 100644 --- a/dashboard/dev-post.js +++ b/dashboard/dev-post.js @@ -109,6 +109,15 @@ export function createPpeWriteApproval(repository, targetEnvironment, jobId) { return createOriginWriteApproval(repository, targetEnvironment, jobId, "ppe"); } +export function inspectPpeWriteConfiguration(repository) { + try { + return { configured: Boolean(configurationAt(repository, "ppe", true, true)), error: null }; + } catch (error) { + return { configured: false, error: safeRuntimeText(error.message, 1000) + ? error.message : "PPE write setup validation failed; review the configured origins and authentication helper." }; + } +} + export function isOriginWriteApproval(approval) { return approval?.mode === "dev-origin" || approval?.mode === "ppe-origin"; } diff --git a/dashboard/public/app.js b/dashboard/public/app.js index cfe1807..85770cc 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -187,7 +187,7 @@ function updatePostRequestsOption() { const key = available ? environment : ""; if (postCheckboxEnvironment !== key) { allowPostRequestsInput.checked = false; - allowDevWritesInput.checked = false; + allowDevWritesInput.checked = key === "ppe" && repositoryReadiness?.ppeWriteConfigured === true; postCheckboxEnvironment = key; } allowDevWritesInput.disabled = !available || !["dev", "ppe"].includes(environment); @@ -405,11 +405,72 @@ const fileAccessMessage = document.querySelector("#job-file-access-message"); const fileAccessSave = document.querySelector("#job-file-access-save"); let fileAccessSelection = null; let fileAccessBusy = false; +const sourceEditReviewPanel = document.querySelector("#source-edit-review"); +const sourceEditReviewSummary = document.querySelector("#source-edit-review-summary"); +const sourceEditReviewOpen = document.querySelector("#source-edit-review-open"); +let sourceEditReviewSelection = null; +let sourceEditAuthorizationSelection = null; + +function prepareSourceEditAuthorization(job) { + const review = job.sourceEditReview; + jobInputDetails.value = review.authorization; + jobInputAction.value = job.status === "running" ? "comment" : "continue"; + sourceEditAuthorizationSelection = { + instanceId: dashboardInstanceId, jobId: job.id, requestId: review.requestId, + authorization: review.authorization + }; + sourceEditReviewSelection = null; + fileAccessMessage.textContent = "File trust is reviewed. Read the fresh authorization below and send it to continue. No edit has been replayed."; + openWorkspaceSection("job-input-panel"); + jobInputDetails.focus(); +} + +async function reviewSourceEditAccess() { + const job = activeJob; + const review = job?.sourceEditReview; + if (!review || job.mode !== "fix-and-validate" || runtimeBusy || fileAccessBusy) { return; } + const selection = { + instanceId: dashboardInstanceId, jobId: job.id, requestId: review.requestId + }; + if (job.status === "running" && + !window.confirm("Stop only this job's executor to review source-edit access? Pending operations are not approved or replayed. File trust and a fresh attempt require separate explicit confirmation.")) { + return; + } + sourceEditReviewSelection = selection; + setRuntimeBusy(true); + try { + if (job.status === "running") { + const body = await fetchJson("/api/job/source-edit-review", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...selection, confirmed: true }) + }); + if (dashboardInstanceId !== selection.instanceId || activeJob?.id !== selection.jobId) { + throw new Error("The job changed while stopping. Refresh before reviewing access."); + } + activeJob = body.job; + rememberJob(body.job); + renderJob(body.job, body.job); + } else { + renderFileAccess(job, true); + } + if (sourceEditReviewSelection) { openWorkspaceSection("job-file-access-panel"); } + } catch (error) { + sourceEditReviewSelection = null; + runtimeMessage.textContent = error.message; + fileAccessMessage.textContent = error.message; + } finally { + setRuntimeBusy(false); + } +} + +sourceEditReviewOpen.addEventListener("click", () => void reviewSourceEditAccess()); function renderFileAccess(job, isActive) { fileAccessPanel.hidden = !job || job.runtime !== "copilot"; if (fileAccessPanel.hidden) { fileAccessSelection = null; + sourceEditReviewSelection = null; + sourceEditAuthorizationSelection = null; return; } const mode = job.fileAccess?.mode ?? "manual"; @@ -426,6 +487,40 @@ function renderFileAccess(job, isActive) { fileAccessSummary.textContent = mode === "manual" ? "Individual file approvals are active." : `${mode === "source-read-write" ? "Read and edit" : "Read"} non-sensitive source/test files for this job and checkout, including resumes. ${job.fileAccess?.allowValidationGates ? "Dashboard-owned validation gates are also trusted; other commands remain manual." : "Commands remain separately reviewed."}`; const editable = isActive && ["blocked", "failed", "passed"].includes(job.status); + const review = isActive && job.mode === "fix-and-validate" && job.status !== "passed" + ? job.sourceEditReview : null; + sourceEditReviewPanel.hidden = !review; + if (sourceEditReviewSelection && + (sourceEditReviewSelection.instanceId !== dashboardInstanceId || + sourceEditReviewSelection.jobId !== job.id || + sourceEditReviewSelection.requestId !== review?.requestId)) { + sourceEditReviewSelection = null; + } + if (sourceEditAuthorizationSelection && + (sourceEditAuthorizationSelection.instanceId !== dashboardInstanceId || + sourceEditAuthorizationSelection.jobId !== job.id || + sourceEditAuthorizationSelection.requestId !== review?.requestId)) { + sourceEditAuthorizationSelection = null; + } + if (review) { + sourceEditReviewSummary.textContent = + `${review.fileName}: ${review.reviewCharacters.toLocaleString()} review characters exceed inline approval's 8,192-character limit. ` + + "Nothing was approved. Review job-wide source/test access, then explicitly authorize a fresh attempt; creating another job or reinstalling does not remove this limit."; + sourceEditReviewOpen.hidden = job.runtimeRequests?.some((item) => + item.id === review.requestId && item.sourceEditReview) ?? false; + sourceEditReviewOpen.disabled = runtimeBusy || fileAccessBusy || job.executorStopping; + if (sourceEditReviewSelection && sourceEditReviewSelection.instanceId === dashboardInstanceId && + sourceEditReviewSelection.jobId === job.id && + sourceEditReviewSelection.requestId === review.requestId && editable && !fileAccessBusy) { + if (mode === "source-read-write") { + prepareSourceEditAuthorization(job); + } else if (!sourceEditReviewSelection.trustPrepared) { + sourceEditReviewSelection = { ...sourceEditReviewSelection, trustPrepared: true }; + fileAccessMode.value = "source-read-write"; + fileAccessMessage.textContent = "Review source/test edit access for this job and checkout, not just this file. Update stopped job file trust to confirm; this does not approve or replay the hidden operation."; + } + } + } fileAccessMode.disabled = fileAccessBusy || !editable; fileAccessSave.disabled = fileAccessBusy || !editable; fileAccessGates.disabled = fileAccessBusy || !editable || fileAccessMode.value === "manual"; @@ -769,6 +864,14 @@ function renderRuntimeRequest(job, isActive) { if (pending.canRecover === true) { button(runtimeActions, "Deny and request safe recovery", "deny", undefined, undefined, true); } + if (pending.sourceEditReview && job.mode === "fix-and-validate") { + const review = document.createElement("button"); + review.type = "button"; + review.textContent = "Review source-edit access"; + review.disabled = runtimeBusy; + review.addEventListener("click", () => void reviewSourceEditAccess()); + runtimeActions.append(review); + } button(runtimeActions, "Deny", "deny"); } else { pending.choices.forEach((choice, index) => { @@ -2650,6 +2753,7 @@ form.addEventListener("submit", async (event) => { } allowPostRequestsInput.checked = false; allowDevWritesInput.checked = false; + postCheckboxEnvironment = null; updatePostRequestsOption(); requestInput.focus(); selectedScreenshots = []; @@ -2863,9 +2967,19 @@ jobInputSubmit.addEventListener("click", async () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, - details + details, + ...(sourceEditAuthorizationSelection && sourceEditAuthorizationSelection.instanceId === dashboardInstanceId && + sourceEditAuthorizationSelection.jobId === activeJob?.id && + details === sourceEditAuthorizationSelection.authorization + ? { + confirmed: true, + instanceId: sourceEditAuthorizationSelection.instanceId, + jobId: sourceEditAuthorizationSelection.jobId, + sourceEditReviewId: sourceEditAuthorizationSelection.requestId + } : {}) }) }); + sourceEditAuthorizationSelection = null; jobInputDetails.value = ""; jobInputMessage.textContent = body.queued diff --git a/dashboard/public/index.html b/dashboard/public/index.html index d54ee3e..8115fcb 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -303,7 +303,7 @@

Azure DevOps intake (optional)

Native Copilot, DEV/SIT/UAT/PPE only; off by default. Checking this authorizes configured endpoint/test-record writes for this job without repeat confirmation. If unchecked, each write needs confirmation. Saved endpoint/test-record/authentication configuration is still required. Approval expires on stop, hold, completion or restart; configuration changes require a newly reviewed job. Explicit read-only/intercepted modes and production remain blocked. Browser guards are not disabled.

-

Native Copilot and DEV/PPE only; off by default. Authorizes POST, PUT, PATCH and DELETE to any path on the selected environment's reviewed dataSafety.devWrite or dataSafety.ppeWrite HTTPS origins, affecting any record your account can access. DEV and PPE grants are separate. No endpoint/test-record list or repeat confirmation. Existing SSO helper required. Expires on stop, hold, completion or restart. Other environments, explicit read-only/intercepted modes and browser-guard bypass remain blocked. Separate from Allow all tools.

+

Native Copilot and DEV/PPE only. PPE defaults on only when PPE origins are configured; uncheck to opt out. DEV remains off by default. Authorizes POST, PUT, PATCH and DELETE to any path on the selected environment's reviewed dataSafety.devWrite or dataSafety.ppeWrite HTTPS origins, affecting any record your account can access. DEV and PPE grants are separate. No endpoint/test-record list or repeat confirmation. Existing SSO helper required. Expires on stop, hold, completion or restart. Other environments, explicit read-only/intercepted modes and browser-guard bypass remain blocked. Separate from Allow all tools.

Batch completion @@ -347,6 +347,10 @@

Job