Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions dashboard/copilot-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" };
Expand Down
9 changes: 9 additions & 0 deletions dashboard/dev-post.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
118 changes: 116 additions & 2 deletions dashboard/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -2650,6 +2753,7 @@ form.addEventListener("submit", async (event) => {
}
allowPostRequestsInput.checked = false;
allowDevWritesInput.checked = false;
postCheckboxEnvironment = null;
updatePostRequestsOption();
requestInput.focus();
selectedScreenshots = [];
Expand Down Expand Up @@ -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
? {
Comment on lines +2971 to +2974
confirmed: true,
instanceId: sourceEditAuthorizationSelection.instanceId,
jobId: sourceEditAuthorizationSelection.jobId,
sourceEditReviewId: sourceEditAuthorizationSelection.requestId
} : {})
})
});
sourceEditAuthorizationSelection = null;
jobInputDetails.value = "";
jobInputMessage.textContent =
body.queued
Expand Down
6 changes: 5 additions & 1 deletion dashboard/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ <h2>Azure DevOps intake (optional)</h2>
<label><input id="allow-post-requests" type="checkbox" name="allowPostRequests"> Allow POST writes for this job in the selected environment</label>
<p id="post-requests-guidance" class="guidance">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.</p>
<label><input id="allow-dev-writes" type="checkbox" name="allowDevWrites"> <span id="origin-writes-label">Allow all DEV API writes for this job</span></label>
<p id="dev-writes-guidance" class="guidance">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.</p>
<p id="dev-writes-guidance" class="guidance">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.</p>
<fieldset>
<legend>Batch completion</legend>
<label><input type="checkbox" name="separatePullRequests"> Create a separate PR per bug</label>
Expand Down Expand Up @@ -347,6 +347,10 @@ <h2>Job</h2>
<section id="job-file-access-panel" hidden aria-labelledby="job-file-access-heading">
<h3 id="job-file-access-heading">This job's repository file trust</h3>
<p id="job-file-access-summary" class="guidance"></p>
<div id="source-edit-review" hidden>
<p id="source-edit-review-summary" class="guidance"></p>
<button id="source-edit-review-open" type="button">Review source-edit access</button>
</div>
<label for="job-file-access-mode">Reviewed access for this job, including resumes</label>
<select id="job-file-access-mode">
<option value="manual">Individual approvals</option>
Expand Down
40 changes: 38 additions & 2 deletions dashboard/runtime-permission-policy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { lstatSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { safeRuntimeText } from "./runtime-requests.js";
Expand Down Expand Up @@ -55,7 +56,8 @@ function safeProfile(path) {
!sensitive(profile);
}

export function createRuntimePermissionPolicy(repository, protectedPaths = [], fileAccess = null, dashboardPort = null) {
export function createRuntimePermissionPolicy(repository, protectedPaths = [], fileAccess = null, dashboardPort = null,
sessionId = null, copilotHome = join(homedir(), ".copilot")) {
if (!Array.isArray(protectedPaths) || protectedPaths.some((path) => typeof path !== "string" || !isAbsolute(path))) {
throw new Error("Protected runtime paths must be an array of absolute paths.");
}
Expand Down Expand Up @@ -108,6 +110,26 @@ export function createRuntimePermissionPolicy(repository, protectedPaths = [], f
contained(path, realpathSync.native(fileURLToPath(new URL("../", import.meta.url)))) === helperPath &&
lstatSync(path).isFile();

function checkpointRead(request, prompt) {
if (request.kind !== "read" || !prompt.canApprove || typeof request.path !== "string" ||
!isAbsolute(request.path) || !/^[a-f0-9-]{36}$/.test(sessionId ?? "") ||
request.managedApprovalRequired || request.requestSandboxBypass || request.requestSandboxPermissive ||
request.sandboxPathGrant || request.warning || request.hasWriteFileRedirection) { return null; }
try {
if (lstatSync(copilotHome).isSymbolicLink() || realpathSync.native(copilotHome) !== copilotHome) { return null; }
const directory = join(copilotHome, "session-state", sessionId, "checkpoints");
if (dirname(request.path) !== directory || !/^[^\\/]+\.md$/i.test(basename(request.path))) { return null; }
const file = contained(request.path, copilotHome);
if (!file || protectedPath(file) || (request.resolvedPath && request.resolvedPath !== file)) { return null; }
const stat = lstatSync(file);
return stat.isFile() && stat.nlink === 1 && stat.size <= 1024 * 1024
? "current session checkpoint Markdown (read only)" : null;
} catch (error) {
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code)) { return null; }
throw error;
}
}

function candidate(request, prompt, automatic = false) {
if (!eligible(request, prompt)) {
return null;
Expand Down Expand Up @@ -343,7 +365,7 @@ export function createRuntimePermissionPolicy(repository, protectedPaths = [], f
commands.clear();
},
automaticApproval(request, prompt) {
return candidate(request, prompt, true)?.key ?? null;
return checkpointRead(request, prompt) ?? candidate(request, prompt, true)?.key ?? null;
},
review(request, prompt) {
const match = candidate(request, prompt);
Expand All @@ -356,6 +378,20 @@ export function createRuntimePermissionPolicy(repository, protectedPaths = [], f
isSourceFile(path) {
return candidate({ kind: "write", fileName: path }, { canApprove: true })?.scope === "repository-source-write";
},
reviewSourceEdit(request, prompt) {
if (request.kind !== "write" || prompt.canApprove || prompt.canUseFileTrust !== true ||
prompt.canRecover !== true || prompt.blockedReasons?.length !== 1 ||
prompt.blockedReasons[0] !== "oversized" ||
candidate(request, { ...prompt, canApprove: true })?.scope !== "repository-source-write") {
return null;
}
const fileName = relative(root(), resolve(root(), request.fileName));
if (!safeRuntimeText(fileName, 400)) { return null; }
return {
fileName,
reviewCharacters: prompt.reviewCharacters
};
},
allows(request, prompt) {
const match = candidate(request, prompt);
return Boolean(match && (match.scope === "repository-reads"
Expand Down
Loading
Loading